From bd6c60bcf434fc4102af776ebbb7b8a1eedd7d6f Mon Sep 17 00:00:00 2001 From: 0xtj24 Date: Wed, 13 Aug 2025 14:11:31 +0200 Subject: [PATCH 1/3] add scrvUSD oracle --- foundry.toml | 1 + src/oracle/AggregatorV3CurveScrvUSD.sol | 45 +++++++++++++++++++ src/test/unit/ScrvUSDXDC.t.sol | 58 +++++++++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 src/oracle/AggregatorV3CurveScrvUSD.sol create mode 100644 src/test/unit/ScrvUSDXDC.t.sol diff --git a/foundry.toml b/foundry.toml index 8795537..7308416 100644 --- a/foundry.toml +++ b/foundry.toml @@ -36,4 +36,5 @@ number_underscore = "preserve" [profile.default.rpc_endpoints] mainnet = "${MAINNET_RPC_URL}" scroll = "${SCROLL_RPC_URL}" +xdc = "${XDC_RPC_URL}" tenderly = "${TENDERLY_FORK_URL}" diff --git a/src/oracle/AggregatorV3CurveScrvUSD.sol b/src/oracle/AggregatorV3CurveScrvUSD.sol new file mode 100644 index 0000000..ada21bb --- /dev/null +++ b/src/oracle/AggregatorV3CurveScrvUSD.sol @@ -0,0 +1,45 @@ +pragma solidity ^0.8.17; + +interface ICurvePool { + function price_oracle(uint256 k) external view returns (uint256); +} + +interface ISCRVUSDOracle { + function price_v0() external view returns (uint256); +} + +/** + * @title AggregatorV3CurveScrvUSD + * @notice Uses curve scrvUSD oracle for getting the exchange rate scrvUSD/crvUSD + */ +contract AggregatorV3CurveScrvUSD { + ICurvePool public immutable pool; + uint256 public immutable k; + bool public immutable invert; + ISCRVUSDOracle public immutable scrvUSDOracle; + + constructor(address _pool, uint256 _k, bool _invert, address _scrvUSDOracle) { + pool = ICurvePool(_pool); + k = _k; + invert = _invert; + scrvUSDOracle = ISCRVUSDOracle(_scrvUSDOracle); + } + + + /// @notice Return the latest redemption rate in stETH for 1 wstETH and the current block timestamp + function latestRoundData() + public + view + returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) + { + uint256 price = pool.price_oracle(k); + price = invert ? 1e36 / price : price; + uint256 scrvUSDRate = scrvUSDOracle.price_v0(); + price = (price * scrvUSDRate) / 1e18; + return (0, int256(price), 0, block.timestamp, 0); + } + + function decimals() public pure returns (uint256) { + return 18; + } +} diff --git a/src/test/unit/ScrvUSDXDC.t.sol b/src/test/unit/ScrvUSDXDC.t.sol new file mode 100644 index 0000000..7051474 --- /dev/null +++ b/src/test/unit/ScrvUSDXDC.t.sol @@ -0,0 +1,58 @@ +pragma solidity ^0.8.17; + +import "forge-std/Test.sol"; + + +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {wdiv, wmul} from "../../utils/Math.sol"; +import {AggregatorV3CurveScrvUSD} from "src/oracle/AggregatorV3CurveScrvUSD.sol"; +import {ChainlinkOracle, AggregatorV3Interface} from "src/oracle/ChainlinkOracle.sol"; + +contract ScrvUSDXDCFeedTest is Test { + ChainlinkOracle feed; + AggregatorV3CurveScrvUSD internal curveOracle; + + address curvePool = 0x24894F0c4f80837d61CA21730A75Fa216FED7200; // scrvUSD/USDC.e pool + address scrvUSDRateXDC = 0x09F8D940EAD55853c51045bcbfE67341B686C071; + address scrvUSD = 0x3d8EADb739D1Ef95dd53D718e4810721837c69c1; // scrvUSD on XDC + uint256 k = 0; // returns USDC/scrvUSD(normalized) price + + + function setUp() public { + vm.createSelectFork("xdc"); + curveOracle = new AggregatorV3CurveScrvUSD(curvePool,k, true, scrvUSDRateXDC); // we want to invert to get cUSDO/USDC price (normalized) + feed = ChainlinkOracle( + address( + new ERC1967Proxy( + address( + new ChainlinkOracle() + ), + abi.encodeWithSelector(ChainlinkOracle.initialize.selector, address(this), address(this)) + ) + ) + ); + address[] memory tokens = new address[](1); + ChainlinkOracle.Oracle[] memory oracles = new ChainlinkOracle.Oracle[](1); + tokens[0] = scrvUSD; + oracles[0] = ChainlinkOracle.Oracle(AggregatorV3Interface(address(curveOracle)), 1, 1e18); + feed.setOracles(tokens,oracles); + assertTrue(feed.getStatus(scrvUSD)); + } + + function test_deploy() public { + assertNotEq(address(feed), address(0)); + } + + function test_spot() public { + // get the price from the curve oracle and invert it to get scrvUSD/USDC + uint256 usdcScrvUSDPrice = curveOracle.pool().price_oracle(k); + uint256 scrvUSDusdc = 1e36 / usdcScrvUSDPrice; + //multiply scrvUSD/USDC normalized with exchange rate to get scrvUSD/USDC + uint256 price = curveOracle.scrvUSDOracle().price_v0() * uint256(scrvUSDusdc) / 10 ** AggregatorV3CurveScrvUSD(curveOracle).decimals(); + assertEq(price, feed.spot(scrvUSD)); + (, int256 answer, ,uint256 updatedAt , ) = AggregatorV3CurveScrvUSD(curveOracle).latestRoundData(); + assertEq(uint(answer), price); + assertEq(updatedAt, block.timestamp); + console2.log("price", feed.spot(scrvUSD)); + } +} From cea6ddb729a79901a658a6e14800528b8764ba4b Mon Sep 17 00:00:00 2001 From: Alexandru Marcu Date: Mon, 18 Aug 2025 23:06:40 +0300 Subject: [PATCH 2/3] Deploy scrvUSD --- scripts/config_xdc.js | 83 +++++++++++++---- scripts/deploy_xdc.js | 172 ++++++++++++++++++++++++++++++++++- scripts/utils/deployUtils.js | 109 +++++++++++++++++++--- 3 files changed, 334 insertions(+), 30 deletions(-) diff --git a/scripts/config_xdc.js b/scripts/config_xdc.js index 58c244d..b5a7c7e 100644 --- a/scripts/config_xdc.js +++ b/scripts/config_xdc.js @@ -9,46 +9,53 @@ module.exports = { }, "initialDebtCeiling": toWad('100000000'), }, + "Flashlender_usdce": { + "constructorArguments": { + "protocolFee_": toWad('0') + }, + "initialDebtCeiling": toWad('100000000'), + }, "WXDC": "0x951857744785E80e2De051c32EE7b25f9c458C42", // XDC native token wrapped "USDC": "0xcc0587aebda397146cc828b445db130a94486e74", // USDC on XDC "WETH": "0xa7348290de5cf01772479c48D50dec791c3fC212", // WETH on XDC + "PenpieHelper": "0x0000000000000000000000000000000000000000", // PenpieHelper not available on XDC "AddressProviderV3": "0xE67f77af54EdA6B92f2dBaB272b8C0817ae0bCa3", - "PoolV3_LpXDC": "0xED166436559Fd3d7f44cb00CACDA96EB999D789e", // To be filled after deployment - "PoolV3_LpUSDC": "", // To be filled after deployment + "PoolV3_LpXDC": "0xED166436559Fd3d7f44cb00CACDA96EB999D789e", + "PoolV3_lpUSDCe": "0x235e49CC709F9e262814795c00eabe73709ef8E2", "FlashlenderLPXDC": "", // To be filled after deployment "FlashlenderLPUSDC": "", // To be filled after deployment "VaultRegistry": "", // To be filled after deployment "ProxyRegistry": "", // To be filled after deployment - "PoolQuotaKeeperV3": "", // To be filled after deployment - "GaugeV3": "", // To be filled after deployment + "PoolQuotaKeeperV3": "0x796e374f741AfA9205B61328363404DA047CB5bC", // PoolQuotaKeeperV3_LpXDC + "GaugeV3": "0x535F701F85F75a39D93b50E3dD04a7cf137F1EA6", // GaugeV3_LpXDC "Actions": { "SwapAction": { "constructorArguments": { - "balancerVault": "", // To be filled with XDC Balancer Vault - "uniV3Router": "", // To be filled with XDC Uniswap V3 Router - "pendleRouter": "", // To be filled with XDC Pendle Router - "kyberRouter": "", // To be filled with XDC Kyber Router - "tranchessRouter": "", // To be filled with XDC Tranchess Router - "spectraRouter": "" // To be filled with XDC Spectra Router + "balancerVault": "0x0000000000000000000000000000000000000000", // Balancer not available on XDC + "uniV3Router": "0x0000000000000000000000000000000000000000", // Uniswap V3 not available on XDC + "pendleRouter": "0x0000000000000000000000000000000000000000", // Pendle not available on XDC + "kyberRouter": "0x0000000000000000000000000000000000000000", // Kyber not available on XDC + "tranchessRouter": "0x0000000000000000000000000000000000000000", // Tranchess not available on XDC + "spectraRouter": "0x0000000000000000000000000000000000000000" // Spectra not available on XDC } }, "PoolAction": { "constructorArguments": { - "balancerVault": "", // To be filled with XDC Balancer Vault - "pendleRouter": "", // To be filled with XDC Pendle Router - "tranchessRouter": "", // To be filled with XDC Tranchess Router - "spectraRouter": "" // To be filled with XDC Spectra Router + "balancerVault": "0x0000000000000000000000000000000000000000", // Balancer not available on XDC + "pendleRouter": "0x0000000000000000000000000000000000000000", // Pendle not available on XDC + "tranchessRouter": "0x0000000000000000000000000000000000000000", // Tranchess not available on XDC + "spectraRouter": "0x0000000000000000000000000000000000000000" // Spectra not available on XDC } }, }, "Gearbox": { "initialGlobalDebtCeiling": "115792089237316195423570985008687907853269984665640564039457584007913129639935", // max uint256 }, - "Treasury": { + "Treasury_usdce": { "constructorArguments": { "payees":[ "0xA719A90a173E8F00618596ffb594F5FfA79d915D", - "stakingLpXDC" + "0xe1987f6cD0b8823a033c29d640596757492479BD" // StakingLPUSDCE ], "shares":[ 200, @@ -78,6 +85,50 @@ module.exports = { "Vendors": { }, "Vaults": { + "Vaults_scrvUSD": { + name: "Vaults_scrvUSD", + description: "This vault allows for borrowing and lending of scrvUSD assets", + type: "CDPVault", + collateralType: "ERC20", + oracle: { + type: "Oracle_scrvUSD", + deploymentArguments: { + "curvePool": "0x24894F0c4f80837d61CA21730A75Fa216FED7200", + "scrvUSDRateXDC": "0x09F8D940EAD55853c51045bcbfE67341B686C071", // The LP token market + "scrvUSD": "0x3d8EADb739D1Ef95dd53D718e4810721837c69c1", + "k": 0 + }, + }, + token: "0x3d8EADb739D1Ef95dd53D718e4810721837c69c1", + poolAddress: "0x235e49CC709F9e262814795c00eabe73709ef8E2", // USDC.e pool address + tokenSymbol: "LOOP-scrvUSD", + tokenScale: toWad("1.0"), + protocolIcon: null, + deploymentArguments: { + constants: { + protocolFee: toWad("0.01"), + }, + configs: { + debtFloor: toWad("1"), + liquidationRatio: toWad("1.1"), + liquidationPenalty: toWad("0.99"), + liquidationDiscount: toWad("0.98"), + roleAdmin: "deployer", + vaultAdmin: "deployer", + pauseAdmin: "deployer", + }, + debtCeiling: toWad("100000000"), + }, + quotas: { + minRate: 100, + maxRate: 10000, + }, + "RewardManager": { + "artifactName": "src/pendle-rewards/RewardManager.sol:RewardManager", + "constructorArguments": [ + ] + } + }, }, "Tokenomics":{ diff --git a/scripts/deploy_xdc.js b/scripts/deploy_xdc.js index 6a0231c..e0e63b6 100644 --- a/scripts/deploy_xdc.js +++ b/scripts/deploy_xdc.js @@ -23,6 +23,7 @@ const { getVaultMetadata, getPoolAddress, deployPoolCore, + deployPoolCoreSelective, deployStakingAndLockingLP, deployActions, deployPositionActions, @@ -237,6 +238,63 @@ async function deployCore() { return deployedCore; } +async function deployUSDCePoolCore() { + console.log(` +/*////////////////////////////////////////////////////////////// + DEPLOYING USDC.e POOL CORE +//////////////////////////////////////////////////////////////*/ + `); + + // Load existing contract addresses from deployment file + const deploymentFilePath = await getDeploymentFilePath(); + const deployment = fs.existsSync(deploymentFilePath) ? JSON.parse(fs.readFileSync(deploymentFilePath)) : {}; + + const existingStakingAddress = deployment.core?.['StakingLPUSDCE']?.address; + const existingLockingAddress = deployment.core?.['LockingLpUSDCE']?.address; + + if (!existingStakingAddress || !existingLockingAddress) { + console.error('ERROR: Could not find existing staking or locking contracts for USDC.e'); + console.log('Available contracts:', Object.keys(deployment.core || {})); + return null; + } + + console.log(`Using existing StakingLPUSDCE at: ${existingStakingAddress}`); + console.log(`Using existing LockingLpUSDCE at: ${existingLockingAddress}`); + + // Custom position actions list excluding PositionActionPenpie (not available on XDC) + const customPositionActions = [ + 'PositionAction20', + 'PositionAction4626', + 'PositionActionPendle', + 'PositionActionTranchess' + // Skip PositionActionPenpie - not available on XDC + ]; + + // Deploy core contracts for USDC.e pool with selective deployment + // Skip staking and locking (already deployed), but deploy treasury, vault registry, flashlender, and actions + const deployedCore = await deployPoolCoreSelective( + CONFIG_NETWORK, + 'usdce', // pool type + 'PoolV3_lpUSDCe', // pool key from config + customPositionActions, // custom position actions excluding Penpie + { + skipStaking: true, + skipLocking: true, + skipTreasury: false, // Deploy treasury + skipVaultRegistry: false, // Deploy vault registry + skipActions: false, // Deploy flashlender, proxy registry, and position actions + existingContracts: { + // Use existing staking and locking contracts from deployment file + stakingLp: existingStakingAddress, + lockLp: existingLockingAddress + } + } + ); + + console.log('USDC.e Pool Core deployment completed'); + return deployedCore; +} + async function deployVaults() { console.log(` /*////////////////////////////////////////////////////////////// @@ -262,6 +320,64 @@ async function deployVaults() { ...Object.values(oracleConfig) ); return deployedOracle.address; + }, + 'Oracle_scrvUSD': async (key, config) => { + // Deploy AggregatorV3CurveScrvUSD oracle for scrvUSD + const oracleConfig = config.oracle.deploymentArguments; + console.log(`Deploying AggregatorV3CurveScrvUSD oracle for ${key}`); + + // Deploy the AggregatorV3CurveScrvUSD contract + const curveOracle = await deployContract( + 'AggregatorV3CurveScrvUSD', + `AggregatorV3CurveScrvUSD_${key}`, + false, + oracleConfig.curvePool, // _pool + oracleConfig.k, // _k + true, // _invert + oracleConfig.scrvUSDRateXDC // _scrvUSDOracle + ); + + console.log(`AggregatorV3CurveScrvUSD deployed at: ${curveOracle.address}`); + + // Deploy ChainlinkOracle implementation + const chainlinkOracleImpl = await deployContract( + 'ChainlinkOracle', + `ChainlinkOracle_Impl_${key}`, + false + ); + + console.log(`ChainlinkOracle implementation deployed at: ${chainlinkOracleImpl.address}`); + + // Deploy ERC1967Proxy for ChainlinkOracle + const signer = await getSignerAddress(); + const ERC1967Proxy = await ethers.getContractFactory('ERC1967Proxy'); + + // Create initialization data for the proxy + const initData = chainlinkOracleImpl.interface.encodeFunctionData('initialize', [signer, signer]); + + // Deploy the proxy + const proxy = await ERC1967Proxy.deploy( + chainlinkOracleImpl.address, + initData + ); + await proxy.deployed(); + + const chainlinkOracle = await ethers.getContractAt('ChainlinkOracle', proxy.address); + + console.log(`ChainlinkOracle proxy deployed at: ${chainlinkOracle.address}`); + + // Set up the oracle mapping + const tokens = [config.token]; // scrvUSD token address + const oracles = [{ + aggregator: curveOracle.address, + stalePeriod: 1, // 1 second stale period (very fresh) + aggregatorScale: ethers.utils.parseEther('1') // 1e18 scale + }]; + + await chainlinkOracle.setOracles(tokens, oracles); + console.log(`Oracle configured for token ${config.token}`); + + return chainlinkOracle.address; } }, CONFIG_NETWORK); @@ -414,6 +530,42 @@ async function performTransactions() { console.log('Pool unlocked'); } +async function storeVaultMetadataForGauge() { + console.log(` +/*////////////////////////////////////////////////////////////// + STORING VAULT METADATA +//////////////////////////////////////////////////////////////*/ + `); + + // Load deployed vaults to get their addresses + const deployedVaults = await loadDeployedVaults(); + + for (const [vaultName, vault] of Object.entries(deployedVaults)) { + // Find corresponding vault config + const vaultKey = vaultName.replace('CDPVault_', ''); + const vaultConfig = CONFIG_NETWORK.Vaults[vaultKey]; + + if (!vaultConfig) { + console.log(`No config found for vault ${vaultName}, skipping metadata storage`); + continue; + } + + // Store metadata including pool address and quotas + const metadata = { + pool: CONFIG_NETWORK.Core.PoolV3_lpUSDCe, // Pool address this vault is associated with + quotas: vaultConfig.quotas, // Min and max rates from config + tokenSymbol: vaultConfig.tokenSymbol, + token: vaultConfig.token + }; + + await storeVaultMetadata(vault.address, metadata); + console.log(`Stored metadata for vault ${vaultName} at ${vault.address}`); + console.log(` Pool: ${metadata.pool}`); + console.log(` Min Rate: ${metadata.quotas.minRate}`); + console.log(` Max Rate: ${metadata.quotas.maxRate}`); + } +} + async function main() { try { // Initialize deployment with account impersonation @@ -422,10 +574,24 @@ async function main() { // Deploy pools (XDC and USDC) // await deployPool(); - await performTransactions(); + // await performTransactions(); + + // Deploy USDC.e pool core contracts (treasury, vault registry, flashlender, actions) + const deployedUSDCeCore = await deployUSDCePoolCore(); + + // Deploy vaults + await deployVaults(); + + // Store vault metadata (needed for gauge configuration) + await storeVaultMetadataForGauge(); + + // Register vaults in the vault registry (use the one we just deployed) + const tempConfig = { ...CONFIG_NETWORK }; + tempConfig.Core.VaultRegistry = deployedUSDCeCore.vaultRegistry.address; + await registerVaults(tempConfig); - // Deploy core contracts - // const deployedCore = await deployCore(); + // Configure gauge (set min/max rates for vaults) + await deployGauge(CONFIG_NETWORK.Core.PoolV3_lpUSDCe, CONFIG_NETWORK, true); // // Finalize deployment // await finalizeDeployment(); diff --git a/scripts/utils/deployUtils.js b/scripts/utils/deployUtils.js index 596bf38..8b537b8 100644 --- a/scripts/utils/deployUtils.js +++ b/scripts/utils/deployUtils.js @@ -427,6 +427,24 @@ async function loadDeployedRewardManagers() { * @param {Array} [customPositionActions] - Optional custom list of position actions to deploy */ async function deployPoolCore(config, poolType, poolKey, customPositionActions) { + return await deployPoolCoreSelective(config, poolType, poolKey, customPositionActions, {}); +} + +/** + * Deploys core contracts with selective deployment options + * @param {Object} config - The network configuration object + * @param {string} poolType - The pool type ('eth' or 'usdc') + * @param {string} poolKey - The key of the pool in config.Core (e.g., 'PoolV3_LpUSD') + * @param {Array} [customPositionActions] - Optional custom list of position actions to deploy + * @param {Object} [options] - Deployment options + * @param {boolean} [options.skipStaking] - Skip staking contract deployment + * @param {boolean} [options.skipLocking] - Skip locking contract deployment + * @param {boolean} [options.skipTreasury] - Skip treasury deployment + * @param {boolean} [options.skipVaultRegistry] - Skip vault registry deployment + * @param {boolean} [options.skipActions] - Skip actions deployment + * @param {Object} [options.existingContracts] - Existing contract addresses to use instead of deploying + */ +async function deployPoolCoreSelective(config, poolType, poolKey, customPositionActions, options = {}) { const signer = await getSignerAddress(); if (hre.network.name == 'tenderly') { @@ -442,21 +460,89 @@ async function deployPoolCore(config, poolType, poolKey, customPositionActions) const pool = await attachContract('PoolV3', config.Core[poolKey]); console.log(`Using pool ${poolKey} at address: ${pool.address}`); - const { stakingLp, lockLp } = await deployStakingAndLockingLP(pool, poolType); + let stakingLp, lockLp; + + // Deploy or attach staking and locking contracts + if (options.skipStaking && options.skipLocking) { + console.log('Skipping staking and locking contract deployment'); + // Use existing contracts if provided + if (options.existingContracts?.stakingLp) { + stakingLp = await attachContract('StakingLPEth', options.existingContracts.stakingLp); + console.log(`Using existing staking contract at: ${stakingLp.address}`); + } + if (options.existingContracts?.lockLp) { + lockLp = await attachContract('Locking', options.existingContracts.lockLp); + console.log(`Using existing locking contract at: ${lockLp.address}`); + } + } else { + const deployed = await deployStakingAndLockingLP(pool, poolType); + stakingLp = deployed.stakingLp; + lockLp = deployed.lockLp; + } + console.log('staking lp property name', `stakingLp${poolType.toUpperCase()}`); - const treasuryReplaceParams = { - 'deployer': signer, - [`stakingLp${poolType.toUpperCase()}`]: stakingLp.address - }; + let treasury; + if (options.skipTreasury) { + console.log('Skipping treasury deployment'); + if (options.existingContracts?.treasury) { + treasury = await attachContract('Treasury', options.existingContracts.treasury); + console.log(`Using existing treasury at: ${treasury.address}`); + } + } else { + // Use pool-specific treasury config if available, otherwise fall back to default + const treasuryConfigKey = `Treasury_${poolType}`; + const treasuryConfig = config.Core[treasuryConfigKey] || config.Core.Treasury; + + if (!treasuryConfig) { + throw new Error(`No treasury configuration found for pool type "${poolType}". Expected "${treasuryConfigKey}" or "Treasury" in config.Core`); + } + + const treasuryReplaceParams = { + 'deployer': signer, + [`stakingLp${poolType.toUpperCase()}`]: stakingLp?.address || options.existingContracts?.stakingLp || ethers.constants.AddressZero, + 'stakingLpToken': stakingLp?.address || options.existingContracts?.stakingLp || ethers.constants.AddressZero + }; - const { payees, shares, admin } = replaceParams(config.Core.Treasury.constructorArguments, treasuryReplaceParams); - const treasury = await deployContract('Treasury', 'Treasury', false, payees, shares, admin); - - await pool.setTreasury(treasury.address); + const { payees, shares, admin } = replaceParams(treasuryConfig.constructorArguments, treasuryReplaceParams); + treasury = await deployContract('Treasury', `Treasury_${poolType}`, false, payees, shares, admin); + + await pool.setTreasury(treasury.address); + console.log(`Treasury deployed and set in pool`); + } - const vaultRegistry = await attachContract('VaultRegistry', config.Core.VaultRegistry); - const { flashlender, proxyRegistry } = await deployActions(pool, vaultRegistry, poolType, config, customPositionActions); + let vaultRegistry; + if (options.skipVaultRegistry) { + console.log('Skipping vault registry deployment'); + if (options.existingContracts?.vaultRegistry) { + vaultRegistry = await attachContract('VaultRegistry', options.existingContracts.vaultRegistry); + console.log(`Using existing vault registry at: ${vaultRegistry.address}`); + } else if (config.Core.VaultRegistry) { + vaultRegistry = await attachContract('VaultRegistry', config.Core.VaultRegistry); + console.log(`Using vault registry from config at: ${vaultRegistry.address}`); + } + } else { + // Deploy new vault registry + vaultRegistry = await deployContract('VaultRegistry', `VaultRegistry_${poolType}`, false); + console.log(`VaultRegistry deployed at: ${vaultRegistry.address}`); + } + + let flashlender, proxyRegistry; + if (options.skipActions) { + console.log('Skipping actions deployment'); + if (options.existingContracts?.flashlender) { + flashlender = await attachContract('Flashlender', options.existingContracts.flashlender); + console.log(`Using existing flashlender at: ${flashlender.address}`); + } + if (options.existingContracts?.proxyRegistry) { + proxyRegistry = await attachContract('PRBProxyRegistry', options.existingContracts.proxyRegistry); + console.log(`Using existing proxy registry at: ${proxyRegistry.address}`); + } + } else { + const deployed = await deployActions(pool, vaultRegistry, poolType, config, customPositionActions); + flashlender = deployed.flashlender; + proxyRegistry = deployed.proxyRegistry; + } return { stakingLp, @@ -986,6 +1072,7 @@ module.exports = { getPoolAddress, loadDeployedRewardManagers, deployPoolCore, + deployPoolCoreSelective, deployStakingAndLockingLP, deployActions, deployPositionActions, From 7216b5fa6fc5c03f596e91e3a0c8bfa779313d25 Mon Sep 17 00:00:00 2001 From: Alexandru Marcu Date: Wed, 10 Sep 2025 15:28:31 +0300 Subject: [PATCH 3/3] Push oracle --- foundry.toml | 15 + scripts/config_xdc.js | 112 ++--- scripts/deploy_xdc.js | 131 +++--- src/oracle/PushOracle.sol | 320 ++++++++++++++ src/test/PushOracle.t.sol | 907 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 1366 insertions(+), 119 deletions(-) create mode 100644 src/oracle/PushOracle.sol create mode 100644 src/test/PushOracle.t.sol diff --git a/foundry.toml b/foundry.toml index 7308416..140607f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -38,3 +38,18 @@ mainnet = "${MAINNET_RPC_URL}" scroll = "${SCROLL_RPC_URL}" xdc = "${XDC_RPC_URL}" tenderly = "${TENDERLY_FORK_URL}" + +[lint] +exclude_lints = [ + "asm-keccak256", + "unused-import", + "mixed-case-variable", + "screaming-snake-case-const", + "mixed-case-function", + "unaliased-plain-import", + "screaming-snake-case-immutable", + "pascal-case-struct", + "erc20-unchecked-transfer", + "divide-before-multiply", + "unchecked-call" +] diff --git a/scripts/config_xdc.js b/scripts/config_xdc.js index b5a7c7e..36504f6 100644 --- a/scripts/config_xdc.js +++ b/scripts/config_xdc.js @@ -15,13 +15,21 @@ module.exports = { }, "initialDebtCeiling": toWad('100000000'), }, + "Flashlender_usdc": { + "constructorArguments": { + "protocolFee_": toWad('0') + }, + "initialDebtCeiling": toWad('100000000'), + }, "WXDC": "0x951857744785E80e2De051c32EE7b25f9c458C42", // XDC native token wrapped - "USDC": "0xcc0587aebda397146cc828b445db130a94486e74", // USDC on XDC + "USDCe": "0xcc0587aebda397146cc828b445db130a94486e74", // USDC on XDC + "USDC": "0xfA2958CB79b0491CC627c1557F441eF849Ca8eb1", // USDC on XDC "WETH": "0xa7348290de5cf01772479c48D50dec791c3fC212", // WETH on XDC "PenpieHelper": "0x0000000000000000000000000000000000000000", // PenpieHelper not available on XDC "AddressProviderV3": "0xE67f77af54EdA6B92f2dBaB272b8C0817ae0bCa3", "PoolV3_LpXDC": "0xED166436559Fd3d7f44cb00CACDA96EB999D789e", "PoolV3_lpUSDCe": "0x235e49CC709F9e262814795c00eabe73709ef8E2", + "PoolV3_lpUSDC": "", // To be filled after USDC pool deployment "FlashlenderLPXDC": "", // To be filled after deployment "FlashlenderLPUSDC": "", // To be filled after deployment "VaultRegistry": "", // To be filled after deployment @@ -51,11 +59,11 @@ module.exports = { "Gearbox": { "initialGlobalDebtCeiling": "115792089237316195423570985008687907853269984665640564039457584007913129639935", // max uint256 }, - "Treasury_usdce": { + "Treasury_usdc": { "constructorArguments": { "payees":[ "0xA719A90a173E8F00618596ffb594F5FfA79d915D", - "0xe1987f6cD0b8823a033c29d640596757492479BD" // StakingLPUSDCE + "stakingLpUSDC" // Reference to staking contract - will be resolved during deployment ], "shares":[ 200, @@ -67,11 +75,11 @@ module.exports = { }, "Pools": { "Pool LpUSDC": { - "name": "Loop Liquidity Pool - USDC.e", - "symbol": "lpUSDCe", - "wrappedToken": "0x0000000000000000000000000000000000000000", // - "treasury": "", // To be filled with actual treasury address - "underlier": "0xcc0587aebda397146cc828b445db130a94486e74", // USDC.E + "name": "Loop Liquidity Pool - USDC", + "symbol": "lpUSDC", + "wrappedToken": "0x0000000000000000000000000000000000000000", // No wrapped token for USDC + "treasury": "Treasury_usdc", // Reference to treasury config key + "underlier": "0xfA2958CB79b0491CC627c1557F441eF849Ca8eb1", // USDC on XDC "interestRateModel": { "U_1": 8000, // U_1 - Higher utilization target for stablecoin "U_2": 9500, // U_2 - Higher optimal utilization for stablecoin @@ -85,50 +93,50 @@ module.exports = { "Vendors": { }, "Vaults": { - "Vaults_scrvUSD": { - name: "Vaults_scrvUSD", - description: "This vault allows for borrowing and lending of scrvUSD assets", - type: "CDPVault", - collateralType: "ERC20", - oracle: { - type: "Oracle_scrvUSD", - deploymentArguments: { - "curvePool": "0x24894F0c4f80837d61CA21730A75Fa216FED7200", - "scrvUSDRateXDC": "0x09F8D940EAD55853c51045bcbfE67341B686C071", // The LP token market - "scrvUSD": "0x3d8EADb739D1Ef95dd53D718e4810721837c69c1", - "k": 0 - }, - }, - token: "0x3d8EADb739D1Ef95dd53D718e4810721837c69c1", - poolAddress: "0x235e49CC709F9e262814795c00eabe73709ef8E2", // USDC.e pool address - tokenSymbol: "LOOP-scrvUSD", - tokenScale: toWad("1.0"), - protocolIcon: null, - deploymentArguments: { - constants: { - protocolFee: toWad("0.01"), - }, - configs: { - debtFloor: toWad("1"), - liquidationRatio: toWad("1.1"), - liquidationPenalty: toWad("0.99"), - liquidationDiscount: toWad("0.98"), - roleAdmin: "deployer", - vaultAdmin: "deployer", - pauseAdmin: "deployer", - }, - debtCeiling: toWad("100000000"), - }, - quotas: { - minRate: 100, - maxRate: 10000, - }, - "RewardManager": { - "artifactName": "src/pendle-rewards/RewardManager.sol:RewardManager", - "constructorArguments": [ - ] - } - }, + // "Vaults_scrvUSD": { + // name: "Vaults_scrvUSD", + // description: "This vault allows for borrowing and lending of scrvUSD assets", + // type: "CDPVault", + // collateralType: "ERC20", + // oracle: { + // type: "Oracle_scrvUSD", + // deploymentArguments: { + // "curvePool": "0x24894F0c4f80837d61CA21730A75Fa216FED7200", + // "scrvUSDRateXDC": "0x09F8D940EAD55853c51045bcbfE67341B686C071", // The LP token market + // "scrvUSD": "0x3d8EADb739D1Ef95dd53D718e4810721837c69c1", + // "k": 0 + // }, + // }, + // token: "0x3d8EADb739D1Ef95dd53D718e4810721837c69c1", + // poolAddress: "0x235e49CC709F9e262814795c00eabe73709ef8E2", // USDC.e pool address + // tokenSymbol: "LOOP-scrvUSD", + // tokenScale: toWad("1.0"), + // protocolIcon: null, + // deploymentArguments: { + // constants: { + // protocolFee: toWad("0.01"), + // }, + // configs: { + // debtFloor: toWad("1"), + // liquidationRatio: toWad("1.1"), + // liquidationPenalty: toWad("0.99"), + // liquidationDiscount: toWad("0.98"), + // roleAdmin: "deployer", + // vaultAdmin: "deployer", + // pauseAdmin: "deployer", + // }, + // debtCeiling: toWad("100000000"), + // }, + // quotas: { + // minRate: 100, + // maxRate: 10000, + // }, + // "RewardManager": { + // "artifactName": "src/pendle-rewards/RewardManager.sol:RewardManager", + // "constructorArguments": [ + // ] + // } + // }, }, "Tokenomics":{ diff --git a/scripts/deploy_xdc.js b/scripts/deploy_xdc.js index e0e63b6..f49942e 100644 --- a/scripts/deploy_xdc.js +++ b/scripts/deploy_xdc.js @@ -101,7 +101,10 @@ async function deployPool() { // Deploy USDC Pool console.log('\n--- Deploying USDC Pool ---'); - await deploySinglePool('Pool LpUSDC'); + const deployedPool = await deploySinglePool('Pool LpUSDC'); + + console.log('USDC Pool deployment completed successfully'); + return deployedPool; } async function deploySinglePool(poolKey) { @@ -225,41 +228,21 @@ async function deploySinglePool(poolKey) { }; } -async function deployCore() { - console.log(` -/*////////////////////////////////////////////////////////////// - DEPLOYING CORE -//////////////////////////////////////////////////////////////*/ - `); +// deployCore function removed - using existing AddressProviderV3 - // Pass CONFIG_NETWORK to deployPoolCore - const deployedCore = await deployPoolCore(CONFIG_NETWORK, 'xdc'); - console.log('Core deployment completed'); - return deployedCore; -} - -async function deployUSDCePoolCore() { +async function deployUSDCPoolCore(poolAddress, stakingAddress, lockingAddress) { console.log(` /*////////////////////////////////////////////////////////////// - DEPLOYING USDC.e POOL CORE + DEPLOYING USDC POOL AUXILIARY CONTRACTS //////////////////////////////////////////////////////////////*/ `); - // Load existing contract addresses from deployment file - const deploymentFilePath = await getDeploymentFilePath(); - const deployment = fs.existsSync(deploymentFilePath) ? JSON.parse(fs.readFileSync(deploymentFilePath)) : {}; - - const existingStakingAddress = deployment.core?.['StakingLPUSDCE']?.address; - const existingLockingAddress = deployment.core?.['LockingLpUSDCE']?.address; - - if (!existingStakingAddress || !existingLockingAddress) { - console.error('ERROR: Could not find existing staking or locking contracts for USDC.e'); - console.log('Available contracts:', Object.keys(deployment.core || {})); - return null; - } - - console.log(`Using existing StakingLPUSDCE at: ${existingStakingAddress}`); - console.log(`Using existing LockingLpUSDCE at: ${existingLockingAddress}`); + console.log(`Using deployed pool at: ${poolAddress}`); + console.log(`Using deployed staking at: ${stakingAddress}`); + console.log(`Using deployed locking at: ${lockingAddress}`); + + // Update the config with the deployed pool address so other functions can find it + CONFIG_NETWORK.Core.PoolV3_lpUSDC = poolAddress; // Custom position actions list excluding PositionActionPenpie (not available on XDC) const customPositionActions = [ @@ -270,32 +253,33 @@ async function deployUSDCePoolCore() { // Skip PositionActionPenpie - not available on XDC ]; - // Deploy core contracts for USDC.e pool with selective deployment - // Skip staking and locking (already deployed), but deploy treasury, vault registry, flashlender, and actions + // Deploy auxiliary contracts for USDC pool + // Deploy treasury, vault registry, flashlender, and actions + // Staking and locking are already deployed as part of the pool deployment const deployedCore = await deployPoolCoreSelective( CONFIG_NETWORK, - 'usdce', // pool type - 'PoolV3_lpUSDCe', // pool key from config + 'usdc', // pool type + 'PoolV3_lpUSDC', // pool key from config (now populated) customPositionActions, // custom position actions excluding Penpie { - skipStaking: true, - skipLocking: true, + skipStaking: true, // Already deployed with pool + skipLocking: true, // Already deployed with pool skipTreasury: false, // Deploy treasury skipVaultRegistry: false, // Deploy vault registry skipActions: false, // Deploy flashlender, proxy registry, and position actions existingContracts: { - // Use existing staking and locking contracts from deployment file - stakingLp: existingStakingAddress, - lockLp: existingLockingAddress + // Use the deployed staking and locking contracts + stakingLp: stakingAddress, + lockLp: lockingAddress } } ); - console.log('USDC.e Pool Core deployment completed'); + console.log('USDC Pool auxiliary contracts deployment completed'); return deployedCore; } -async function deployVaults() { +async function deployVaults_UNUSED() { console.log(` /*////////////////////////////////////////////////////////////// DEPLOYING VAULTS @@ -530,7 +514,8 @@ async function performTransactions() { console.log('Pool unlocked'); } -async function storeVaultMetadataForGauge() { +// Removed storeVaultMetadataForGauge - not needed for USDC pool only deployment +async function storeVaultMetadataForGauge_UNUSED() { console.log(` /*////////////////////////////////////////////////////////////// STORING VAULT METADATA @@ -550,9 +535,14 @@ async function storeVaultMetadataForGauge() { continue; } + // Get the deployed USDC pool address from deployment file + const deploymentFilePath = await getDeploymentFilePath(); + const deployment = fs.existsSync(deploymentFilePath) ? JSON.parse(fs.readFileSync(deploymentFilePath)) : {}; + const usdcPoolAddress = deployment.pools?.['Pool LpUSDC']?.address || CONFIG_NETWORK.Core.PoolV3_lpUSDC; + // Store metadata including pool address and quotas const metadata = { - pool: CONFIG_NETWORK.Core.PoolV3_lpUSDCe, // Pool address this vault is associated with + pool: usdcPoolAddress, // Pool address this vault is associated with quotas: vaultConfig.quotas, // Min and max rates from config tokenSymbol: vaultConfig.tokenSymbol, token: vaultConfig.token @@ -566,39 +556,46 @@ async function storeVaultMetadataForGauge() { } } +// deployPositionActionsForPool function removed - position actions are deployed as part of auxiliary contracts + async function main() { try { - // Initialize deployment with account impersonation - // const impersonatedSigner = await impersonateDeployer(); - - // Deploy pools (XDC and USDC) - // await deployPool(); - - // await performTransactions(); - - // Deploy USDC.e pool core contracts (treasury, vault registry, flashlender, actions) - const deployedUSDCeCore = await deployUSDCePoolCore(); + console.log('Starting USDC Pool deployment on XDC...'); + console.log('Using existing AddressProviderV3:', CONFIG_NETWORK.Core.AddressProviderV3); - // Deploy vaults - await deployVaults(); + // Step 1: Deploy USDC Pool with all components (interest rate model, quota keeper, voter, gauge, staking, locking) + console.log('\n=== STEP 1: DEPLOYING USDC POOL ==='); + const deployedPool = await deployPool(); - // Store vault metadata (needed for gauge configuration) - await storeVaultMetadataForGauge(); + // Step 2: Deploy USDC pool auxiliary contracts (treasury, vault registry, flashlender, actions) + console.log('\n=== STEP 2: DEPLOYING USDC POOL AUXILIARY CONTRACTS ==='); + const deployedAuxiliaryContracts = await deployUSDCPoolCore(deployedPool.pool.address, deployedPool.stakingLp.address, deployedPool.lockLp.address); - // Register vaults in the vault registry (use the one we just deployed) - const tempConfig = { ...CONFIG_NETWORK }; - tempConfig.Core.VaultRegistry = deployedUSDCeCore.vaultRegistry.address; - await registerVaults(tempConfig); + // Position actions are already deployed as part of Step 2 (auxiliary contracts) - // Configure gauge (set min/max rates for vaults) - await deployGauge(CONFIG_NETWORK.Core.PoolV3_lpUSDCe, CONFIG_NETWORK, true); + console.log('\nšŸŽ‰ USDC Pool deployment completed successfully!'); + console.log('\nDeployed contracts summary:'); + console.log('- USDC Pool: āœ…'); + console.log('- Interest Rate Model: āœ…'); + console.log('- Pool Quota Keeper: āœ…'); + console.log('- Voter: āœ…'); + console.log('- Gauge: āœ…'); + console.log('- Staking Contract: āœ…'); + console.log('- Locking Contract: āœ…'); + console.log('- Treasury: āœ…'); + console.log('- Vault Registry: āœ…'); + console.log('- Flashlender: āœ…'); + console.log('- Position Actions (4 types): āœ…'); - // // Finalize deployment - // await finalizeDeployment(); + console.log('\nPool Address:', deployedPool.pool.address); + console.log('Staking Address:', deployedPool.stakingLp.address); + console.log('Locking Address:', deployedPool.lockLp.address); + console.log('Treasury Address:', deployedAuxiliaryContracts.treasury?.address || 'N/A'); + console.log('Vault Registry Address:', deployedAuxiliaryContracts.vaultRegistry?.address || 'N/A'); + console.log('Flashlender Address:', deployedAuxiliaryContracts.flashlender?.address || 'N/A'); - console.log('XDC deployment completed successfully!'); } catch (error) { - console.error('Error during XDC deployment:', error); + console.error('āŒ Error during USDC Pool deployment:', error); process.exit(1); } } diff --git a/src/oracle/PushOracle.sol b/src/oracle/PushOracle.sol new file mode 100644 index 0000000..070ce38 --- /dev/null +++ b/src/oracle/PushOracle.sol @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; + +import {wdiv, wmul, WAD} from "../utils/Math.sol"; +import {IOracle, MANAGER_ROLE} from "../interfaces/IOracle.sol"; + +/// @title PushOracle +/// @notice Oracle that accepts price updates from authorized EOAs with TWAP functionality +/// @dev Implements time-weighted average pricing and staleness checks +contract PushOracle is IOracle, AccessControlUpgradeable, UUPSUpgradeable { + /*////////////////////////////////////////////////////////////// + CONSTANTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Role for price updaters + bytes32 public constant PRICE_UPDATER_ROLE = keccak256("PRICE_UPDATER_ROLE"); + + /*////////////////////////////////////////////////////////////// + STRUCTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Simple TWAP data structure + struct TWAPData { + uint256 price; // Current TWAP price in WAD format + uint256 lastTimestamp; // Timestamp of last update + uint256 spotPrice; // Latest spot price (for non-TWAP tokens) + } + + /// @notice Oracle configuration for each token + struct OracleConfig { + uint256 stalePeriod; // Maximum age before price becomes stale + uint256 twapWindow; // Time window for TWAP smoothing (half-life) + bool twapEnabled; // Whether TWAP is enabled for this token + } + + /*////////////////////////////////////////////////////////////// + STORAGE + //////////////////////////////////////////////////////////////*/ + + /// @notice Mapping from token address to oracle configuration + mapping(address => OracleConfig) public oracleConfigs; + + /// @notice Mapping from token address to TWAP data + mapping(address => TWAPData) public twapData; + + /*////////////////////////////////////////////////////////////// + STORAGE GAP + //////////////////////////////////////////////////////////////*/ + + uint256[50] private __gap; + + /*////////////////////////////////////////////////////////////// + EVENTS + //////////////////////////////////////////////////////////////*/ + + /// @notice Emitted when a price is updated + event PriceUpdated(address indexed token, uint256 price, address indexed updater, uint256 timestamp); + + /// @notice Emitted when oracle configuration is updated + event OracleConfigUpdated(address indexed token, uint256 stalePeriod, uint256 twapWindow, bool twapEnabled); + + /*////////////////////////////////////////////////////////////// + ERRORS + //////////////////////////////////////////////////////////////*/ + + error PushOracle__spot_invalidValue(); + error PushOracle__spot_stalePrice(); + error PushOracle__updatePrice_invalidPrice(); + error PushOracle__updatePrice_tokenNotConfigured(); + error PushOracle__setOracleConfig_invalidWindow(); + + /*////////////////////////////////////////////////////////////// + INITIALIZATION + //////////////////////////////////////////////////////////////*/ + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialize method called by the proxy contract + /// @param admin The address of the admin + /// @param manager The address of the manager who can authorize upgrades + function initialize(address admin, address manager) external initializer { + // init. Access Control + __AccessControl_init(); + // Role Admin + _grantRole(DEFAULT_ADMIN_ROLE, admin); + // Credit Manager + _grantRole(MANAGER_ROLE, manager); + } + + /*////////////////////////////////////////////////////////////// + UPGRADEABILITY + //////////////////////////////////////////////////////////////*/ + + /// @notice Authorizes an upgrade + /// @param /*implementation*/ The address of the new implementation + /// @dev reverts if the caller is not a manager + function _authorizeUpgrade(address /*implementation*/) internal virtual override onlyRole(MANAGER_ROLE) {} + + /*////////////////////////////////////////////////////////////// + CONFIGURATION + //////////////////////////////////////////////////////////////*/ + + /// @notice Set oracle configuration for tokens + /// @param tokens Array of token addresses + /// @param configs Array of oracle configurations + function setOracleConfigs( + address[] calldata tokens, + OracleConfig[] calldata configs + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + require(tokens.length == configs.length, "Array length mismatch"); + + for (uint256 i = 0; i < tokens.length; i++) { + if (configs[i].twapEnabled && configs[i].twapWindow == 0) { + revert PushOracle__setOracleConfig_invalidWindow(); + } + + oracleConfigs[tokens[i]] = configs[i]; + + emit OracleConfigUpdated( + tokens[i], + configs[i].stalePeriod, + configs[i].twapWindow, + configs[i].twapEnabled + ); + } + } + + /// @notice Grant price updater role to an address + /// @param updater Address to grant the role to + function grantPriceUpdaterRole(address updater) external onlyRole(DEFAULT_ADMIN_ROLE) { + _grantRole(PRICE_UPDATER_ROLE, updater); + } + + /// @notice Revoke price updater role from an address + /// @param updater Address to revoke the role from + function revokePriceUpdaterRole(address updater) external onlyRole(DEFAULT_ADMIN_ROLE) { + _revokeRole(PRICE_UPDATER_ROLE, updater); + } + + /*////////////////////////////////////////////////////////////// + PRICE UPDATES + //////////////////////////////////////////////////////////////*/ + + /// @notice Update price for a token + /// @param token Token address + /// @param price New price in WAD format + function updatePrice(address token, uint256 price) external onlyRole(PRICE_UPDATER_ROLE) { + _updatePrice(token, price); + } + + /// @notice Batch update prices for multiple tokens + /// @param tokens Array of token addresses + /// @param prices Array of prices in WAD format + function updatePrices( + address[] calldata tokens, + uint256[] calldata prices + ) external onlyRole(PRICE_UPDATER_ROLE) { + require(tokens.length == prices.length, "Array length mismatch"); + + for (uint256 i = 0; i < tokens.length; i++) { + _updatePrice(tokens[i], prices[i]); + } + } + + /// @notice Internal function to update price for a token + /// @param token Token address + /// @param price New price in WAD format + function _updatePrice(address token, uint256 price) internal { + if (price == 0) revert PushOracle__updatePrice_invalidPrice(); + + OracleConfig memory config = oracleConfigs[token]; + if (config.stalePeriod == 0) revert PushOracle__updatePrice_tokenNotConfigured(); + + uint256 timestamp = block.timestamp; + TWAPData storage data = twapData[token]; + + if (config.twapEnabled) { + // Update TWAP using exponential moving average + data.price = _calculateNewTWAP(data.price, data.lastTimestamp, price, timestamp, config.twapWindow); + } else { + // Simple spot price update + data.price = price; + } + + data.spotPrice = price; + data.lastTimestamp = timestamp; + + emit PriceUpdated(token, price, msg.sender, timestamp); + } + + /*////////////////////////////////////////////////////////////// + PRICING + //////////////////////////////////////////////////////////////*/ + + /// @notice Returns the status of the oracle + /// @param token Token address + /// @dev The status is valid if the price exists and is not stale + function getStatus(address token) public view virtual override returns (bool status) { + OracleConfig memory config = oracleConfigs[token]; + TWAPData memory data = twapData[token]; + + if (config.stalePeriod == 0 || data.lastTimestamp == 0) return false; + return (block.timestamp - data.lastTimestamp <= config.stalePeriod); + } + + /// @notice Returns the latest price for the asset + /// @param token Token address + /// @return price Asset price [WAD] + /// @dev reverts if the price is invalid or stale + function spot(address token) external view virtual override returns (uint256 price) { + OracleConfig memory config = oracleConfigs[token]; + if (config.stalePeriod == 0) revert PushOracle__spot_invalidValue(); + + TWAPData memory data = twapData[token]; + if (data.lastTimestamp == 0) revert PushOracle__spot_invalidValue(); + + // Check staleness + if (block.timestamp - data.lastTimestamp > config.stalePeriod) { + revert PushOracle__spot_stalePrice(); + } + + price = data.price; + if (price == 0) revert PushOracle__spot_invalidValue(); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /// @notice Calculate new TWAP using exponential moving average + /// @param currentTWAP Current TWAP value + /// @param lastTimestamp Timestamp of last update + /// @param newPrice New price to incorporate + /// @param newTimestamp Timestamp of new price + /// @param twapWindow Time window for TWAP smoothing (half-life in seconds) + /// @return newTWAP Updated TWAP value + function _calculateNewTWAP( + uint256 currentTWAP, + uint256 lastTimestamp, + uint256 newPrice, + uint256 newTimestamp, + uint256 twapWindow + ) private pure returns (uint256 newTWAP) { + // If this is the first price update, return the new price + if (lastTimestamp == 0 || currentTWAP == 0) { + return newPrice; + } + + // Calculate time elapsed since last update + uint256 timeElapsed = newTimestamp - lastTimestamp; + + // If no time has passed, return current TWAP + if (timeElapsed == 0) { + return currentTWAP; + } + + // Calculate the weight for the new price using exponential decay + // We cap the maximum weight to maintain some memory of old prices + // This provides better attack resistance even for long time gaps + + uint256 maxWeight = 800000000000000000; // 80% (0.8 WAD) - configurable max weight + uint256 weight; + + if (timeElapsed >= twapWindow) { + // Even for very long gaps, cap at maxWeight to preserve some old TWAP + weight = maxWeight; + } else { + // Linear interpolation: weight = timeElapsed / twapWindow + weight = wdiv(timeElapsed, twapWindow); + // Also cap during normal operation to ensure we never exceed maxWeight + if (weight > maxWeight) { + weight = maxWeight; + } + } + + // Calculate new TWAP: newTWAP = currentTWAP * (1 - weight) + newPrice * weight + // Since we cap weight at maxWeight (80%), we always blend old and new prices + uint256 oldWeight = WAD - weight; + newTWAP = wmul(currentTWAP, oldWeight) + wmul(newPrice, weight); + + return newTWAP; + } + + /*////////////////////////////////////////////////////////////// + VIEW FUNCTIONS + //////////////////////////////////////////////////////////////*/ + + /// @notice Get the latest price data for a token + /// @param token Token address + /// @return spotPrice Latest spot price + /// @return twapPrice Current TWAP price (same as spot if TWAP disabled) + /// @return timestamp Latest update timestamp + function getLatestPriceData(address token) external view returns (uint256 spotPrice, uint256 twapPrice, uint256 timestamp) { + TWAPData memory data = twapData[token]; + return (data.spotPrice, data.price, data.lastTimestamp); + } + + /// @notice Get current TWAP for a token (view function) + /// @param token Token address + /// @return twapPrice Current TWAP price + function getTWAP(address token) external view returns (uint256 twapPrice) { + OracleConfig memory config = oracleConfigs[token]; + if (!config.twapEnabled) revert PushOracle__spot_invalidValue(); + + return twapData[token].price; + } + + /// @notice Get the spot price (latest price update) for a token + /// @param token Token address + /// @return spotPrice Latest spot price + function getSpotPrice(address token) external view returns (uint256 spotPrice) { + return twapData[token].spotPrice; + } +} diff --git a/src/test/PushOracle.t.sol b/src/test/PushOracle.t.sol new file mode 100644 index 0000000..dabcd53 --- /dev/null +++ b/src/test/PushOracle.t.sol @@ -0,0 +1,907 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Test.sol"; +import "../oracle/PushOracle.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {WAD} from "../utils/Math.sol"; +import {MANAGER_ROLE} from "../interfaces/IOracle.sol"; + +/** + * @title PushOracleTest + * @notice Comprehensive test suite for PushOracle contract + * @dev Includes tests for initialization, configuration, price updates, TWAP calculations, + * WAD precision, access control, upgrades, and stress testing + */ +contract PushOracleTest is Test { + PushOracle public oracle; + PushOracle public oracleImpl; + + address public admin = makeAddr("admin"); + address public manager = makeAddr("manager"); + address public priceUpdater = makeAddr("priceUpdater"); + address public priceUpdater2 = makeAddr("priceUpdater2"); + address public unauthorizedUser = makeAddr("unauthorizedUser"); + + address public token1 = makeAddr("token1"); + address public token2 = makeAddr("token2"); + + uint256 public constant PRICE_1 = 1000 * WAD; // 1000 USD + uint256 public constant PRICE_2 = 2000 * WAD; // 2000 USD + uint256 public constant STALE_PERIOD = 1 hours; + uint256 public constant TWAP_WINDOW = 30 minutes; + + event PriceUpdated(address indexed token, uint256 price, address indexed updater, uint256 timestamp); + event OracleConfigUpdated(address indexed token, uint256 stalePeriod, uint256 twapWindow, bool twapEnabled); + + function setUp() public { + // Deploy implementation + oracleImpl = new PushOracle(); + + // Deploy proxy + bytes memory initData = abi.encodeWithSelector( + PushOracle.initialize.selector, + admin, + manager + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(oracleImpl), initData); + oracle = PushOracle(address(proxy)); + + // Setup oracle configurations + vm.startPrank(admin); + + address[] memory tokens = new address[](2); + tokens[0] = token1; + tokens[1] = token2; + + PushOracle.OracleConfig[] memory configs = new PushOracle.OracleConfig[](2); + configs[0] = PushOracle.OracleConfig({ + stalePeriod: STALE_PERIOD, + twapWindow: TWAP_WINDOW, + twapEnabled: false + }); + configs[1] = PushOracle.OracleConfig({ + stalePeriod: STALE_PERIOD, + twapWindow: TWAP_WINDOW, + twapEnabled: true + }); + + oracle.setOracleConfigs(tokens, configs); + oracle.grantPriceUpdaterRole(priceUpdater); + oracle.grantPriceUpdaterRole(priceUpdater2); + + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////// + INITIALIZATION TESTS + //////////////////////////////////////////////////////////////*/ + + function testInitialization() public { + assertTrue(oracle.hasRole(oracle.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(oracle.hasRole(MANAGER_ROLE, manager)); + assertTrue(oracle.hasRole(oracle.PRICE_UPDATER_ROLE(), priceUpdater)); + assertTrue(oracle.hasRole(oracle.PRICE_UPDATER_ROLE(), priceUpdater2)); + } + + function testSetOracleConfigs() public { + (uint256 stalePeriod, uint256 twapWindow, bool twapEnabled) = oracle.oracleConfigs(token1); + assertEq(stalePeriod, STALE_PERIOD); + assertEq(twapWindow, TWAP_WINDOW); + assertFalse(twapEnabled); + + (stalePeriod, twapWindow, twapEnabled) = oracle.oracleConfigs(token2); + assertEq(stalePeriod, STALE_PERIOD); + assertEq(twapWindow, TWAP_WINDOW); + assertTrue(twapEnabled); + } + + function testSetOracleConfigsRevertInvalidWindow() public { + vm.startPrank(admin); + + address[] memory tokens = new address[](1); + tokens[0] = makeAddr("newToken"); + + PushOracle.OracleConfig[] memory configs = new PushOracle.OracleConfig[](1); + configs[0] = PushOracle.OracleConfig({ + stalePeriod: STALE_PERIOD, + twapWindow: 0, + twapEnabled: true // TWAP enabled but window is 0 + }); + + vm.expectRevert(PushOracle.PushOracle__setOracleConfig_invalidWindow.selector); + oracle.setOracleConfigs(tokens, configs); + + vm.stopPrank(); + } + + function testSetOracleConfigsOnlyAdmin() public { + vm.startPrank(unauthorizedUser); + + address[] memory tokens = new address[](1); + tokens[0] = makeAddr("newToken"); + + PushOracle.OracleConfig[] memory configs = new PushOracle.OracleConfig[](1); + configs[0] = PushOracle.OracleConfig({ + stalePeriod: STALE_PERIOD, + twapWindow: TWAP_WINDOW, + twapEnabled: false + }); + + vm.expectRevert(); + oracle.setOracleConfigs(tokens, configs); + + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////// + PRICE UPDATE TESTS + //////////////////////////////////////////////////////////////*/ + + function testUpdatePrice() public { + vm.startPrank(priceUpdater); + + vm.expectEmit(true, true, false, true); + emit PriceUpdated(token1, PRICE_1, priceUpdater, block.timestamp); + + oracle.updatePrice(token1, PRICE_1); + + (uint256 spotPrice, uint256 twapPrice, uint256 timestamp) = oracle.getLatestPriceData(token1); + uint256 price = spotPrice; + assertEq(price, PRICE_1); + assertEq(timestamp, block.timestamp); + + vm.stopPrank(); + } + + function testUpdatePriceRevertZeroPrice() public { + vm.startPrank(priceUpdater); + + vm.expectRevert(PushOracle.PushOracle__updatePrice_invalidPrice.selector); + oracle.updatePrice(token1, 0); + + vm.stopPrank(); + } + + function testUpdatePriceRevertTokenNotConfigured() public { + address unconfiguredToken = makeAddr("unconfiguredToken"); + + vm.startPrank(priceUpdater); + + vm.expectRevert(PushOracle.PushOracle__updatePrice_tokenNotConfigured.selector); + oracle.updatePrice(unconfiguredToken, PRICE_1); + + vm.stopPrank(); + } + + function testUpdatePriceOnlyAuthorized() public { + vm.startPrank(unauthorizedUser); + + vm.expectRevert(); + oracle.updatePrice(token1, PRICE_1); + + vm.stopPrank(); + } + + function testUpdatePricesArray() public { + vm.startPrank(priceUpdater); + + address[] memory tokens = new address[](2); + tokens[0] = token1; + tokens[1] = token2; + + uint256[] memory prices = new uint256[](2); + prices[0] = PRICE_1; + prices[1] = PRICE_2; + + oracle.updatePrices(tokens, prices); + + (uint256 price1,,) = oracle.getLatestPriceData(token1); + (uint256 price2,,) = oracle.getLatestPriceData(token2); + + assertEq(price1, PRICE_1); + assertEq(price2, PRICE_2); + + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////// + ORACLE STATUS TESTS + //////////////////////////////////////////////////////////////*/ + + function testGetStatusFreshPrice() public { + vm.startPrank(priceUpdater); + oracle.updatePrice(token1, PRICE_1); + vm.stopPrank(); + + assertTrue(oracle.getStatus(token1)); + } + + function testGetStatusStalePrice() public { + vm.startPrank(priceUpdater); + oracle.updatePrice(token1, PRICE_1); + vm.stopPrank(); + + // Move forward past stale period + vm.warp(block.timestamp + STALE_PERIOD + 1); + + assertFalse(oracle.getStatus(token1)); + } + + function testGetStatusNoPrice() public { + assertFalse(oracle.getStatus(token1)); + } + + /*////////////////////////////////////////////////////////////// + SPOT PRICE TESTS + //////////////////////////////////////////////////////////////*/ + + function testSpotPrice() public { + vm.startPrank(priceUpdater); + oracle.updatePrice(token1, PRICE_1); + vm.stopPrank(); + + uint256 spotPrice = oracle.spot(token1); + assertEq(spotPrice, PRICE_1); + } + + function testSpotPriceRevertStale() public { + vm.startPrank(priceUpdater); + oracle.updatePrice(token1, PRICE_1); + vm.stopPrank(); + + // Move forward past stale period + vm.warp(block.timestamp + STALE_PERIOD + 1); + + vm.expectRevert(PushOracle.PushOracle__spot_stalePrice.selector); + oracle.spot(token1); + } + + function testSpotPriceRevertNoPrice() public { + vm.expectRevert(PushOracle.PushOracle__spot_invalidValue.selector); + oracle.spot(token1); + } + + function testSpotPriceRevertTokenNotConfigured() public { + address unconfiguredToken = makeAddr("unconfiguredToken"); + + vm.expectRevert(PushOracle.PushOracle__spot_invalidValue.selector); + oracle.spot(unconfiguredToken); + } + + /*////////////////////////////////////////////////////////////// + TWAP TESTS + //////////////////////////////////////////////////////////////*/ + + function testTWAPSinglePrice() public { + vm.startPrank(priceUpdater); + oracle.updatePrice(token2, PRICE_1); // token2 has TWAP enabled + vm.stopPrank(); + + uint256 twapPrice = oracle.spot(token2); + assertEq(twapPrice, PRICE_1); + } + + function testTWAPMultiplePrices() public { + vm.startPrank(priceUpdater); + + // First price + oracle.updatePrice(token2, 1000 * WAD); + uint256 time1 = block.timestamp; + + // Second price after 10 minutes + vm.warp(time1 + 10 minutes); + oracle.updatePrice(token2, 1200 * WAD); + uint256 time2 = block.timestamp; + + // Third price after another 10 minutes + vm.warp(time2 + 10 minutes); + oracle.updatePrice(token2, 1400 * WAD); + + vm.stopPrank(); + + // With exponential moving average (80% max weight): + // Step 1: 1000 -> 1000 (first price) + // Step 2: weight = 600/1800 = 1/3, TWAP = 1000*(2/3) + 1200*(1/3) = 1066.67 + // Step 3: weight = 600/1800 = 1/3, TWAP = 1066.67*(2/3) + 1400*(1/3) = 1177.78 + uint256 twapPrice = oracle.spot(token2); + uint256 expectedTWAP = 1177777777777777777777; // ~1177.78 WAD + // Allow for small rounding errors (within 1000 wei) + assertApproxEqAbs(twapPrice, expectedTWAP, 1000); + } + + function testTWAPOutsideWindow() public { + vm.startPrank(priceUpdater); + + // First price (this will be outside TWAP window) + oracle.updatePrice(token2, 500 * WAD); + uint256 time1 = block.timestamp; + + // Move forward beyond TWAP window + vm.warp(time1 + TWAP_WINDOW + 10 minutes); + oracle.updatePrice(token2, 1000 * WAD); + uint256 time2 = block.timestamp; + + // Third price after 10 minutes + vm.warp(time2 + 10 minutes); + oracle.updatePrice(token2, 1200 * WAD); + + vm.stopPrank(); + + // With exponential moving average (80% max weight): + // Step 1: 500 -> 500 (first price) + // Step 2: weight = 0.8 (capped), TWAP = 500*0.2 + 1000*0.8 = 100 + 800 = 900 + // Step 3: weight = 600/1800 = 1/3, TWAP = 900*(2/3) + 1200*(1/3) = 600 + 400 = 1000 + uint256 twapPrice = oracle.spot(token2); + uint256 expectedTWAP = 1000000000000000000000; // 1000 WAD + // Allow for small rounding errors (within 1000 wei) + assertApproxEqAbs(twapPrice, expectedTWAP, 1000); + } + + function testTWAPStalePrice() public { + vm.startPrank(priceUpdater); + oracle.updatePrice(token2, PRICE_1); + vm.stopPrank(); + + // Move forward past stale period + vm.warp(block.timestamp + STALE_PERIOD + 1); + + vm.expectRevert(PushOracle.PushOracle__spot_stalePrice.selector); + oracle.spot(token2); + } + + function testGetTWAPView() public { + vm.startPrank(priceUpdater); + oracle.updatePrice(token2, PRICE_1); + vm.stopPrank(); + + uint256 twapPrice = oracle.getTWAP(token2); + assertEq(twapPrice, PRICE_1); + } + + function testGetTWAPRevertNotEnabled() public { + vm.expectRevert(PushOracle.PushOracle__spot_invalidValue.selector); + oracle.getTWAP(token1); // token1 has TWAP disabled + } + + /*////////////////////////////////////////////////////////////// + CIRCULAR BUFFER TESTS + //////////////////////////////////////////////////////////////*/ + + function testMultiplePriceUpdates() public { + vm.startPrank(priceUpdater); + + // Test multiple price updates - no circular buffer needed with new implementation + for (uint256 i = 1; i <= 12; i++) { + oracle.updatePrice(token1, i * 100 * WAD); + vm.warp(block.timestamp + 1 minutes); + } + + vm.stopPrank(); + + // Latest price should be the last one we added + uint256 latestPrice = oracle.getSpotPrice(token1); + assertEq(latestPrice, 1200 * WAD); + + // Verify the price is accessible via spot function + uint256 spotPrice = oracle.spot(token1); + assertEq(spotPrice, 1200 * WAD); + } + + /*////////////////////////////////////////////////////////////// + ACCESS CONTROL TESTS + //////////////////////////////////////////////////////////////*/ + + function testGrantPriceUpdaterRole() public { + address newUpdater = makeAddr("newUpdater"); + + vm.startPrank(admin); + oracle.grantPriceUpdaterRole(newUpdater); + vm.stopPrank(); + + assertTrue(oracle.hasRole(oracle.PRICE_UPDATER_ROLE(), newUpdater)); + } + + function testRevokePriceUpdaterRole() public { + vm.startPrank(admin); + oracle.revokePriceUpdaterRole(priceUpdater); + vm.stopPrank(); + + assertFalse(oracle.hasRole(oracle.PRICE_UPDATER_ROLE(), priceUpdater)); + } + + function testGrantPriceUpdaterRoleOnlyAdmin() public { + address newUpdater = makeAddr("newUpdater"); + + vm.startPrank(unauthorizedUser); + vm.expectRevert(); + oracle.grantPriceUpdaterRole(newUpdater); + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////// + UPGRADEABILITY TESTS + //////////////////////////////////////////////////////////////*/ + + function testUpgrade() public { + // Deploy new implementation + PushOracle newImpl = new PushOracle(); + + vm.startPrank(manager); + oracle.upgradeTo(address(newImpl)); + vm.stopPrank(); + + // Verify upgrade worked by checking the oracle still functions + vm.startPrank(priceUpdater); + oracle.updatePrice(token1, PRICE_1); + vm.stopPrank(); + + uint256 spotPrice = oracle.spot(token1); + assertEq(spotPrice, PRICE_1); + } + + function testUpgradeOnlyManager() public { + PushOracle newImpl = new PushOracle(); + + vm.startPrank(unauthorizedUser); + vm.expectRevert(); + oracle.upgradeTo(address(newImpl)); + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////// + WAD PRECISION TESTS + //////////////////////////////////////////////////////////////*/ + + function testWADPrecisionBasic() public { + vm.startPrank(priceUpdater); + + // Test with precise WAD values + uint256 precisePrice = 1234567890123456789; // 1.234567890123456789 WAD + oracle.updatePrice(token1, precisePrice); + + vm.stopPrank(); + + uint256 spotPrice = oracle.spot(token1); + assertEq(spotPrice, precisePrice); + } + + function testWADPrecisionTWAPCalculation() public { + vm.startPrank(priceUpdater); + + // Use precise prices that will test WAD arithmetic + uint256 price1 = 1333333333333333333; // 1.333333333333333333 WAD + uint256 price2 = 1666666666666666667; // 1.666666666666666667 WAD + + oracle.updatePrice(token2, price1); + + // Wait exactly 15 minutes (half the TWAP window) + vm.warp(block.timestamp + 15 minutes); + oracle.updatePrice(token2, price2); + + vm.stopPrank(); + + // With exponential moving average and 15 minutes (half window): + // weight = 15 * 60 / (30 * 60) = 0.5 + // newTWAP = price1 * (1 - 0.5) + price2 * 0.5 = 1.333... * 0.5 + 1.666... * 0.5 = 1.5 WAD + uint256 expectedTWAP = 1500000000000000000; // 1.5 WAD + uint256 actualTWAP = oracle.spot(token2); + + // Allow for 1 wei rounding error + assertApproxEqAbs(actualTWAP, expectedTWAP, 1); + } + + function testWADPrecisionComplexTWAP() public { + vm.startPrank(priceUpdater); + + // Test with two prices to verify exponential moving average + uint256 price1 = 1111111111111111111; // 1.111... WAD + uint256 price2 = 5555555555555555555; // 5.555... WAD + + oracle.updatePrice(token2, price1); + + // Wait 5 minutes (1/6 of TWAP window) + vm.warp(block.timestamp + 5 minutes); + oracle.updatePrice(token2, price2); + + vm.stopPrank(); + + // With exponential moving average and 5 minutes: + // weight = 5 * 60 / (30 * 60) = 1/6 ā‰ˆ 0.1667 + // newTWAP = price1 * (1 - 1/6) + price2 * (1/6) = 1.111... * (5/6) + 5.555... * (1/6) + // = 1.111... * 0.8333... + 5.555... * 0.1667... ā‰ˆ 1.851851851851851851 WAD + uint256 expectedTWAP = 1851851851851851851; + uint256 actualTWAP = oracle.spot(token2); + + // Allow for small rounding errors (within 5 wei) + assertApproxEqAbs(actualTWAP, expectedTWAP, 5); + } + + function testWADPrecisionVerySmallPrices() public { + vm.startPrank(priceUpdater); + + // Test with very small prices (micro-cents) + uint256 smallPrice1 = 1000000000000; // 0.000001 WAD + uint256 smallPrice2 = 2000000000000; // 0.000002 WAD + + oracle.updatePrice(token2, smallPrice1); + vm.warp(block.timestamp + 10 minutes); + oracle.updatePrice(token2, smallPrice2); + vm.warp(block.timestamp + 10 minutes); + + vm.stopPrank(); + + // With exponential moving average: + // Step 1: 0.000001 -> 0.000001 (first price) + // Step 2: weight = 600/1800 = 1/3, TWAP = 0.000001*(2/3) + 0.000002*(1/3) = 0.00000133 WAD + uint256 expectedTWAP = 1333333333333; // ~0.00000133 WAD + uint256 actualTWAP = oracle.spot(token2); + + assertApproxEqAbs(actualTWAP, expectedTWAP, 1000000000); // Allow for precision loss + } + + function testWADPrecisionVeryLargePrices() public { + vm.startPrank(priceUpdater); + + // Test with very large prices (millions) + uint256 largePrice1 = 1000000 * WAD; // 1 million + uint256 largePrice2 = 2000000 * WAD; // 2 million + + oracle.updatePrice(token2, largePrice1); + vm.warp(block.timestamp + 10 minutes); + oracle.updatePrice(token2, largePrice2); + vm.warp(block.timestamp + 10 minutes); + + vm.stopPrank(); + + // With exponential moving average: + // Step 1: 1M -> 1M (first price) + // Step 2: weight = 600/1800 = 1/3, TWAP = 1M*(2/3) + 2M*(1/3) = 1.33M WAD + uint256 expectedTWAP = 1333333333333333333333333; // ~1.33M WAD + uint256 actualTWAP = oracle.spot(token2); + + // Allow for small rounding errors (larger tolerance for large numbers) + assertApproxEqAbs(actualTWAP, expectedTWAP, 1000000); + } + + function testWADPrecisionRoundingEdgeCases() public { + vm.startPrank(priceUpdater); + + // Test prices that might cause rounding issues + uint256 price1 = 999999999999999999; // Just under 1 WAD + uint256 price2 = 1000000000000000001; // Just over 1 WAD + + oracle.updatePrice(token2, price1); + vm.warp(block.timestamp + 1 seconds); + oracle.updatePrice(token2, price2); + vm.warp(block.timestamp + 1 seconds); + + vm.stopPrank(); + + // Expected TWAP should be exactly 1 WAD + uint256 expectedTWAP = WAD; + uint256 actualTWAP = oracle.spot(token2); + + // Allow for small rounding errors (within 5 wei) + assertApproxEqAbs(actualTWAP, expectedTWAP, 5); + } + + function testWADPrecisionTWAPWindowBoundary() public { + vm.startPrank(priceUpdater); + + // Add a price that will be exactly at the TWAP window boundary + oracle.updatePrice(token2, 1000 * WAD); + uint256 startTime = block.timestamp; + + // Move to exactly TWAP_WINDOW seconds later + vm.warp(startTime + TWAP_WINDOW); + oracle.updatePrice(token2, 2000 * WAD); + + // Move 1 second forward + vm.warp(block.timestamp + 1); + oracle.updatePrice(token2, 3000 * WAD); + + vm.stopPrank(); + + // With exponential moving average (80% max weight): + // Step 1: 1000 -> 1000 (first price) + // Step 2: weight = 0.8 (capped), TWAP = 1000*0.2 + 2000*0.8 = 1800 + // Step 3: weight = 1/1800 ā‰ˆ 0.0006, TWAP ā‰ˆ 1800*0.9994 + 3000*0.0006 ā‰ˆ 1800.67 + uint256 actualTWAP = oracle.spot(token2); + + uint256 expectedTWAP = 1800666666666666666666; // ~1800.67 WAD + // Allow for small rounding errors (within 1000 wei) + assertApproxEqAbs(actualTWAP, expectedTWAP, 1000); + } + + /*////////////////////////////////////////////////////////////// + EDGE CASE TESTS + //////////////////////////////////////////////////////////////*/ + + function testMultiplePriceUpdaters() public { + // Test that multiple price updaters can update prices + vm.startPrank(priceUpdater); + oracle.updatePrice(token1, PRICE_1); + vm.stopPrank(); + + vm.warp(block.timestamp + 5 minutes); + + vm.startPrank(priceUpdater2); + oracle.updatePrice(token1, PRICE_2); + vm.stopPrank(); + + uint256 latestPrice = oracle.getSpotPrice(token1); + assertEq(latestPrice, PRICE_2); + } + + function testPriceUpdateArrayMismatch() public { + vm.startPrank(priceUpdater); + + address[] memory tokens = new address[](2); + tokens[0] = token1; + tokens[1] = token2; + + uint256[] memory prices = new uint256[](1); // Mismatched length + prices[0] = PRICE_1; + + vm.expectRevert("Array length mismatch"); + oracle.updatePrices(tokens, prices); + + vm.stopPrank(); + } + + function testConfigArrayMismatch() public { + vm.startPrank(admin); + + address[] memory tokens = new address[](2); + tokens[0] = makeAddr("newToken1"); + tokens[1] = makeAddr("newToken2"); + + PushOracle.OracleConfig[] memory configs = new PushOracle.OracleConfig[](1); // Mismatched length + configs[0] = PushOracle.OracleConfig({ + stalePeriod: STALE_PERIOD, + twapWindow: TWAP_WINDOW, + twapEnabled: false + }); + + vm.expectRevert("Array length mismatch"); + oracle.setOracleConfigs(tokens, configs); + + vm.stopPrank(); + } + + function testTWAPWithNoTimeGap() public { + vm.startPrank(priceUpdater); + + // Add multiple prices at the same timestamp + oracle.updatePrice(token2, 1000 * WAD); + oracle.updatePrice(token2, 1200 * WAD); + oracle.updatePrice(token2, 1400 * WAD); + + vm.stopPrank(); + + // With exponential moving average, when timeElapsed = 0, it returns current TWAP + // Since all updates happen at same timestamp, TWAP stays at first price + uint256 twapPrice = oracle.spot(token2); + assertEq(twapPrice, 1000 * WAD); + } + + /*////////////////////////////////////////////////////////////// + FUZZ TESTS + //////////////////////////////////////////////////////////////*/ + + function testFuzzPriceUpdate(uint256 price) public { + // Bound price to reasonable range (1 wei to 1 billion WAD) + price = bound(price, 1, 1_000_000_000 * WAD); + + vm.startPrank(priceUpdater); + oracle.updatePrice(token1, price); + vm.stopPrank(); + + uint256 spotPrice = oracle.spot(token1); + assertEq(spotPrice, price); + } + + function testFuzzTWAPCalculation(uint256 price1, uint256 price2, uint256 timeGap) public { + // Bound inputs to reasonable ranges - avoid zero prices + price1 = bound(price1, 1 * WAD, 1_000_000 * WAD); + price2 = bound(price2, 1 * WAD, 1_000_000 * WAD); + timeGap = bound(timeGap, 1 minutes, TWAP_WINDOW - 1 minutes); + + vm.startPrank(priceUpdater); + + oracle.updatePrice(token2, price1); + vm.warp(block.timestamp + timeGap); + oracle.updatePrice(token2, price2); + vm.warp(block.timestamp + timeGap); + + vm.stopPrank(); + + uint256 twapPrice = oracle.spot(token2); + + // With exponential moving average, TWAP should be reasonable + // It should be greater than 0 and not exceed reasonable bounds + assertGt(twapPrice, 0); + + // For reasonable bounds, TWAP should be within a reasonable range of the input prices + uint256 minPrice = price1 < price2 ? price1 : price2; + uint256 maxPrice = price1 > price2 ? price1 : price2; + + // Allow for some deviation due to exponential moving average behavior + // TWAP might be outside the [min, max] range due to the 80% weight cap + assertGt(twapPrice, minPrice / 2); // Should be at least half the minimum + assertLt(twapPrice, maxPrice * 2); // Should be at most double the maximum + } + + function testFuzzCircularBuffer(uint8 numUpdates) public { + // Bound to reasonable number of updates + numUpdates = uint8(bound(numUpdates, 1, 20)); + + vm.startPrank(priceUpdater); + + for (uint256 i = 1; i <= numUpdates; i++) { + oracle.updatePrice(token1, i * WAD); + vm.warp(block.timestamp + 1 minutes); + } + + vm.stopPrank(); + + // With new implementation, we don't have storage limits + // Just verify the latest price is correct + uint256 latestPrice = oracle.getSpotPrice(token1); + assertEq(latestPrice, numUpdates * WAD); + } + + /*////////////////////////////////////////////////////////////// + TWAP MATH VERIFICATION TESTS + //////////////////////////////////////////////////////////////*/ + + function testTWAPMathVerification() public { + vm.startPrank(priceUpdater); + + uint256 price1 = 1000 * WAD; + uint256 price2 = 2000 * WAD; + uint256 timeGap = 900; // 15 minutes + + oracle.updatePrice(token2, price1); + vm.warp(block.timestamp + timeGap); + oracle.updatePrice(token2, price2); + + uint256 twapPrice = oracle.spot(token2); + + // Calculate expected TWAP manually + // weight = timeGap / TWAP_WINDOW = 900 / 1800 = 0.5 + // newTWAP = price1 * (1 - 0.5) + price2 * 0.5 = 1000 * 0.5 + 2000 * 0.5 = 1500 + uint256 expectedTWAP = 1500 * WAD; + + assertEq(twapPrice, expectedTWAP); + + vm.stopPrank(); + } + + function testTWAPSmallTimeGap() public { + vm.startPrank(priceUpdater); + + uint256 price1 = 1000 * WAD; + uint256 price2 = 2000 * WAD; + uint256 timeGap = 18; // 18 seconds = 1% of TWAP window + + oracle.updatePrice(token2, price1); + vm.warp(block.timestamp + timeGap); + oracle.updatePrice(token2, price2); + + uint256 twapPrice = oracle.spot(token2); + + // With 1% weight, TWAP should be very close to original price + // newTWAP = 1000 * 0.99 + 2000 * 0.01 = 990 + 20 = 1010 + uint256 expectedTWAP = 1010 * WAD; + + assertEq(twapPrice, expectedTWAP); + + vm.stopPrank(); + } + + /*////////////////////////////////////////////////////////////// + STRESS TESTS + //////////////////////////////////////////////////////////////*/ + + function testStressManyPriceUpdates() public { + vm.startPrank(priceUpdater); + + // Add many price updates to test performance + uint256 numUpdates = 10; + for (uint256 i = 1; i <= numUpdates; i++) { + oracle.updatePrice(token2, i * 1000 * WAD); + vm.warp(block.timestamp + 2 minutes); + } + + vm.stopPrank(); + + // Verify TWAP calculation works correctly + uint256 twapPrice = oracle.spot(token2); + + // Should be a reasonable value (with exponential moving average, it should be closer to recent prices) + assertGt(twapPrice, 1000 * WAD); // Greater than first price + assertLt(twapPrice, numUpdates * 1000 * WAD); // Less than last price + } + + function testStressRapidUpdates() public { + vm.startPrank(priceUpdater); + + // Rapid price updates (same timestamp) + for (uint256 i = 1; i <= 5; i++) { + oracle.updatePrice(token1, i * 500 * WAD); + // Don't advance time - test same timestamp updates + } + + vm.stopPrank(); + + // Should return the latest price + uint256 spotPrice = oracle.spot(token1); + assertEq(spotPrice, 2500 * WAD); // 5 * 500 + } + + function testStressTWAPWithManyPoints() public { + vm.startPrank(priceUpdater); + + // Add many price points with varying intervals + uint256[] memory prices = new uint256[](8); + uint256[] memory intervals = new uint256[](8); + + prices[0] = 1000 * WAD; + prices[1] = 1100 * WAD; + prices[2] = 1200 * WAD; + prices[3] = 1300 * WAD; + prices[4] = 1400 * WAD; + prices[5] = 1500 * WAD; + prices[6] = 1600 * WAD; + prices[7] = 1700 * WAD; + + intervals[0] = 1 minutes; + intervals[1] = 2 minutes; + intervals[2] = 3 minutes; + intervals[3] = 1 minutes; + intervals[4] = 4 minutes; + intervals[5] = 2 minutes; + intervals[6] = 1 minutes; + intervals[7] = 0; // Last one doesn't need interval + + uint256 currentTime = block.timestamp; + + for (uint256 i = 0; i < prices.length; i++) { + oracle.updatePrice(token2, prices[i]); + if (i < prices.length - 1) { + currentTime += intervals[i]; + vm.warp(currentTime); + } + } + + vm.stopPrank(); + + // TWAP should be calculated correctly + uint256 twapPrice = oracle.spot(token2); + + // Should be within reasonable bounds + assertGt(twapPrice, 1000 * WAD); + assertLt(twapPrice, 1700 * WAD); + } + + function testStressPrecisionLimits() public { + vm.startPrank(priceUpdater); + + // Test with maximum precision values + uint256 maxPrecisionPrice1 = type(uint256).max / 1000; // Avoid overflow in calculations + uint256 maxPrecisionPrice2 = maxPrecisionPrice1 / 2; + + oracle.updatePrice(token1, maxPrecisionPrice2); + vm.warp(block.timestamp + 1 minutes); + oracle.updatePrice(token1, maxPrecisionPrice1); + + vm.stopPrank(); + + // Should not revert and return a valid price + uint256 spotPrice = oracle.spot(token1); + assertEq(spotPrice, maxPrecisionPrice1); + } +}