From a238315aa8d67d8cc233cc5dfbac1f96cf16d269 Mon Sep 17 00:00:00 2001 From: Bruno Eidam Guerios Date: Thu, 22 Feb 2024 17:02:07 -0300 Subject: [PATCH 01/18] Add standalone-utils with PriceImpact helper contract (wip) --- .vscode/settings.json | 3 + pkg/standalone-utils/.solcover.js | 3 + pkg/standalone-utils/.solhintignore | 1 + pkg/standalone-utils/CHANGELOG.md | 3 + pkg/standalone-utils/README.md | 15 + .../contracts/PriceImpact.sol | 392 ++++++++++++++++++ pkg/standalone-utils/coverage.sh | 86 ++++ pkg/standalone-utils/foundry.toml | 1 + pkg/standalone-utils/hardhat.config.ts | 20 + pkg/standalone-utils/package.json | 60 +++ .../test/foundry/PriceImpact.t.sol | 0 pkg/standalone-utils/tsconfig.json | 6 + yarn.lock | 26 ++ 13 files changed, 616 insertions(+) create mode 100644 .vscode/settings.json create mode 100644 pkg/standalone-utils/.solcover.js create mode 100644 pkg/standalone-utils/.solhintignore create mode 100644 pkg/standalone-utils/CHANGELOG.md create mode 100644 pkg/standalone-utils/README.md create mode 100644 pkg/standalone-utils/contracts/PriceImpact.sol create mode 100755 pkg/standalone-utils/coverage.sh create mode 120000 pkg/standalone-utils/foundry.toml create mode 100644 pkg/standalone-utils/hardhat.config.ts create mode 100644 pkg/standalone-utils/package.json create mode 100644 pkg/standalone-utils/test/foundry/PriceImpact.t.sol create mode 100644 pkg/standalone-utils/tsconfig.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..65a196532 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode" +} diff --git a/pkg/standalone-utils/.solcover.js b/pkg/standalone-utils/.solcover.js new file mode 100644 index 000000000..e06da7cac --- /dev/null +++ b/pkg/standalone-utils/.solcover.js @@ -0,0 +1,3 @@ +module.exports = { + skipFiles: ['test'], +}; diff --git a/pkg/standalone-utils/.solhintignore b/pkg/standalone-utils/.solhintignore new file mode 100644 index 000000000..11d11fbae --- /dev/null +++ b/pkg/standalone-utils/.solhintignore @@ -0,0 +1 @@ +contracts/test/ diff --git a/pkg/standalone-utils/CHANGELOG.md b/pkg/standalone-utils/CHANGELOG.md new file mode 100644 index 000000000..1512c4216 --- /dev/null +++ b/pkg/standalone-utils/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +## Unreleased diff --git a/pkg/standalone-utils/README.md b/pkg/standalone-utils/README.md new file mode 100644 index 000000000..6a58f5038 --- /dev/null +++ b/pkg/standalone-utils/README.md @@ -0,0 +1,15 @@ +# Balancer + +# Balancer V3 Standalone Utils + +This package contains standalone utilities that can be used to perform advanced actions in the Balancer V3 protocol. + +- [`PriceImpact`](./contracts/PriceImpact.sol) can be used by off-chain clients to calculate price impact for add liquidity unbalanced operations. + +## Overview + +### Usage + +## Licensing + +[GNU General Public License Version 3 (GPL v3)](../../LICENSE). diff --git a/pkg/standalone-utils/contracts/PriceImpact.sol b/pkg/standalone-utils/contracts/PriceImpact.sol new file mode 100644 index 000000000..86989c56b --- /dev/null +++ b/pkg/standalone-utils/contracts/PriceImpact.sol @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.4; + +import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; + +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; +import { IBasePool } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePool.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IWETH } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/misc/IWETH.sol"; +import "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; + +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; +import { EnumerableSet } from "@balancer-labs/v3-solidity-utils/contracts/openzeppelin/EnumerableSet.sol"; + +contract PriceImpact { + using Address for address payable; + using EnumerableSet for EnumerableSet.AddressSet; + using FixedPoint for int256; + + IVault private immutable _vault; + + // solhint-disable-next-line var-name-mixedcase + IWETH private immutable _weth; + + // Transient storage used to track tokens and amount flowing in and out within a batch swap. + // Set of input tokens involved in a batch swap. + EnumerableSet.AddressSet private _currentSwapTokensIn; + // Set of output tokens involved in a batch swap. + EnumerableSet.AddressSet private _currentSwapTokensOut; + // token in -> amount: tracks token in amounts within a batch swap. + mapping(address => uint256) private _currentSwapTokenInAmounts; + // token out -> amount: tracks token out amounts within a batch swap. + mapping(address => uint256) private _currentSwapTokenOutAmounts; + + modifier onlyVault() { + if (msg.sender != address(_vault)) { + revert IVaultErrors.SenderIsNotVault(msg.sender); + } + _; + } + + constructor(IVault vault, IWETH weth) { + _vault = vault; + _weth = weth; + weth.approve(address(_vault), type(uint256).max); + } + + /******************************************************************************* + Price Impact + *******************************************************************************/ + + function priceImpactForAddLiquidityUnbalanced( + address pool, + uint256[] exactAmountsIn + ) external returns (uint256 priceImpact) { + // query addLiquidityUnbalanced + uint256 bptAmountOut = queryAddLiquidityUnbalanced(pool, exactAmountsIn, 0, new bytes(0)); + // query removeLiquidityProportional + uint256[] proportionalAmountsOut = queryRemoveLiquidityProportional( + pool, + bptAmountOut, + new uint256[](exactAmountsIn.length), + userData + ); + // get deltas between exactAmountsIn and proportionalAmountsOut + int256[] deltas = new uint256[](exactAmountsIn.length); + for (uint256 i = 0; i < exactAmountsIn.length; i++) { + deltas[i] = proportionalAmountsOut[i] - exactAmountsIn[i]; + } + // query add liquidity for each delta, so we know how unbalanced each amount in is in terms of BPT + int256[] deltaBPTs = new int256[](exactAmountsIn.length); + for (uint256 i = 0; i < exactAmountsIn.length; i++) { + deltaBPTs[i] = queryAddLiquidityForTokenDelta(pool, i, deltas, deltaBPTs); + } + // zero out deltas leaving only a remaining delta within a single token + uint256 remaininDeltaIndex = zeroOutDeltas(pool, deltas, deltaBPTs); + // calculate price impact ABA with remaining delta and its respective exactAmountIn + return deltas[remaininDeltaIndex].divDown(exactAmountsIn[remaininDeltaIndex]) / 2; + } + + /******************************************************************************* + Helpers + *******************************************************************************/ + + function queryAddLiquidityForTokenDelta( + address pool, + uint256 tokenIndex, + int256 deltas, + int256 deltaBPTs + ) internal returns (int256 deltaBPT) { + uint256[] zerosWithSingleDelta = new uint256[](deltas.length); + if (deltaBPTs[tokenIndex] == 0) { + return 0; + } else if (deltaBPTs[tokenIndex] > 0) { + zerosWithSingleDelta[tokenIndex] = deltas[tokenIndex]; + return queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, new bytes(0)); + } else { + zerosWithSingleDelta[tokenIndex] = deltas[tokenIndex] * -1; + return queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, new bytes(0)); + } + } + + function zeroOutDeltas(address pool, int256[] deltas, int256[] deltaBPTs) internal returns (uint256) { + uint256 minNegativeDeltaIndex = 0; + + for (uint256 i = 0; i < deltas.length - 1; i++) { + // get minPositiveDeltaIndex and maxNegativeDeltaIndex + uint256 minPositiveDeltaIndex = minPositiveIndex(deltaBPTs); + uint256 minNegativeDeltaIndex = maxNegativeIndex(deltaBPTs); + + uint256 givenTokenIndex; + uint256 resultTokenIndex; + uint256 resultAmount; + + if (deltaBPTs[minPositiveDeltaIndex] < deltaBPTs[minNegativeDeltaIndex] * -1) { + givenTokenIndex = minPositiveDeltaIndex; + resultTokenIndex = minNegativeDeltaIndex; + resultAmount = querySwapSingleTokenExactIn( + pool, + IERC20(_vault.getPoolToken(pool, givenTokenIndex)), + IERC20(_vault.getPoolToken(pool, resultTokenIndex)), + deltas[givenTokenIndex], + new bytes(0) + ); + } else { + givenTokenIndex = minNegativeDeltaIndex; + resultTokenIndex = minPositiveDeltaIndex; + resultAmount = querySwapSingleTokenExactOut( + pool, + IERC20(_vault.getPoolToken(pool, resultTokenIndex)), + IERC20(_vault.getPoolToken(pool, givenTokenIndex)), + deltas[givenTokenIndex] * -1, + new bytes(0) + ); + } + + // Update deltas and deltaBPTs + deltas[givenTokenIndex] = 0; + deltaBPTs[givenTokenIndex] = 0; + deltas[resultTokenIndex] += resultAmount; + deltaBPTs[resultTokenIndex] = queryAddLiquidityForTokenDelta(pool, resultTokenIndex, deltas, deltaBPTs); + } + + return minNegativeDeltaIndex; + } + + // returns the index of the smallest positive integer in an array - i.e. [3, 2, -2, -3] returns 1 + function minPositiveIndex(int256[] memory array) internal returns (uint256 index) { + int256 min = type(int256).max; + for (uint256 i = 0; i < array.length; i++) { + if (array[i] > 0 && array[i] < min) { + min = array[i]; + index = i; + } + } + } + + // returns the index of the biggest negative integer in an array - i.e. [3, 1, -2, -3] returns 2 + function maxNegativeIndex(int256[] memory array) internal returns (uint256 index) { + int256 max = type(int256).min; + for (uint256 i = 0; i < array.length; i++) { + if (array[i] < 0 && array[i] > max) { + max = array[i]; + index = i; + } + } + } + + /******************************************************************************* + Pools + *******************************************************************************/ + + function _swapHook( + SwapSingleTokenHookParams calldata params + ) internal returns (uint256 amountCalculated, uint256 amountIn, uint256 amountOut) { + // The deadline is timestamp-based: it should not be relied upon for sub-minute accuracy. + // solhint-disable-next-line not-rely-on-time + if (block.timestamp > params.deadline) { + revert SwapDeadline(); + } + + (amountCalculated, amountIn, amountOut) = _vault.swap( + SwapParams({ + kind: params.kind, + pool: params.pool, + tokenIn: params.tokenIn, + tokenOut: params.tokenOut, + amountGivenRaw: params.amountGiven, + limitRaw: params.limit, + userData: params.userData + }) + ); + } + + /******************************************************************************* + Queries + *******************************************************************************/ + + /// @inheritdoc IRouter + function querySwapSingleTokenExactIn( + address pool, + IERC20 tokenIn, + IERC20 tokenOut, + uint256 exactAmountIn, + bytes calldata userData + ) external returns (uint256 amountCalculated) { + return + abi.decode( + _vault.quote( + abi.encodeWithSelector( + Router.querySwapHook.selector, + SwapSingleTokenHookParams({ + sender: msg.sender, + kind: SwapKind.EXACT_IN, + pool: pool, + tokenIn: tokenIn, + tokenOut: tokenOut, + amountGiven: exactAmountIn, + limit: 0, + deadline: type(uint256).max, + wethIsEth: false, + userData: userData + }) + ) + ), + (uint256) + ); + } + + /// @inheritdoc IRouter + function querySwapSingleTokenExactOut( + address pool, + IERC20 tokenIn, + IERC20 tokenOut, + uint256 exactAmountOut, + bytes calldata userData + ) external returns (uint256 amountCalculated) { + return + abi.decode( + _vault.quote( + abi.encodeWithSelector( + Router.querySwapHook.selector, + SwapSingleTokenHookParams({ + sender: msg.sender, + kind: SwapKind.EXACT_OUT, + pool: pool, + tokenIn: tokenIn, + tokenOut: tokenOut, + amountGiven: exactAmountOut, + limit: type(uint256).max, + deadline: type(uint256).max, + wethIsEth: false, + userData: userData + }) + ) + ), + (uint256) + ); + } + + /** + * @notice Hook for swap queries. + * @dev Can only be called by the Vault. Also handles native ETH. + * @param params Swap parameters (see IRouter for struct definition) + * @return Token amount calculated by the pool math (e.g., amountOut for a exact in swap) + */ + function querySwapHook( + SwapSingleTokenHookParams calldata params + ) external payable nonReentrant onlyVault returns (uint256) { + (uint256 amountCalculated, , ) = _swapHook(params); + + return amountCalculated; + } + + /// @inheritdoc IRouter + function queryAddLiquidityUnbalanced( + address pool, + uint256[] memory exactAmountsIn, + uint256 minBptAmountOut, + bytes memory userData + ) external returns (uint256 bptAmountOut) { + (, bptAmountOut, ) = abi.decode( + _vault.quote( + abi.encodeWithSelector( + Router.queryAddLiquidityHook.selector, + AddLiquidityHookParams({ + // we use router as a sender to simplify basic query functions + // but it is possible to add liquidity to any recipient + sender: address(this), + pool: pool, + maxAmountsIn: exactAmountsIn, + minBptAmountOut: minBptAmountOut, + kind: AddLiquidityKind.UNBALANCED, + wethIsEth: false, + userData: userData + }) + ) + ), + (uint256[], uint256, bytes) + ); + } + + /** + * @notice Hook for add liquidity queries. + * @dev Can only be called by the Vault. + * @param params Add liquidity parameters (see IRouter for struct definition) + * @return amountsIn Actual token amounts in required as inputs + * @return bptAmountOut Expected pool tokens to be minted + * @return returnData Arbitrary (optional) data with encoded response from the pool + */ + function queryAddLiquidityHook( + AddLiquidityHookParams calldata params + ) + external + payable + nonReentrant + onlyVault + returns (uint256[] memory amountsIn, uint256 bptAmountOut, bytes memory returnData) + { + (amountsIn, bptAmountOut, returnData) = _vault.addLiquidity( + AddLiquidityParams({ + pool: params.pool, + to: params.sender, + maxAmountsIn: params.maxAmountsIn, + minBptAmountOut: params.minBptAmountOut, + kind: params.kind, + userData: params.userData + }) + ); + } + + /// @inheritdoc IRouter + function queryRemoveLiquidityProportional( + address pool, + uint256 exactBptAmountIn, + uint256[] memory minAmountsOut, + bytes memory userData + ) external returns (uint256[] memory amountsOut) { + (, amountsOut, ) = abi.decode( + _vault.quote( + abi.encodeWithSelector( + Router.queryRemoveLiquidityHook.selector, + RemoveLiquidityHookParams({ + // We use router as a sender to simplify basic query functions + // but it is possible to remove liquidity from any sender + sender: address(this), + pool: pool, + minAmountsOut: minAmountsOut, + maxBptAmountIn: exactBptAmountIn, + kind: RemoveLiquidityKind.PROPORTIONAL, + wethIsEth: false, + userData: userData + }) + ) + ), + (uint256, uint256[], bytes) + ); + } + + /** + * @notice Hook for remove liquidity queries. + * @dev Can only be called by the Vault. + * @param params Remove liquidity parameters (see IRouter for struct definition) + * @return bptAmountIn Pool token amount to be burned for the output tokens + * @return amountsOut Expected token amounts to be transferred to the sender + * @return returnData Arbitrary (optional) data with encoded response from the pool + */ + function queryRemoveLiquidityHook( + RemoveLiquidityHookParams calldata params + ) + external + nonReentrant + onlyVault + returns (uint256 bptAmountIn, uint256[] memory amountsOut, bytes memory returnData) + { + return + _vault.removeLiquidity( + RemoveLiquidityParams({ + pool: params.pool, + from: params.sender, + maxBptAmountIn: params.maxBptAmountIn, + minAmountsOut: params.minAmountsOut, + kind: params.kind, + userData: params.userData + }) + ); + } +} diff --git a/pkg/standalone-utils/coverage.sh b/pkg/standalone-utils/coverage.sh new file mode 100755 index 000000000..252849717 --- /dev/null +++ b/pkg/standalone-utils/coverage.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +set -e # exit on error + +# generates lcov.info +forge coverage --report lcov + +# Initialize variables +current_file="" +lines_found=0 +lines_hit=0 + +# Clear files_with_lines_coverage.txt before usage +> files_with_lines_coverage.txt + +# Process each line of the LCOV report +while IFS= read -r line +do + if [[ $line == LF:* ]]; then + # Get the line count + lines_found=${line#LF:} + elif [[ $line == LH:* ]]; then + # Get the line hit count + lines_hit=${line#LH:} + + # Check if lines_found is equal to lines_hit + if [[ $lines_found -eq $lines_hit ]]; then + # Remember the current file as having 100% coverage + echo "$current_file" >> files_with_lines_coverage.txt + fi + elif [[ $line == SF:* ]]; then + # If the line contains "SF:", it's the start of a new file. Save the filename. + current_file=${line#SF:} + fi +done < lcov.info + +# Create a space-separated string of all file patterns +patterns=$(cat files_with_lines_coverage.txt | tr '\n' ' ') + +# Now use single lcov --extract command with all file patterns +lcov --extract lcov.info $patterns --output-file lcov.info + +# generates coverage/lcov.info +yarn hardhat coverage + +# Foundry uses relative paths but Hardhat uses absolute paths. +# Convert absolute paths to relative paths for consistency. +sed -i -e "s/\/.*$(basename "$PWD").//g" coverage/lcov.info + +# Now use single lcov --remove command with all file patterns +lcov --remove coverage/lcov.info $patterns --output-file coverage/lcov.info + +# Merge lcov files +lcov \ + --rc lcov_branch_coverage=1 \ + --add-tracefile coverage/lcov.info \ + --add-tracefile lcov.info \ + --output-file merged-lcov.info \ + --no-checksum + +# Filter out node_modules, test, and mock files +lcov \ + --rc lcov_branch_coverage=1 \ + --remove merged-lcov.info \ + "*node_modules*" "*test*" "*mock*" \ + --output-file coverage/filtered-lcov.info + +# Generate summary +lcov \ + --rc lcov_branch_coverage=1 \ + --list coverage/filtered-lcov.info + +# Open more granular breakdown in browser +if [ "$HTML" == "true" ] +then + genhtml \ + --rc genhtml_branch_coverage=1 \ + --output-directory coverage \ + coverage/filtered-lcov.info + open coverage/index.html +fi + +# Delete temp files +rm lcov.info merged-lcov.info files_with_lines_coverage.txt + + diff --git a/pkg/standalone-utils/foundry.toml b/pkg/standalone-utils/foundry.toml new file mode 120000 index 000000000..2d554be76 --- /dev/null +++ b/pkg/standalone-utils/foundry.toml @@ -0,0 +1 @@ +../../foundry.toml \ No newline at end of file diff --git a/pkg/standalone-utils/hardhat.config.ts b/pkg/standalone-utils/hardhat.config.ts new file mode 100644 index 000000000..f42e8cc79 --- /dev/null +++ b/pkg/standalone-utils/hardhat.config.ts @@ -0,0 +1,20 @@ +import '@nomicfoundation/hardhat-ethers'; +import '@nomicfoundation/hardhat-toolbox'; +import '@typechain/hardhat'; + +import 'hardhat-ignore-warnings'; +import 'hardhat-gas-reporter'; + +import { hardhatBaseConfig } from '@balancer-labs/v3-common'; + +export default { + networks: { + hardhat: { + allowUnlimitedContractSize: true, + }, + }, + solidity: { + compilers: hardhatBaseConfig.compilers, + }, + warnings: hardhatBaseConfig.warnings, +}; diff --git a/pkg/standalone-utils/package.json b/pkg/standalone-utils/package.json new file mode 100644 index 000000000..6599b89d6 --- /dev/null +++ b/pkg/standalone-utils/package.json @@ -0,0 +1,60 @@ +{ + "name": "@balancer-labs/standalone-utils", + "version": "0.1.0", + "description": "Balancer V3 Standalone Utils", + "license": "GPL-3.0-only", + "homepage": "https://github.com/balancer-labs/balancer-v3-monorepo/tree/master/pkg/standalone-utils#readme", + "repository": { + "type": "git", + "url": "https://github.com/balancer-labs/balancer-v3-monorepo.git", + "directory": "pkg/standalone-utils" + }, + "bugs": { + "url": "https://github.com/balancer-labs/balancer-v3-monorepo/issues" + }, + "files": [ + "contracts/**/*.sol", + "!contracts/test/**/*.sol" + ], + "scripts": { + "build": "yarn compile && rm -rf artifacts/build-info", + "compile": "hardhat compile", + "compile:watch": "nodemon --ext sol --exec yarn compile", + "lint": "yarn lint:solidity && yarn lint:typescript", + "lint:solidity": "npx prettier --check --plugin=prettier-plugin-solidity 'contracts/**/*.sol' ''test/**/*.sol'' && npx solhint 'contracts/**/*.sol'", + "lint:typescript": "NODE_NO_WARNINGS=1 eslint . --ext .ts --ignore-path ../../.eslintignore --max-warnings 0", + "prettier": "npx prettier --write --plugin=prettier-plugin-solidity 'contracts/**/*.sol' 'test/**/*.sol'", + "test": "yarn test:hardhat && yarn test:forge", + "test:hardhat": "hardhat test", + "test:forge": "forge test --ffi -vvv", + "test:stress": "FOUNDRY_PROFILE=intense forge test --ffi -vvv", + "coverage": "./coverage.sh", + "gas": "REPORT_GAS=true hardhat test", + "test:watch": "nodemon --ext js,ts --watch test --watch lib --exec 'clear && yarn test --no-compile'", + "slither": "yarn compile && bash -c 'source ../../slither/bin/activate && slither --compile-force-framework hardhat --ignore-compile . --config-file ../../.slither.config.json'", + "slither:triage": "yarn compile && bash -c 'source ../../slither/bin/activate && slither --compile-force-framework hardhat --ignore-compile . --config-file ../../.slither.config.json --triage-mode'" + }, + "dependencies": { + "@balancer-labs/v3-interfaces": "workspace:*" + }, + "devDependencies": { + "@balancer-labs/solidity-toolbox": "workspace:*", + "@balancer-labs/v3-solidity-utils": "workspace:*", + "@typescript-eslint/eslint-plugin": "^5.41.0", + "@typescript-eslint/parser": "^5.41.0", + "decimal.js": "^10.4.2", + "eslint": "^8.26.0", + "eslint-plugin-mocha-no-only": "^1.1.1", + "eslint-plugin-prettier": "^4.2.1", + "hardhat": "^2.14.0", + "lodash.frompairs": "^4.0.1", + "lodash.pick": "^4.4.0", + "lodash.range": "^3.2.0", + "lodash.times": "^4.3.2", + "lodash.zip": "^4.2.0", + "mathjs": "^11.8.0", + "mocha": "^10.1.0", + "nodemon": "^2.0.20", + "solhint": "^3.4.1" + } +} diff --git a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol new file mode 100644 index 000000000..e69de29bb diff --git a/pkg/standalone-utils/tsconfig.json b/pkg/standalone-utils/tsconfig.json new file mode 100644 index 000000000..b4e69ae1f --- /dev/null +++ b/pkg/standalone-utils/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + } +} diff --git a/yarn.lock b/yarn.lock index b2367b149..189fec054 100644 --- a/yarn.lock +++ b/yarn.lock @@ -84,6 +84,32 @@ __metadata: languageName: unknown linkType: soft +"@balancer-labs/standalone-utils@workspace:pkg/standalone-utils": + version: 0.0.0-use.local + resolution: "@balancer-labs/standalone-utils@workspace:pkg/standalone-utils" + dependencies: + "@balancer-labs/solidity-toolbox": "workspace:*" + "@balancer-labs/v3-interfaces": "workspace:*" + "@balancer-labs/v3-solidity-utils": "workspace:*" + "@typescript-eslint/eslint-plugin": "npm:^5.41.0" + "@typescript-eslint/parser": "npm:^5.41.0" + decimal.js: "npm:^10.4.2" + eslint: "npm:^8.26.0" + eslint-plugin-mocha-no-only: "npm:^1.1.1" + eslint-plugin-prettier: "npm:^4.2.1" + hardhat: "npm:^2.14.0" + lodash.frompairs: "npm:^4.0.1" + lodash.pick: "npm:^4.4.0" + lodash.range: "npm:^3.2.0" + lodash.times: "npm:^4.3.2" + lodash.zip: "npm:^4.2.0" + mathjs: "npm:^11.8.0" + mocha: "npm:^10.1.0" + nodemon: "npm:^2.0.20" + solhint: "npm:^3.4.1" + languageName: unknown + linkType: soft + "@balancer-labs/v3-benchmarks@workspace:pvt/benchmarks": version: 0.0.0-use.local resolution: "@balancer-labs/v3-benchmarks@workspace:pvt/benchmarks" From 5c06a0a14e1fa45d88ba3681915f4075ac147268 Mon Sep 17 00:00:00 2001 From: Bruno Eidam Guerios Date: Mon, 26 Feb 2024 14:14:53 -0300 Subject: [PATCH 02/18] Apply suggestions from hardhat solidity extension on vscode --- .../contracts/PriceImpact.sol | 110 +++++++----------- 1 file changed, 45 insertions(+), 65 deletions(-) diff --git a/pkg/standalone-utils/contracts/PriceImpact.sol b/pkg/standalone-utils/contracts/PriceImpact.sol index 86989c56b..e318a8f94 100644 --- a/pkg/standalone-utils/contracts/PriceImpact.sol +++ b/pkg/standalone-utils/contracts/PriceImpact.sol @@ -16,26 +16,10 @@ import "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; import { EnumerableSet } from "@balancer-labs/v3-solidity-utils/contracts/openzeppelin/EnumerableSet.sol"; -contract PriceImpact { - using Address for address payable; - using EnumerableSet for EnumerableSet.AddressSet; - using FixedPoint for int256; +contract PriceImpact is ReentrancyGuard { IVault private immutable _vault; - // solhint-disable-next-line var-name-mixedcase - IWETH private immutable _weth; - - // Transient storage used to track tokens and amount flowing in and out within a batch swap. - // Set of input tokens involved in a batch swap. - EnumerableSet.AddressSet private _currentSwapTokensIn; - // Set of output tokens involved in a batch swap. - EnumerableSet.AddressSet private _currentSwapTokensOut; - // token in -> amount: tracks token in amounts within a batch swap. - mapping(address => uint256) private _currentSwapTokenInAmounts; - // token out -> amount: tracks token out amounts within a batch swap. - mapping(address => uint256) private _currentSwapTokenOutAmounts; - modifier onlyVault() { if (msg.sender != address(_vault)) { revert IVaultErrors.SenderIsNotVault(msg.sender); @@ -43,10 +27,8 @@ contract PriceImpact { _; } - constructor(IVault vault, IWETH weth) { + constructor(IVault vault) { _vault = vault; - _weth = weth; - weth.approve(address(_vault), type(uint256).max); } /******************************************************************************* @@ -55,31 +37,32 @@ contract PriceImpact { function priceImpactForAddLiquidityUnbalanced( address pool, - uint256[] exactAmountsIn + uint256[] memory exactAmountsIn ) external returns (uint256 priceImpact) { // query addLiquidityUnbalanced - uint256 bptAmountOut = queryAddLiquidityUnbalanced(pool, exactAmountsIn, 0, new bytes(0)); + uint256 bptAmountOut = this.queryAddLiquidityUnbalanced(pool, exactAmountsIn, 0, new bytes(0)); // query removeLiquidityProportional - uint256[] proportionalAmountsOut = queryRemoveLiquidityProportional( + uint256[] memory proportionalAmountsOut = this.queryRemoveLiquidityProportional( pool, bptAmountOut, new uint256[](exactAmountsIn.length), - userData + new bytes(0) ); // get deltas between exactAmountsIn and proportionalAmountsOut - int256[] deltas = new uint256[](exactAmountsIn.length); + int256[] memory deltas = new int256[](exactAmountsIn.length); for (uint256 i = 0; i < exactAmountsIn.length; i++) { - deltas[i] = proportionalAmountsOut[i] - exactAmountsIn[i]; + deltas[i] = int(proportionalAmountsOut[i] - exactAmountsIn[i]); } // query add liquidity for each delta, so we know how unbalanced each amount in is in terms of BPT - int256[] deltaBPTs = new int256[](exactAmountsIn.length); + int256[] memory deltaBPTs = new int256[](exactAmountsIn.length); for (uint256 i = 0; i < exactAmountsIn.length; i++) { deltaBPTs[i] = queryAddLiquidityForTokenDelta(pool, i, deltas, deltaBPTs); } // zero out deltas leaving only a remaining delta within a single token uint256 remaininDeltaIndex = zeroOutDeltas(pool, deltas, deltaBPTs); // calculate price impact ABA with remaining delta and its respective exactAmountIn - return deltas[remaininDeltaIndex].divDown(exactAmountsIn[remaininDeltaIndex]) / 2; + uint256 delta = uint(deltas[remaininDeltaIndex] * -1); // remaining delta is always negative, so by multiplying by -1 we get a positive number + return FixedPoint.divDown(delta, exactAmountsIn[remaininDeltaIndex]) / 2; } /******************************************************************************* @@ -89,28 +72,29 @@ contract PriceImpact { function queryAddLiquidityForTokenDelta( address pool, uint256 tokenIndex, - int256 deltas, - int256 deltaBPTs + int256[] memory deltas, + int256[] memory deltaBPTs ) internal returns (int256 deltaBPT) { - uint256[] zerosWithSingleDelta = new uint256[](deltas.length); + uint256[] memory zerosWithSingleDelta = new uint256[](deltas.length); if (deltaBPTs[tokenIndex] == 0) { return 0; } else if (deltaBPTs[tokenIndex] > 0) { - zerosWithSingleDelta[tokenIndex] = deltas[tokenIndex]; - return queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, new bytes(0)); + zerosWithSingleDelta[tokenIndex] = uint(deltas[tokenIndex]); + return int(this.queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, new bytes(0))); } else { - zerosWithSingleDelta[tokenIndex] = deltas[tokenIndex] * -1; - return queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, new bytes(0)); + zerosWithSingleDelta[tokenIndex] = uint(deltas[tokenIndex] * -1); + return int(this.queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, new bytes(0))) * -1; } } - function zeroOutDeltas(address pool, int256[] deltas, int256[] deltaBPTs) internal returns (uint256) { + function zeroOutDeltas(address pool, int256[] memory deltas, int256[] memory deltaBPTs) internal returns (uint256) { uint256 minNegativeDeltaIndex = 0; + IERC20[] memory poolTokens = _vault.getPoolTokens(pool); for (uint256 i = 0; i < deltas.length - 1; i++) { // get minPositiveDeltaIndex and maxNegativeDeltaIndex uint256 minPositiveDeltaIndex = minPositiveIndex(deltaBPTs); - uint256 minNegativeDeltaIndex = maxNegativeIndex(deltaBPTs); + minNegativeDeltaIndex = maxNegativeIndex(deltaBPTs); uint256 givenTokenIndex; uint256 resultTokenIndex; @@ -119,21 +103,21 @@ contract PriceImpact { if (deltaBPTs[minPositiveDeltaIndex] < deltaBPTs[minNegativeDeltaIndex] * -1) { givenTokenIndex = minPositiveDeltaIndex; resultTokenIndex = minNegativeDeltaIndex; - resultAmount = querySwapSingleTokenExactIn( + resultAmount = this.querySwapSingleTokenExactIn( pool, - IERC20(_vault.getPoolToken(pool, givenTokenIndex)), - IERC20(_vault.getPoolToken(pool, resultTokenIndex)), - deltas[givenTokenIndex], + poolTokens[givenTokenIndex], + poolTokens[resultTokenIndex], + uint(deltas[givenTokenIndex]), new bytes(0) ); } else { givenTokenIndex = minNegativeDeltaIndex; resultTokenIndex = minPositiveDeltaIndex; - resultAmount = querySwapSingleTokenExactOut( + resultAmount = this.querySwapSingleTokenExactOut( pool, - IERC20(_vault.getPoolToken(pool, resultTokenIndex)), - IERC20(_vault.getPoolToken(pool, givenTokenIndex)), - deltas[givenTokenIndex] * -1, + poolTokens[resultTokenIndex], + poolTokens[givenTokenIndex], + uint(deltas[givenTokenIndex] * -1), new bytes(0) ); } @@ -141,7 +125,7 @@ contract PriceImpact { // Update deltas and deltaBPTs deltas[givenTokenIndex] = 0; deltaBPTs[givenTokenIndex] = 0; - deltas[resultTokenIndex] += resultAmount; + deltas[resultTokenIndex] += int(resultAmount); deltaBPTs[resultTokenIndex] = queryAddLiquidityForTokenDelta(pool, resultTokenIndex, deltas, deltaBPTs); } @@ -149,7 +133,7 @@ contract PriceImpact { } // returns the index of the smallest positive integer in an array - i.e. [3, 2, -2, -3] returns 1 - function minPositiveIndex(int256[] memory array) internal returns (uint256 index) { + function minPositiveIndex(int256[] memory array) internal pure returns (uint256 index) { int256 min = type(int256).max; for (uint256 i = 0; i < array.length; i++) { if (array[i] > 0 && array[i] < min) { @@ -160,7 +144,7 @@ contract PriceImpact { } // returns the index of the biggest negative integer in an array - i.e. [3, 1, -2, -3] returns 2 - function maxNegativeIndex(int256[] memory array) internal returns (uint256 index) { + function maxNegativeIndex(int256[] memory array) internal pure returns (uint256 index) { int256 max = type(int256).min; for (uint256 i = 0; i < array.length; i++) { if (array[i] < 0 && array[i] > max) { @@ -175,12 +159,12 @@ contract PriceImpact { *******************************************************************************/ function _swapHook( - SwapSingleTokenHookParams calldata params + IRouter.SwapSingleTokenHookParams calldata params ) internal returns (uint256 amountCalculated, uint256 amountIn, uint256 amountOut) { // The deadline is timestamp-based: it should not be relied upon for sub-minute accuracy. // solhint-disable-next-line not-rely-on-time if (block.timestamp > params.deadline) { - revert SwapDeadline(); + revert IRouter.SwapDeadline(); } (amountCalculated, amountIn, amountOut) = _vault.swap( @@ -200,7 +184,6 @@ contract PriceImpact { Queries *******************************************************************************/ - /// @inheritdoc IRouter function querySwapSingleTokenExactIn( address pool, IERC20 tokenIn, @@ -212,8 +195,8 @@ contract PriceImpact { abi.decode( _vault.quote( abi.encodeWithSelector( - Router.querySwapHook.selector, - SwapSingleTokenHookParams({ + PriceImpact.querySwapHook.selector, + IRouter.SwapSingleTokenHookParams({ sender: msg.sender, kind: SwapKind.EXACT_IN, pool: pool, @@ -231,7 +214,6 @@ contract PriceImpact { ); } - /// @inheritdoc IRouter function querySwapSingleTokenExactOut( address pool, IERC20 tokenIn, @@ -243,8 +225,8 @@ contract PriceImpact { abi.decode( _vault.quote( abi.encodeWithSelector( - Router.querySwapHook.selector, - SwapSingleTokenHookParams({ + PriceImpact.querySwapHook.selector, + IRouter.SwapSingleTokenHookParams({ sender: msg.sender, kind: SwapKind.EXACT_OUT, pool: pool, @@ -269,14 +251,13 @@ contract PriceImpact { * @return Token amount calculated by the pool math (e.g., amountOut for a exact in swap) */ function querySwapHook( - SwapSingleTokenHookParams calldata params + IRouter.SwapSingleTokenHookParams calldata params ) external payable nonReentrant onlyVault returns (uint256) { (uint256 amountCalculated, , ) = _swapHook(params); return amountCalculated; } - /// @inheritdoc IRouter function queryAddLiquidityUnbalanced( address pool, uint256[] memory exactAmountsIn, @@ -286,8 +267,8 @@ contract PriceImpact { (, bptAmountOut, ) = abi.decode( _vault.quote( abi.encodeWithSelector( - Router.queryAddLiquidityHook.selector, - AddLiquidityHookParams({ + PriceImpact.queryAddLiquidityHook.selector, + IRouter.AddLiquidityHookParams({ // we use router as a sender to simplify basic query functions // but it is possible to add liquidity to any recipient sender: address(this), @@ -313,7 +294,7 @@ contract PriceImpact { * @return returnData Arbitrary (optional) data with encoded response from the pool */ function queryAddLiquidityHook( - AddLiquidityHookParams calldata params + IRouter.AddLiquidityHookParams calldata params ) external payable @@ -333,7 +314,6 @@ contract PriceImpact { ); } - /// @inheritdoc IRouter function queryRemoveLiquidityProportional( address pool, uint256 exactBptAmountIn, @@ -343,8 +323,8 @@ contract PriceImpact { (, amountsOut, ) = abi.decode( _vault.quote( abi.encodeWithSelector( - Router.queryRemoveLiquidityHook.selector, - RemoveLiquidityHookParams({ + PriceImpact.queryRemoveLiquidityHook.selector, + IRouter.RemoveLiquidityHookParams({ // We use router as a sender to simplify basic query functions // but it is possible to remove liquidity from any sender sender: address(this), @@ -370,7 +350,7 @@ contract PriceImpact { * @return returnData Arbitrary (optional) data with encoded response from the pool */ function queryRemoveLiquidityHook( - RemoveLiquidityHookParams calldata params + IRouter.RemoveLiquidityHookParams calldata params ) external nonReentrant From 447366ebd26cd194b013a4f165f332dbfd0293b2 Mon Sep 17 00:00:00 2001 From: Bruno Eidam Guerios Date: Mon, 26 Feb 2024 14:46:04 -0300 Subject: [PATCH 03/18] Add .vscode to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 571140e84..1983a4202 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ slither/ # Jupyter .ipynb_checkpoints/ + +# VSCode +.vscode/* \ No newline at end of file From b51856b364f7b262bb1a5f548489f9a784456925 Mon Sep 17 00:00:00 2001 From: Bruno Eidam Guerios Date: Tue, 27 Feb 2024 15:00:10 -0300 Subject: [PATCH 04/18] Apply code review suggestions --- .gitignore | 3 - .../contracts/PriceImpact.sol | 76 ++++++++++++++----- 2 files changed, 58 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index 1983a4202..571140e84 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,3 @@ slither/ # Jupyter .ipynb_checkpoints/ - -# VSCode -.vscode/* \ No newline at end of file diff --git a/pkg/standalone-utils/contracts/PriceImpact.sol b/pkg/standalone-utils/contracts/PriceImpact.sol index e318a8f94..c1c6fe895 100644 --- a/pkg/standalone-utils/contracts/PriceImpact.sol +++ b/pkg/standalone-utils/contracts/PriceImpact.sol @@ -18,6 +18,8 @@ import { EnumerableSet } from "@balancer-labs/v3-solidity-utils/contracts/openze contract PriceImpact is ReentrancyGuard { + using FixedPoint for uint256; + IVault private immutable _vault; modifier onlyVault() { @@ -40,9 +42,9 @@ contract PriceImpact is ReentrancyGuard { uint256[] memory exactAmountsIn ) external returns (uint256 priceImpact) { // query addLiquidityUnbalanced - uint256 bptAmountOut = this.queryAddLiquidityUnbalanced(pool, exactAmountsIn, 0, new bytes(0)); + uint256 bptAmountOut = _queryAddLiquidityUnbalanced(pool, exactAmountsIn, 0, new bytes(0)); // query removeLiquidityProportional - uint256[] memory proportionalAmountsOut = this.queryRemoveLiquidityProportional( + uint256[] memory proportionalAmountsOut = _queryRemoveLiquidityProportional( pool, bptAmountOut, new uint256[](exactAmountsIn.length), @@ -56,20 +58,20 @@ contract PriceImpact is ReentrancyGuard { // query add liquidity for each delta, so we know how unbalanced each amount in is in terms of BPT int256[] memory deltaBPTs = new int256[](exactAmountsIn.length); for (uint256 i = 0; i < exactAmountsIn.length; i++) { - deltaBPTs[i] = queryAddLiquidityForTokenDelta(pool, i, deltas, deltaBPTs); + deltaBPTs[i] = _queryAddLiquidityForTokenDelta(pool, i, deltas, deltaBPTs); } // zero out deltas leaving only a remaining delta within a single token - uint256 remaininDeltaIndex = zeroOutDeltas(pool, deltas, deltaBPTs); + uint256 remaininDeltaIndex = _zeroOutDeltas(pool, deltas, deltaBPTs); // calculate price impact ABA with remaining delta and its respective exactAmountIn uint256 delta = uint(deltas[remaininDeltaIndex] * -1); // remaining delta is always negative, so by multiplying by -1 we get a positive number - return FixedPoint.divDown(delta, exactAmountsIn[remaininDeltaIndex]) / 2; + return delta.divDown(exactAmountsIn[remaininDeltaIndex]) / 2; } /******************************************************************************* Helpers *******************************************************************************/ - function queryAddLiquidityForTokenDelta( + function _queryAddLiquidityForTokenDelta( address pool, uint256 tokenIndex, int256[] memory deltas, @@ -80,21 +82,21 @@ contract PriceImpact is ReentrancyGuard { return 0; } else if (deltaBPTs[tokenIndex] > 0) { zerosWithSingleDelta[tokenIndex] = uint(deltas[tokenIndex]); - return int(this.queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, new bytes(0))); + return int(_queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, new bytes(0))); } else { zerosWithSingleDelta[tokenIndex] = uint(deltas[tokenIndex] * -1); - return int(this.queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, new bytes(0))) * -1; + return int(_queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, new bytes(0))) * -1; } } - function zeroOutDeltas(address pool, int256[] memory deltas, int256[] memory deltaBPTs) internal returns (uint256) { + function _zeroOutDeltas(address pool, int256[] memory deltas, int256[] memory deltaBPTs) internal returns (uint256) { uint256 minNegativeDeltaIndex = 0; IERC20[] memory poolTokens = _vault.getPoolTokens(pool); for (uint256 i = 0; i < deltas.length - 1; i++) { // get minPositiveDeltaIndex and maxNegativeDeltaIndex - uint256 minPositiveDeltaIndex = minPositiveIndex(deltaBPTs); - minNegativeDeltaIndex = maxNegativeIndex(deltaBPTs); + uint256 minPositiveDeltaIndex = _minPositiveIndex(deltaBPTs); + minNegativeDeltaIndex = _maxNegativeIndex(deltaBPTs); uint256 givenTokenIndex; uint256 resultTokenIndex; @@ -103,22 +105,22 @@ contract PriceImpact is ReentrancyGuard { if (deltaBPTs[minPositiveDeltaIndex] < deltaBPTs[minNegativeDeltaIndex] * -1) { givenTokenIndex = minPositiveDeltaIndex; resultTokenIndex = minNegativeDeltaIndex; - resultAmount = this.querySwapSingleTokenExactIn( + resultAmount = _querySwapSingleTokenExactIn( pool, poolTokens[givenTokenIndex], poolTokens[resultTokenIndex], uint(deltas[givenTokenIndex]), - new bytes(0) + "" ); } else { givenTokenIndex = minNegativeDeltaIndex; resultTokenIndex = minPositiveDeltaIndex; - resultAmount = this.querySwapSingleTokenExactOut( + resultAmount = _querySwapSingleTokenExactOut( pool, poolTokens[resultTokenIndex], poolTokens[givenTokenIndex], uint(deltas[givenTokenIndex] * -1), - new bytes(0) + "" ); } @@ -126,14 +128,14 @@ contract PriceImpact is ReentrancyGuard { deltas[givenTokenIndex] = 0; deltaBPTs[givenTokenIndex] = 0; deltas[resultTokenIndex] += int(resultAmount); - deltaBPTs[resultTokenIndex] = queryAddLiquidityForTokenDelta(pool, resultTokenIndex, deltas, deltaBPTs); + deltaBPTs[resultTokenIndex] = _queryAddLiquidityForTokenDelta(pool, resultTokenIndex, deltas, deltaBPTs); } return minNegativeDeltaIndex; } // returns the index of the smallest positive integer in an array - i.e. [3, 2, -2, -3] returns 1 - function minPositiveIndex(int256[] memory array) internal pure returns (uint256 index) { + function _minPositiveIndex(int256[] memory array) internal pure returns (uint256 index) { int256 min = type(int256).max; for (uint256 i = 0; i < array.length; i++) { if (array[i] > 0 && array[i] < min) { @@ -144,7 +146,7 @@ contract PriceImpact is ReentrancyGuard { } // returns the index of the biggest negative integer in an array - i.e. [3, 1, -2, -3] returns 2 - function maxNegativeIndex(int256[] memory array) internal pure returns (uint256 index) { + function _maxNegativeIndex(int256[] memory array) internal pure returns (uint256 index) { int256 max = type(int256).min; for (uint256 i = 0; i < array.length; i++) { if (array[i] < 0 && array[i] > max) { @@ -191,6 +193,16 @@ contract PriceImpact is ReentrancyGuard { uint256 exactAmountIn, bytes calldata userData ) external returns (uint256 amountCalculated) { + return _querySwapSingleTokenExactIn(pool, tokenIn, tokenOut, exactAmountIn, userData); + } + + function _querySwapSingleTokenExactIn( + address pool, + IERC20 tokenIn, + IERC20 tokenOut, + uint256 exactAmountIn, + bytes memory userData + ) internal returns (uint256 amountCalculated) { return abi.decode( _vault.quote( @@ -221,6 +233,16 @@ contract PriceImpact is ReentrancyGuard { uint256 exactAmountOut, bytes calldata userData ) external returns (uint256 amountCalculated) { + return _querySwapSingleTokenExactOut(pool, tokenIn, tokenOut, exactAmountOut, userData); + } + + function _querySwapSingleTokenExactOut( + address pool, + IERC20 tokenIn, + IERC20 tokenOut, + uint256 exactAmountOut, + bytes memory userData + ) internal returns (uint256 amountCalculated) { return abi.decode( _vault.quote( @@ -264,6 +286,15 @@ contract PriceImpact is ReentrancyGuard { uint256 minBptAmountOut, bytes memory userData ) external returns (uint256 bptAmountOut) { + return _queryAddLiquidityUnbalanced(pool, exactAmountsIn, minBptAmountOut, userData); + } + + function _queryAddLiquidityUnbalanced( + address pool, + uint256[] memory exactAmountsIn, + uint256 minBptAmountOut, + bytes memory userData + ) internal returns (uint256 bptAmountOut) { (, bptAmountOut, ) = abi.decode( _vault.quote( abi.encodeWithSelector( @@ -320,6 +351,15 @@ contract PriceImpact is ReentrancyGuard { uint256[] memory minAmountsOut, bytes memory userData ) external returns (uint256[] memory amountsOut) { + return _queryRemoveLiquidityProportional(pool, exactBptAmountIn, minAmountsOut, userData); + } + + function _queryRemoveLiquidityProportional( + address pool, + uint256 exactBptAmountIn, + uint256[] memory minAmountsOut, + bytes memory userData + ) internal returns (uint256[] memory amountsOut) { (, amountsOut, ) = abi.decode( _vault.quote( abi.encodeWithSelector( From 21dbcbf54fe0439fb0514c04fa8dfa9c06a8b3e5 Mon Sep 17 00:00:00 2001 From: Bruno Eidam Guerios Date: Tue, 27 Feb 2024 18:05:41 -0300 Subject: [PATCH 05/18] Add test (wip) --- .../contracts/PriceImpact.sol | 8 +-- .../test/foundry/PriceImpact.t.sol | 59 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/pkg/standalone-utils/contracts/PriceImpact.sol b/pkg/standalone-utils/contracts/PriceImpact.sol index c1c6fe895..2f984bab1 100644 --- a/pkg/standalone-utils/contracts/PriceImpact.sol +++ b/pkg/standalone-utils/contracts/PriceImpact.sol @@ -42,13 +42,13 @@ contract PriceImpact is ReentrancyGuard { uint256[] memory exactAmountsIn ) external returns (uint256 priceImpact) { // query addLiquidityUnbalanced - uint256 bptAmountOut = _queryAddLiquidityUnbalanced(pool, exactAmountsIn, 0, new bytes(0)); + uint256 bptAmountOut = _queryAddLiquidityUnbalanced(pool, exactAmountsIn, 0, ""); // query removeLiquidityProportional uint256[] memory proportionalAmountsOut = _queryRemoveLiquidityProportional( pool, bptAmountOut, new uint256[](exactAmountsIn.length), - new bytes(0) + "" ); // get deltas between exactAmountsIn and proportionalAmountsOut int256[] memory deltas = new int256[](exactAmountsIn.length); @@ -82,10 +82,10 @@ contract PriceImpact is ReentrancyGuard { return 0; } else if (deltaBPTs[tokenIndex] > 0) { zerosWithSingleDelta[tokenIndex] = uint(deltas[tokenIndex]); - return int(_queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, new bytes(0))); + return int(_queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, "")); } else { zerosWithSingleDelta[tokenIndex] = uint(deltas[tokenIndex] * -1); - return int(_queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, new bytes(0))) * -1; + return int(_queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, "")) * -1; } } diff --git a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol index e69de29bb..e27f1dab5 100644 --- a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol +++ b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.4; + +import "forge-std/Test.sol"; +import { GasSnapshot } from "forge-gas-snapshot/GasSnapshot.sol"; + +import { IERC20Errors } from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { IVaultExtension } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExtension.sol"; +import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; +import { IERC20MultiToken } from "@balancer-labs/v3-interfaces/contracts/vault/IERC20MultiToken.sol"; +import { IAuthentication } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/helpers/IAuthentication.sol"; + +import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/ArrayHelpers.sol"; +import { ERC20TestToken } from "@balancer-labs/v3-solidity-utils/contracts/test/ERC20TestToken.sol"; +import { WETHTestToken } from "@balancer-labs/v3-solidity-utils/contracts/test/WETHTestToken.sol"; +import { BasicAuthorizerMock } from "@balancer-labs/v3-solidity-utils/contracts/test/BasicAuthorizerMock.sol"; +import { EVMCallModeHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/EVMCallModeHelpers.sol"; + +import { PoolMock } from "@balancer-labs/v3-vault/contracts/test/PoolMock.sol"; +import { Router } from "@balancer-labs/v3-vault/contracts/Router.sol"; +import { VaultMock } from "@balancer-labs/v3-vault/contracts/test/VaultMock.sol"; +import { VaultExtensionMock } from "@balancer-labs/v3-vault/contracts/test/VaultExtensionMock.sol"; + +import { PriceImpact } from "../../contracts/PriceImpact.sol"; + +import { VaultMockDeployer } from "@balancer-labs/v3-vault/test/foundry/utils/VaultMockDeployer.sol"; + +import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; + +contract PriceImpactTest is BaseVaultTest { + using ArrayHelpers for *; + + uint256 internal usdcAmountIn = 1e3 * 1e6; + uint256 internal daiAmountIn = 1e3 * 1e18; + uint256 internal daiAmountOut = 1e2 * 1e18; + uint256 internal ethAmountIn = 1e3 ether; + uint256 internal initBpt = 10e18; + uint256 internal bptAmountOut = 1e18; + + PoolMock internal wethPool; + + PriceImpact internal priceImpactHelper; + + function setUp() public virtual override { + BaseVaultTest.setUp(); + priceImpactHelper = new PriceImpact(IVault(address(vault))); + } + + function testPriceImpact() public { + uint256 priceImpact = priceImpactHelper.priceImpactForAddLiquidityUnbalanced(pool, [poolInitAmount, 0].toMemoryArray()); + assertEq(priceImpact, 123, "Incorrect price impact for add liquidity unbalanced"); + } + +} From bbd553c8eb23b5a0429437d0789fec5db02b4c46 Mon Sep 17 00:00:00 2001 From: Bruno Eidam Guerios Date: Wed, 28 Feb 2024 11:13:54 -0300 Subject: [PATCH 06/18] Fix NotStaticCall error from tests --- pkg/standalone-utils/test/foundry/PriceImpact.t.sol | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol index e27f1dab5..198d9dfba 100644 --- a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol +++ b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol @@ -52,8 +52,11 @@ contract PriceImpactTest is BaseVaultTest { } function testPriceImpact() public { - uint256 priceImpact = priceImpactHelper.priceImpactForAddLiquidityUnbalanced(pool, [poolInitAmount, 0].toMemoryArray()); + vm.prank(address(0), address(0)); + uint256 priceImpact = priceImpactHelper.priceImpactForAddLiquidityUnbalanced( + pool, + [poolInitAmount, 0].toMemoryArray() + ); assertEq(priceImpact, 123, "Incorrect price impact for add liquidity unbalanced"); } - } From daa6954f9083235b0f66db822330b157e30e2b60 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Ubeira Date: Wed, 13 Mar 2024 16:25:55 -0300 Subject: [PATCH 07/18] Implement helper, tests. --- pkg/interfaces/contracts/vault/IRouter.sol | 2 + .../contracts/vault/IVaultErrors.sol | 2 + .../contracts/vault/IVaultExtension.sol | 17 +++ .../contracts/helpers/RawCallHelpers.sol | 58 +++++++++ .../test/foundry/RawCallHelpers.t.sol | 51 ++++++++ ...SwapSingleTokenExactInWithProtocolFee.snap | 2 +- pkg/vault/contracts/VaultExtension.sol | 22 ++++ pkg/vault/contracts/test/RouterMock.sol | 113 ++++++++++++++++++ pkg/vault/test/Queries.test.ts | 58 ++++++++- 9 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 pkg/solidity-utils/contracts/helpers/RawCallHelpers.sol create mode 100644 pkg/solidity-utils/test/foundry/RawCallHelpers.t.sol diff --git a/pkg/interfaces/contracts/vault/IRouter.sol b/pkg/interfaces/contracts/vault/IRouter.sol index ffb8a7f1b..401b660a0 100644 --- a/pkg/interfaces/contracts/vault/IRouter.sol +++ b/pkg/interfaces/contracts/vault/IRouter.sol @@ -9,6 +9,8 @@ import { IBasePool } from "./IBasePool.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IRouter { + error FailedQuoteInnerCall(); + /*************************************************************************** Pool Initialization ***************************************************************************/ diff --git a/pkg/interfaces/contracts/vault/IVaultErrors.sol b/pkg/interfaces/contracts/vault/IVaultErrors.sol index 3e0b6e6aa..b9b14bba5 100644 --- a/pkg/interfaces/contracts/vault/IVaultErrors.sol +++ b/pkg/interfaces/contracts/vault/IVaultErrors.sol @@ -272,4 +272,6 @@ interface IVaultErrors { /// @dev The vault admin was configured with an incorrect Vault address. error WrongVaultAdminDeployment(); + + error QuoteResultSpoofed(); } diff --git a/pkg/interfaces/contracts/vault/IVaultExtension.sol b/pkg/interfaces/contracts/vault/IVaultExtension.sol index e48a8b8c9..09be26152 100644 --- a/pkg/interfaces/contracts/vault/IVaultExtension.sol +++ b/pkg/interfaces/contracts/vault/IVaultExtension.sol @@ -322,6 +322,23 @@ interface IVaultExtension { */ function quote(bytes calldata data) external payable returns (bytes memory result); + /** + * @notice Performs a callback on msg.sender with arguments provided in `data`. + * @dev Used to query a set of operations on the Vault. Only off-chain eth_call are allowed, + * anything else will revert. + * + * Allows querying any operation on the Vault that has the `withLocker` modifier. + * + * Allows the external calling of a function via the Vault contract to + * access Vault's functions guarded by `withLocker`. + * `transient` modifier ensuring balances changes within the Vault are settled. + * + * This call always reverts, returning the result in the revert reason. + * + * @param data Contains function signature and args to be passed to the msg.sender + */ + function quoteAndRevert(bytes calldata data) external payable; + /** * @notice Checks if the queries enabled on the Vault. * @return If true, then queries are disabled diff --git a/pkg/solidity-utils/contracts/helpers/RawCallHelpers.sol b/pkg/solidity-utils/contracts/helpers/RawCallHelpers.sol new file mode 100644 index 000000000..28b165cb0 --- /dev/null +++ b/pkg/solidity-utils/contracts/helpers/RawCallHelpers.sol @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.4; + +// solhint-disable no-inline-assembly + +library RawCallHelpers { + error Result(bytes result); + + error UnexpectedCallSuccess(); + + error ErrorSelectorNotFound(); + + function unwrapRawCallResult(bool success, bytes memory resultRaw) internal pure returns (bytes memory) { + if (success) { + revert UnexpectedCallSuccess(); + } + + bytes4 errorSelector = RawCallHelpers.parseSelector(resultRaw); + if (errorSelector != Result.selector) { + // Bubble up error message if the revert reason is not the expected one. + RawCallHelpers.bubbleUpRevert(resultRaw); + } + + uint256 resultRawLength = resultRaw.length; + assembly { + resultRaw := add(resultRaw, 0x04) // Slice the sighash. + mstore(resultRaw, sub(resultRawLength, 4)) // Set proper length + } + + return abi.decode(resultRaw, (bytes)); + } + + /// @dev Returns the first 4 bytes in an array, reverting if the length is < 4. + function parseSelector(bytes memory callResult) internal pure returns (bytes4 errorSelector) { + if (callResult.length < 4) { + revert ErrorSelectorNotFound(); + } + assembly { + errorSelector := mload(add(callResult, 0x20)) // Load the first 4 bytes from data (skip length offset) + } + } + + /// @dev Taken from Openzeppelin's Address. + function bubbleUpRevert(bytes memory returndata) internal pure { + // Look for revert reason and bubble it up if present + if (returndata.length > 0) { + // The easiest way to bubble the revert reason is using memory via assembly + /// @solidity memory-safe-assembly + assembly { + let returndata_size := mload(returndata) + revert(add(32, returndata), returndata_size) + } + } else { + revert ErrorSelectorNotFound(); + } + } +} diff --git a/pkg/solidity-utils/test/foundry/RawCallHelpers.t.sol b/pkg/solidity-utils/test/foundry/RawCallHelpers.t.sol new file mode 100644 index 000000000..51f939edd --- /dev/null +++ b/pkg/solidity-utils/test/foundry/RawCallHelpers.t.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.4; + +import "forge-std/Test.sol"; + +import { RawCallHelpers } from "../../contracts/helpers/RawCallHelpers.sol"; + +contract RawCallHelpersTest is Test { + error TestCustomError(uint256 code); + + function testUnwrapRawCallResultSuccess() public { + vm.expectRevert(abi.encodeWithSelector(RawCallHelpers.UnexpectedCallSuccess.selector)); + RawCallHelpers.unwrapRawCallResult(true, ""); + } + + function testUnwrapRawCallResultNoSelector() public { + vm.expectRevert(abi.encodeWithSelector(RawCallHelpers.ErrorSelectorNotFound.selector)); + RawCallHelpers.unwrapRawCallResult(false, ""); + } + + function testUnwrapRawCallResultCustomError() public { + vm.expectRevert(abi.encodeWithSelector(TestCustomError.selector, uint256(123))); + RawCallHelpers.unwrapRawCallResult( + false, + bytes(abi.encodeWithSelector(TestCustomError.selector, uint256(123))) + ); + } + + function testUnwrapRawCallResultOk() public { + bytes memory encodedError = abi.encodeWithSelector( + RawCallHelpers.Result.selector, + abi.encode(uint256(987), true) + ); + bytes memory result = RawCallHelpers.unwrapRawCallResult(false, encodedError); + (uint256 decodedResultInt, bool decodedResultBool) = abi.decode(result, (uint256, bool)); + + assertEq(decodedResultInt, uint256(987), "Wrong decoded result (int)"); + assertEq(decodedResultBool, true, "Wrong decoded result (bool)"); + } + + function testParseSelectorNoData() public { + vm.expectRevert(abi.encodeWithSelector(RawCallHelpers.ErrorSelectorNotFound.selector)); + RawCallHelpers.parseSelector(""); + } + + function testParseSelector() public { + bytes4 selector = RawCallHelpers.parseSelector(abi.encodePacked(hex"112233445566778899aabbccddeeff")); + assertEq(selector, bytes4(0x11223344), "Incorrect selector"); + } +} diff --git a/pkg/vault/.forge-snapshots/vaultSwapSingleTokenExactInWithProtocolFee.snap b/pkg/vault/.forge-snapshots/vaultSwapSingleTokenExactInWithProtocolFee.snap index 947ca7abf..0c3aa31cd 100644 --- a/pkg/vault/.forge-snapshots/vaultSwapSingleTokenExactInWithProtocolFee.snap +++ b/pkg/vault/.forge-snapshots/vaultSwapSingleTokenExactInWithProtocolFee.snap @@ -1 +1 @@ -306375 \ No newline at end of file +306398 \ No newline at end of file diff --git a/pkg/vault/contracts/VaultExtension.sol b/pkg/vault/contracts/VaultExtension.sol index 3b910e8c7..c580a09a5 100644 --- a/pkg/vault/contracts/VaultExtension.sol +++ b/pkg/vault/contracts/VaultExtension.sol @@ -19,6 +19,7 @@ import { IVaultAdmin } from "@balancer-labs/v3-interfaces/contracts/vault/IVault import { IVaultExtension } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExtension.sol"; import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/ArrayHelpers.sol"; import { InputHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/InputHelpers.sol"; +import { RawCallHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/RawCallHelpers.sol"; import { ScalingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/ScalingHelpers.sol"; import { EVMCallModeHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/EVMCallModeHelpers.sol"; import { EnumerableMap } from "@balancer-labs/v3-solidity-utils/contracts/openzeppelin/EnumerableMap.sol"; @@ -592,6 +593,27 @@ contract VaultExtension is IVaultExtension, VaultCommon, Proxy { return (msg.sender).functionCallWithValue(data, msg.value); } + /// @inheritdoc IVaultExtension + function quoteAndRevert(bytes calldata data) external payable query onlyVault { + // Forward the incoming call to the original sender of this transaction. + (bool success, bytes memory result) = (msg.sender).call{ value: msg.value }(data); + if (success) { + // This will only revert if result is empty and sender account has no code. + Address.verifyCallResultFromTarget(msg.sender, success, result); + // Send result in revert reason. + revert RawCallHelpers.Result(result); + } else { + // If the call reverted with a spoofed `QuoteResult`, we catch it and bubble up a different reason. + bytes4 errorSelector = RawCallHelpers.parseSelector(result); + if (errorSelector == RawCallHelpers.Result.selector) { + revert QuoteResultSpoofed(); + } + + // Otherwise we bubble up the original revert reason. + RawCallHelpers.bubbleUpRevert(result); + } + } + /// @inheritdoc IVaultExtension function isQueryDisabled() external view onlyVault returns (bool) { return _isQueryDisabled; diff --git a/pkg/vault/contracts/test/RouterMock.sol b/pkg/vault/contracts/test/RouterMock.sol index d1acdebbd..1319cbace 100644 --- a/pkg/vault/contracts/test/RouterMock.sol +++ b/pkg/vault/contracts/test/RouterMock.sol @@ -2,9 +2,14 @@ pragma solidity ^0.8.4; +import { IVaultExtension } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExtension.sol"; +import { RawCallHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/RawCallHelpers.sol"; + import "../Router.sol"; contract RouterMock is Router { + error MockErrorCode(); + constructor(IVault vault, IWETH weth) Router(vault, weth) {} function getSingleInputArrayAndTokenIndex( @@ -14,4 +19,112 @@ contract RouterMock is Router { ) external view returns (uint256[] memory amountsGiven, uint256 tokenIndex) { return _getSingleInputArrayAndTokenIndex(pool, token, amountGiven); } + + function querySwapSingleTokenExactInAndRevert( + address pool, + IERC20 tokenIn, + IERC20 tokenOut, + uint256 exactAmountIn, + bytes calldata userData + ) external returns (uint256 amountCalculated) { + (bool success, bytes memory resultRaw) = address(_vault).call( + abi.encodeWithSelector( + IVaultExtension.quoteAndRevert.selector, + abi.encodeWithSelector( + Router.querySwapHook.selector, + SwapSingleTokenHookParams({ + sender: msg.sender, + kind: SwapKind.EXACT_IN, + pool: pool, + tokenIn: tokenIn, + tokenOut: tokenOut, + amountGiven: exactAmountIn, + limit: 0, + deadline: _MAX_AMOUNT, + wethIsEth: false, + userData: userData + }) + ) + ) + ); + + return abi.decode(RawCallHelpers.unwrapRawCallResult(success, resultRaw), (uint256)); + } + + function querySpoof() external returns (uint256) { + (bool success, bytes memory resultRaw) = address(_vault).call( + abi.encodeWithSelector( + IVaultExtension.quoteAndRevert.selector, + abi.encodeWithSelector(RouterMock.querySpoofHook.selector) + ) + ); + + return abi.decode(RawCallHelpers.unwrapRawCallResult(success, resultRaw), (uint256)); + } + + function querySpoofHook() external pure { + revert RawCallHelpers.Result(abi.encode(uint256(1234))); + } + + function queryRevertErrorCode() external returns (uint256) { + (bool success, bytes memory resultRaw) = address(_vault).call( + abi.encodeWithSelector( + IVaultExtension.quoteAndRevert.selector, + abi.encodeWithSelector(RouterMock.queryRevertErrorCodeHook.selector) + ) + ); + + return abi.decode(RawCallHelpers.unwrapRawCallResult(success, resultRaw), (uint256)); + } + + function queryRevertErrorCodeHook() external pure { + revert MockErrorCode(); + } + + function queryRevertLegacy() external returns (uint256) { + (bool success, bytes memory resultRaw) = address(_vault).call( + abi.encodeWithSelector( + IVaultExtension.quoteAndRevert.selector, + abi.encodeWithSelector(RouterMock.queryRevertLegacyHook.selector) + ) + ); + + return abi.decode(RawCallHelpers.unwrapRawCallResult(success, resultRaw), (uint256)); + } + + function queryRevertLegacyHook() external pure { + revert("Legacy revert reason"); + } + + function queryRevertPanic() external returns (uint256) { + (bool success, bytes memory resultRaw) = address(_vault).call( + abi.encodeWithSelector( + IVaultExtension.quoteAndRevert.selector, + abi.encodeWithSelector(RouterMock.queryRevertPanicHook.selector) + ) + ); + + return abi.decode(RawCallHelpers.unwrapRawCallResult(success, resultRaw), (uint256)); + } + + function queryRevertPanicHook() external pure returns (uint256) { + uint256 a = 10; + uint256 b = 0; + return a / b; + } + + function queryRevertNoReason() external returns (uint256) { + (bool success, bytes memory resultRaw) = address(_vault).call( + abi.encodeWithSelector( + IVaultExtension.quoteAndRevert.selector, + abi.encodeWithSelector(RouterMock.queryRevertNoReasonHook.selector) + ) + ); + + return abi.decode(RawCallHelpers.unwrapRawCallResult(success, resultRaw), (uint256)); + } + + function queryRevertNoReasonHook() external pure returns (uint256) { + revert(); + } } diff --git a/pkg/vault/test/Queries.test.ts b/pkg/vault/test/Queries.test.ts index ecc411678..9573f35c0 100644 --- a/pkg/vault/test/Queries.test.ts +++ b/pkg/vault/test/Queries.test.ts @@ -10,9 +10,10 @@ import { VoidSigner } from 'ethers'; import { sharedBeforeEach } from '@balancer-labs/v3-common/sharedBeforeEach'; import { fp } from '@balancer-labs/v3-helpers/src/numbers'; import * as VaultDeployer from '@balancer-labs/v3-helpers/src/models/vault/VaultDeployer'; -import { Vault } from '@balancer-labs/v3-vault/typechain-types'; +import { RouterMock, Vault } from '@balancer-labs/v3-vault/typechain-types'; import { buildTokenConfig } from './poolSetup'; import { sortAddresses } from '@balancer-labs/v3-helpers/src/models/tokens/sortingHelper'; +import { WETHTestToken } from '@balancer-labs/v3-solidity-utils/typechain-types'; describe('Queries', function () { let vault: Vault; @@ -20,6 +21,7 @@ describe('Queries', function () { let pool: ERC20PoolMock; let DAI: ERC20TestToken; let USDC: ERC20TestToken; + let WETH: WETHTestToken; let zero: VoidSigner; const DAI_AMOUNT_IN = fp(1000); @@ -36,7 +38,7 @@ describe('Queries', function () { sharedBeforeEach('deploy vault, tokens, and pools', async function () { vault = await VaultDeployer.deploy(); const vaultAddress = await vault.getAddress(); - const WETH = await deploy('v3-solidity-utils/WETHTestToken'); + WETH = await deploy('v3-solidity-utils/WETHTestToken'); router = await deploy('Router', { args: [vaultAddress, WETH] }); DAI = await deploy('v3-solidity-utils/ERC20TestToken', { args: ['DAI', 'Token A', 18] }); @@ -207,4 +209,56 @@ describe('Queries', function () { ).to.be.revertedWithCustomError(vault, 'NotStaticCall'); }); }); + + describe('query and revert', () => { + let router: RouterMock; + + sharedBeforeEach('deploy mock router', async () => { + router = await deploy('RouterMock', { args: [await vault.getAddress(), WETH] }); + }); + + describe('swap', () => { + it('queries a swap exact in correctly', async () => { + const amountCalculated = await router + .connect(zero) + .querySwapSingleTokenExactInAndRevert.staticCall(pool, USDC, DAI, USDC_AMOUNT_IN, '0x'); + expect(amountCalculated).to.be.eq(DAI_AMOUNT_IN); + }); + + it('reverts if not a static call (exact in)', async () => { + await expect( + router.querySwapSingleTokenExactInAndRevert.staticCall(pool, USDC, DAI, USDC_AMOUNT_IN, '0x') + ).to.be.revertedWithCustomError(vault, 'NotStaticCall'); + }); + + it('handles query spoofs', async () => { + await expect(router.connect(zero).querySpoof.staticCall()).to.be.revertedWithCustomError( + vault, + 'QuoteResultSpoofed' + ); + }); + + it('handles custom error codes', async () => { + await expect(router.connect(zero).queryRevertErrorCode.staticCall()).to.be.revertedWithCustomError( + router, + 'MockErrorCode' + ); + }); + + it('handles legacy errors', async () => { + await expect(router.connect(zero).queryRevertLegacy.staticCall()).to.be.revertedWith('Legacy revert reason'); + }); + + it('handles revert with no reason', async () => { + await expect(router.connect(zero).queryRevertNoReason.staticCall()).to.be.revertedWithCustomError( + router, + 'ErrorSelectorNotFound' + ); + }); + + it('handles panic', async () => { + await expect(router.connect(zero).queryRevertPanic.staticCall()).to.be.revertedWithPanic(); + }); + }); + }); }); From 7f7a52c1c9fc7a3e79123c4f334670f43fbaf31e Mon Sep 17 00:00:00 2001 From: Juan Ignacio Ubeira Date: Wed, 13 Mar 2024 16:34:30 -0300 Subject: [PATCH 08/18] Remove unnecessary error. --- pkg/interfaces/contracts/vault/IRouter.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/interfaces/contracts/vault/IRouter.sol b/pkg/interfaces/contracts/vault/IRouter.sol index 401b660a0..ffb8a7f1b 100644 --- a/pkg/interfaces/contracts/vault/IRouter.sol +++ b/pkg/interfaces/contracts/vault/IRouter.sol @@ -9,8 +9,6 @@ import { IBasePool } from "./IBasePool.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; interface IRouter { - error FailedQuoteInnerCall(); - /*************************************************************************** Pool Initialization ***************************************************************************/ From 28028423c6cccef7e3e0938f002d9bf02f445caa Mon Sep 17 00:00:00 2001 From: Juan Ignacio Ubeira Date: Wed, 13 Mar 2024 17:18:25 -0300 Subject: [PATCH 09/18] Use try catch instead of raw call. --- .../{RawCallHelpers.sol => RevertCodec.sol} | 12 +-- .../test/foundry/RawCallHelpers.t.sol | 51 ------------ .../test/foundry/RevertCodec.t.sol | 40 +++++++++ pkg/vault/contracts/VaultExtension.sol | 10 +-- pkg/vault/contracts/test/RouterMock.sol | 82 ++++++++----------- 5 files changed, 83 insertions(+), 112 deletions(-) rename pkg/solidity-utils/contracts/helpers/{RawCallHelpers.sol => RevertCodec.sol} (82%) delete mode 100644 pkg/solidity-utils/test/foundry/RawCallHelpers.t.sol create mode 100644 pkg/solidity-utils/test/foundry/RevertCodec.t.sol diff --git a/pkg/solidity-utils/contracts/helpers/RawCallHelpers.sol b/pkg/solidity-utils/contracts/helpers/RevertCodec.sol similarity index 82% rename from pkg/solidity-utils/contracts/helpers/RawCallHelpers.sol rename to pkg/solidity-utils/contracts/helpers/RevertCodec.sol index 28b165cb0..0231bf9a3 100644 --- a/pkg/solidity-utils/contracts/helpers/RawCallHelpers.sol +++ b/pkg/solidity-utils/contracts/helpers/RevertCodec.sol @@ -4,22 +4,18 @@ pragma solidity ^0.8.4; // solhint-disable no-inline-assembly -library RawCallHelpers { +library RevertCodec { error Result(bytes result); error UnexpectedCallSuccess(); error ErrorSelectorNotFound(); - function unwrapRawCallResult(bool success, bytes memory resultRaw) internal pure returns (bytes memory) { - if (success) { - revert UnexpectedCallSuccess(); - } - - bytes4 errorSelector = RawCallHelpers.parseSelector(resultRaw); + function catchEncodedResult(bytes memory resultRaw) internal pure returns (bytes memory) { + bytes4 errorSelector = RevertCodec.parseSelector(resultRaw); if (errorSelector != Result.selector) { // Bubble up error message if the revert reason is not the expected one. - RawCallHelpers.bubbleUpRevert(resultRaw); + RevertCodec.bubbleUpRevert(resultRaw); } uint256 resultRawLength = resultRaw.length; diff --git a/pkg/solidity-utils/test/foundry/RawCallHelpers.t.sol b/pkg/solidity-utils/test/foundry/RawCallHelpers.t.sol deleted file mode 100644 index 51f939edd..000000000 --- a/pkg/solidity-utils/test/foundry/RawCallHelpers.t.sol +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -pragma solidity ^0.8.4; - -import "forge-std/Test.sol"; - -import { RawCallHelpers } from "../../contracts/helpers/RawCallHelpers.sol"; - -contract RawCallHelpersTest is Test { - error TestCustomError(uint256 code); - - function testUnwrapRawCallResultSuccess() public { - vm.expectRevert(abi.encodeWithSelector(RawCallHelpers.UnexpectedCallSuccess.selector)); - RawCallHelpers.unwrapRawCallResult(true, ""); - } - - function testUnwrapRawCallResultNoSelector() public { - vm.expectRevert(abi.encodeWithSelector(RawCallHelpers.ErrorSelectorNotFound.selector)); - RawCallHelpers.unwrapRawCallResult(false, ""); - } - - function testUnwrapRawCallResultCustomError() public { - vm.expectRevert(abi.encodeWithSelector(TestCustomError.selector, uint256(123))); - RawCallHelpers.unwrapRawCallResult( - false, - bytes(abi.encodeWithSelector(TestCustomError.selector, uint256(123))) - ); - } - - function testUnwrapRawCallResultOk() public { - bytes memory encodedError = abi.encodeWithSelector( - RawCallHelpers.Result.selector, - abi.encode(uint256(987), true) - ); - bytes memory result = RawCallHelpers.unwrapRawCallResult(false, encodedError); - (uint256 decodedResultInt, bool decodedResultBool) = abi.decode(result, (uint256, bool)); - - assertEq(decodedResultInt, uint256(987), "Wrong decoded result (int)"); - assertEq(decodedResultBool, true, "Wrong decoded result (bool)"); - } - - function testParseSelectorNoData() public { - vm.expectRevert(abi.encodeWithSelector(RawCallHelpers.ErrorSelectorNotFound.selector)); - RawCallHelpers.parseSelector(""); - } - - function testParseSelector() public { - bytes4 selector = RawCallHelpers.parseSelector(abi.encodePacked(hex"112233445566778899aabbccddeeff")); - assertEq(selector, bytes4(0x11223344), "Incorrect selector"); - } -} diff --git a/pkg/solidity-utils/test/foundry/RevertCodec.t.sol b/pkg/solidity-utils/test/foundry/RevertCodec.t.sol new file mode 100644 index 000000000..766d61ca0 --- /dev/null +++ b/pkg/solidity-utils/test/foundry/RevertCodec.t.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.4; + +import "forge-std/Test.sol"; + +import { RevertCodec } from "../../contracts/helpers/RevertCodec.sol"; + +contract RevertCodecTest is Test { + error TestCustomError(uint256 code); + + function testcatchEncodedResultNoSelector() public { + vm.expectRevert(abi.encodeWithSelector(RevertCodec.ErrorSelectorNotFound.selector)); + RevertCodec.catchEncodedResult(""); + } + + function testcatchEncodedResultCustomError() public { + vm.expectRevert(abi.encodeWithSelector(TestCustomError.selector, uint256(123))); + RevertCodec.catchEncodedResult(bytes(abi.encodeWithSelector(TestCustomError.selector, uint256(123)))); + } + + function testcatchEncodedResultOk() public { + bytes memory encodedError = abi.encodeWithSelector(RevertCodec.Result.selector, abi.encode(uint256(987), true)); + bytes memory result = RevertCodec.catchEncodedResult(encodedError); + (uint256 decodedResultInt, bool decodedResultBool) = abi.decode(result, (uint256, bool)); + + assertEq(decodedResultInt, uint256(987), "Wrong decoded result (int)"); + assertEq(decodedResultBool, true, "Wrong decoded result (bool)"); + } + + function testParseSelectorNoData() public { + vm.expectRevert(abi.encodeWithSelector(RevertCodec.ErrorSelectorNotFound.selector)); + RevertCodec.parseSelector(""); + } + + function testParseSelector() public { + bytes4 selector = RevertCodec.parseSelector(abi.encodePacked(hex"112233445566778899aabbccddeeff")); + assertEq(selector, bytes4(0x11223344), "Incorrect selector"); + } +} diff --git a/pkg/vault/contracts/VaultExtension.sol b/pkg/vault/contracts/VaultExtension.sol index c580a09a5..2502d376b 100644 --- a/pkg/vault/contracts/VaultExtension.sol +++ b/pkg/vault/contracts/VaultExtension.sol @@ -19,7 +19,7 @@ import { IVaultAdmin } from "@balancer-labs/v3-interfaces/contracts/vault/IVault import { IVaultExtension } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExtension.sol"; import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/ArrayHelpers.sol"; import { InputHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/InputHelpers.sol"; -import { RawCallHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/RawCallHelpers.sol"; +import { RevertCodec } from "@balancer-labs/v3-solidity-utils/contracts/helpers/RevertCodec.sol"; import { ScalingHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/ScalingHelpers.sol"; import { EVMCallModeHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/EVMCallModeHelpers.sol"; import { EnumerableMap } from "@balancer-labs/v3-solidity-utils/contracts/openzeppelin/EnumerableMap.sol"; @@ -601,16 +601,16 @@ contract VaultExtension is IVaultExtension, VaultCommon, Proxy { // This will only revert if result is empty and sender account has no code. Address.verifyCallResultFromTarget(msg.sender, success, result); // Send result in revert reason. - revert RawCallHelpers.Result(result); + revert RevertCodec.Result(result); } else { // If the call reverted with a spoofed `QuoteResult`, we catch it and bubble up a different reason. - bytes4 errorSelector = RawCallHelpers.parseSelector(result); - if (errorSelector == RawCallHelpers.Result.selector) { + bytes4 errorSelector = RevertCodec.parseSelector(result); + if (errorSelector == RevertCodec.Result.selector) { revert QuoteResultSpoofed(); } // Otherwise we bubble up the original revert reason. - RawCallHelpers.bubbleUpRevert(result); + RevertCodec.bubbleUpRevert(result); } } diff --git a/pkg/vault/contracts/test/RouterMock.sol b/pkg/vault/contracts/test/RouterMock.sol index 1319cbace..4a5742d77 100644 --- a/pkg/vault/contracts/test/RouterMock.sol +++ b/pkg/vault/contracts/test/RouterMock.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.4; import { IVaultExtension } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExtension.sol"; -import { RawCallHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/RawCallHelpers.sol"; +import { RevertCodec } from "@balancer-labs/v3-solidity-utils/contracts/helpers/RevertCodec.sol"; import "../Router.sol"; @@ -27,9 +27,8 @@ contract RouterMock is Router { uint256 exactAmountIn, bytes calldata userData ) external returns (uint256 amountCalculated) { - (bool success, bytes memory resultRaw) = address(_vault).call( - abi.encodeWithSelector( - IVaultExtension.quoteAndRevert.selector, + try + _vault.quoteAndRevert( abi.encodeWithSelector( Router.querySwapHook.selector, SwapSingleTokenHookParams({ @@ -46,35 +45,31 @@ contract RouterMock is Router { }) ) ) - ); - - return abi.decode(RawCallHelpers.unwrapRawCallResult(success, resultRaw), (uint256)); + { + revert("Unexpected success"); + } catch (bytes memory result) { + return abi.decode(RevertCodec.catchEncodedResult(result), (uint256)); + } } function querySpoof() external returns (uint256) { - (bool success, bytes memory resultRaw) = address(_vault).call( - abi.encodeWithSelector( - IVaultExtension.quoteAndRevert.selector, - abi.encodeWithSelector(RouterMock.querySpoofHook.selector) - ) - ); - - return abi.decode(RawCallHelpers.unwrapRawCallResult(success, resultRaw), (uint256)); + try _vault.quoteAndRevert(abi.encodeWithSelector(RouterMock.querySpoofHook.selector)) { + revert("Unexpected success"); + } catch (bytes memory result) { + return abi.decode(RevertCodec.catchEncodedResult(result), (uint256)); + } } function querySpoofHook() external pure { - revert RawCallHelpers.Result(abi.encode(uint256(1234))); + revert RevertCodec.Result(abi.encode(uint256(1234))); } function queryRevertErrorCode() external returns (uint256) { - (bool success, bytes memory resultRaw) = address(_vault).call( - abi.encodeWithSelector( - IVaultExtension.quoteAndRevert.selector, - abi.encodeWithSelector(RouterMock.queryRevertErrorCodeHook.selector) - ) - ); - - return abi.decode(RawCallHelpers.unwrapRawCallResult(success, resultRaw), (uint256)); + try _vault.quoteAndRevert(abi.encodeWithSelector(RouterMock.queryRevertErrorCodeHook.selector)) { + revert("Unexpected success"); + } catch (bytes memory result) { + return abi.decode(RevertCodec.catchEncodedResult(result), (uint256)); + } } function queryRevertErrorCodeHook() external pure { @@ -82,14 +77,11 @@ contract RouterMock is Router { } function queryRevertLegacy() external returns (uint256) { - (bool success, bytes memory resultRaw) = address(_vault).call( - abi.encodeWithSelector( - IVaultExtension.quoteAndRevert.selector, - abi.encodeWithSelector(RouterMock.queryRevertLegacyHook.selector) - ) - ); - - return abi.decode(RawCallHelpers.unwrapRawCallResult(success, resultRaw), (uint256)); + try _vault.quoteAndRevert(abi.encodeWithSelector(RouterMock.queryRevertLegacyHook.selector)) { + revert("Unexpected success"); + } catch (bytes memory result) { + return abi.decode(RevertCodec.catchEncodedResult(result), (uint256)); + } } function queryRevertLegacyHook() external pure { @@ -97,14 +89,11 @@ contract RouterMock is Router { } function queryRevertPanic() external returns (uint256) { - (bool success, bytes memory resultRaw) = address(_vault).call( - abi.encodeWithSelector( - IVaultExtension.quoteAndRevert.selector, - abi.encodeWithSelector(RouterMock.queryRevertPanicHook.selector) - ) - ); - - return abi.decode(RawCallHelpers.unwrapRawCallResult(success, resultRaw), (uint256)); + try _vault.quoteAndRevert(abi.encodeWithSelector(RouterMock.queryRevertPanicHook.selector)) { + revert("Unexpected success"); + } catch (bytes memory result) { + return abi.decode(RevertCodec.catchEncodedResult(result), (uint256)); + } } function queryRevertPanicHook() external pure returns (uint256) { @@ -114,14 +103,11 @@ contract RouterMock is Router { } function queryRevertNoReason() external returns (uint256) { - (bool success, bytes memory resultRaw) = address(_vault).call( - abi.encodeWithSelector( - IVaultExtension.quoteAndRevert.selector, - abi.encodeWithSelector(RouterMock.queryRevertNoReasonHook.selector) - ) - ); - - return abi.decode(RawCallHelpers.unwrapRawCallResult(success, resultRaw), (uint256)); + try _vault.quoteAndRevert(abi.encodeWithSelector(RouterMock.queryRevertNoReasonHook.selector)) { + revert("Unexpected success"); + } catch (bytes memory result) { + return abi.decode(RevertCodec.catchEncodedResult(result), (uint256)); + } } function queryRevertNoReasonHook() external pure returns (uint256) { From 558820b184d25f7ae5b6854503b3ba316e9b8837 Mon Sep 17 00:00:00 2001 From: Bruno Eidam Guerios Date: Tue, 19 Mar 2024 16:22:28 -0300 Subject: [PATCH 10/18] Update to use queryAndRevert --- .vscode/settings.json | 3 - .../contracts/PriceImpact.sol | 144 ++++++++++-------- .../test/foundry/PriceImpact.t.sol | 60 ++++++-- 3 files changed, 129 insertions(+), 78 deletions(-) delete mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 65a196532..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "editor.defaultFormatter": "esbenp.prettier-vscode" -} diff --git a/pkg/standalone-utils/contracts/PriceImpact.sol b/pkg/standalone-utils/contracts/PriceImpact.sol index 2f984bab1..d4a6e2e4e 100644 --- a/pkg/standalone-utils/contracts/PriceImpact.sol +++ b/pkg/standalone-utils/contracts/PriceImpact.sol @@ -7,6 +7,7 @@ import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { Address } from "@openzeppelin/contracts/utils/Address.sol"; import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { IVaultExtension } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExtension.sol"; import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; import { IBasePool } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePool.sol"; import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; @@ -15,11 +16,13 @@ import "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; import { EnumerableSet } from "@balancer-labs/v3-solidity-utils/contracts/openzeppelin/EnumerableSet.sol"; +import { RevertCodec } from "@balancer-labs/v3-solidity-utils/contracts/helpers/RevertCodec.sol"; contract PriceImpact is ReentrancyGuard { - using FixedPoint for uint256; + error SwapDeadline(); + IVault private immutable _vault; modifier onlyVault() { @@ -53,12 +56,12 @@ contract PriceImpact is ReentrancyGuard { // get deltas between exactAmountsIn and proportionalAmountsOut int256[] memory deltas = new int256[](exactAmountsIn.length); for (uint256 i = 0; i < exactAmountsIn.length; i++) { - deltas[i] = int(proportionalAmountsOut[i] - exactAmountsIn[i]); + deltas[i] = int(proportionalAmountsOut[i]) - int(exactAmountsIn[i]); } // query add liquidity for each delta, so we know how unbalanced each amount in is in terms of BPT int256[] memory deltaBPTs = new int256[](exactAmountsIn.length); for (uint256 i = 0; i < exactAmountsIn.length; i++) { - deltaBPTs[i] = _queryAddLiquidityForTokenDelta(pool, i, deltas, deltaBPTs); + deltaBPTs[i] = _queryAddLiquidityForTokenDelta(pool, i, deltas); } // zero out deltas leaving only a remaining delta within a single token uint256 remaininDeltaIndex = _zeroOutDeltas(pool, deltas, deltaBPTs); @@ -74,13 +77,12 @@ contract PriceImpact is ReentrancyGuard { function _queryAddLiquidityForTokenDelta( address pool, uint256 tokenIndex, - int256[] memory deltas, - int256[] memory deltaBPTs + int256[] memory deltas ) internal returns (int256 deltaBPT) { uint256[] memory zerosWithSingleDelta = new uint256[](deltas.length); - if (deltaBPTs[tokenIndex] == 0) { + if (deltas[tokenIndex] == 0) { return 0; - } else if (deltaBPTs[tokenIndex] > 0) { + } else if (deltas[tokenIndex] > 0) { zerosWithSingleDelta[tokenIndex] = uint(deltas[tokenIndex]); return int(_queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, "")); } else { @@ -89,7 +91,11 @@ contract PriceImpact is ReentrancyGuard { } } - function _zeroOutDeltas(address pool, int256[] memory deltas, int256[] memory deltaBPTs) internal returns (uint256) { + function _zeroOutDeltas( + address pool, + int256[] memory deltas, + int256[] memory deltaBPTs + ) internal returns (uint256) { uint256 minNegativeDeltaIndex = 0; IERC20[] memory poolTokens = _vault.getPoolTokens(pool); @@ -128,7 +134,7 @@ contract PriceImpact is ReentrancyGuard { deltas[givenTokenIndex] = 0; deltaBPTs[givenTokenIndex] = 0; deltas[resultTokenIndex] += int(resultAmount); - deltaBPTs[resultTokenIndex] = _queryAddLiquidityForTokenDelta(pool, resultTokenIndex, deltas, deltaBPTs); + deltaBPTs[resultTokenIndex] = _queryAddLiquidityForTokenDelta(pool, resultTokenIndex, deltas); } return minNegativeDeltaIndex; @@ -166,7 +172,7 @@ contract PriceImpact is ReentrancyGuard { // The deadline is timestamp-based: it should not be relied upon for sub-minute accuracy. // solhint-disable-next-line not-rely-on-time if (block.timestamp > params.deadline) { - revert IRouter.SwapDeadline(); + revert SwapDeadline(); } (amountCalculated, amountIn, amountOut) = _vault.swap( @@ -203,27 +209,30 @@ contract PriceImpact is ReentrancyGuard { uint256 exactAmountIn, bytes memory userData ) internal returns (uint256 amountCalculated) { - return - abi.decode( - _vault.quote( - abi.encodeWithSelector( - PriceImpact.querySwapHook.selector, - IRouter.SwapSingleTokenHookParams({ - sender: msg.sender, - kind: SwapKind.EXACT_IN, - pool: pool, - tokenIn: tokenIn, - tokenOut: tokenOut, - amountGiven: exactAmountIn, - limit: 0, - deadline: type(uint256).max, - wethIsEth: false, - userData: userData - }) - ) - ), - (uint256) - ); + try + _vault.quoteAndRevert( + abi.encodeWithSelector( + PriceImpact.querySwapHook.selector, + IRouter.SwapSingleTokenHookParams({ + sender: msg.sender, + kind: SwapKind.EXACT_IN, + pool: pool, + tokenIn: tokenIn, + tokenOut: tokenOut, + amountGiven: exactAmountIn, + limit: 0, + deadline: type(uint256).max, + wethIsEth: false, + userData: userData + }) + ) + ) + { + revert("Unexpected success"); + } catch (bytes memory result) { + amountCalculated = abi.decode(RevertCodec.catchEncodedResult(result), (uint256)); + return amountCalculated; + } } function querySwapSingleTokenExactOut( @@ -243,27 +252,30 @@ contract PriceImpact is ReentrancyGuard { uint256 exactAmountOut, bytes memory userData ) internal returns (uint256 amountCalculated) { - return - abi.decode( - _vault.quote( - abi.encodeWithSelector( - PriceImpact.querySwapHook.selector, - IRouter.SwapSingleTokenHookParams({ - sender: msg.sender, - kind: SwapKind.EXACT_OUT, - pool: pool, - tokenIn: tokenIn, - tokenOut: tokenOut, - amountGiven: exactAmountOut, - limit: type(uint256).max, - deadline: type(uint256).max, - wethIsEth: false, - userData: userData - }) - ) - ), - (uint256) - ); + try + _vault.quoteAndRevert( + abi.encodeWithSelector( + PriceImpact.querySwapHook.selector, + IRouter.SwapSingleTokenHookParams({ + sender: msg.sender, + kind: SwapKind.EXACT_OUT, + pool: pool, + tokenIn: tokenIn, + tokenOut: tokenOut, + amountGiven: exactAmountOut, + limit: type(uint256).max, + deadline: type(uint256).max, + wethIsEth: false, + userData: userData + }) + ) + ) + { + revert("Unexpected success"); + } catch (bytes memory result) { + amountCalculated = abi.decode(RevertCodec.catchEncodedResult(result), (uint256)); + return amountCalculated; + } } /** @@ -295,8 +307,8 @@ contract PriceImpact is ReentrancyGuard { uint256 minBptAmountOut, bytes memory userData ) internal returns (uint256 bptAmountOut) { - (, bptAmountOut, ) = abi.decode( - _vault.quote( + try + _vault.quoteAndRevert( abi.encodeWithSelector( PriceImpact.queryAddLiquidityHook.selector, IRouter.AddLiquidityHookParams({ @@ -311,9 +323,13 @@ contract PriceImpact is ReentrancyGuard { userData: userData }) ) - ), - (uint256[], uint256, bytes) - ); + ) + { + revert("Unexpected success"); + } catch (bytes memory result) { + (, bptAmountOut, ) = abi.decode(RevertCodec.catchEncodedResult(result), (uint256[], uint256, bytes)); + return bptAmountOut; + } } /** @@ -360,8 +376,8 @@ contract PriceImpact is ReentrancyGuard { uint256[] memory minAmountsOut, bytes memory userData ) internal returns (uint256[] memory amountsOut) { - (, amountsOut, ) = abi.decode( - _vault.quote( + try + _vault.quoteAndRevert( abi.encodeWithSelector( PriceImpact.queryRemoveLiquidityHook.selector, IRouter.RemoveLiquidityHookParams({ @@ -376,9 +392,13 @@ contract PriceImpact is ReentrancyGuard { userData: userData }) ) - ), - (uint256, uint256[], bytes) - ); + ) + { + revert("Unexpected success"); + } catch (bytes memory result) { + (, amountsOut, ) = abi.decode(RevertCodec.catchEncodedResult(result), (uint256, uint256[], bytes)); + return amountsOut; + } } /** diff --git a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol index 198d9dfba..650f71770 100644 --- a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol +++ b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol @@ -13,36 +13,38 @@ import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol" import { IVaultExtension } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExtension.sol"; import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; import { IERC20MultiToken } from "@balancer-labs/v3-interfaces/contracts/vault/IERC20MultiToken.sol"; +import { TokenConfig } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; import { IAuthentication } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/helpers/IAuthentication.sol"; +import { WeightedPool } from "@balancer-labs/v3-pool-weighted/contracts/WeightedPool.sol"; +import { WeightedPoolFactory } from "@balancer-labs/v3-pool-weighted/contracts/WeightedPoolFactory.sol"; + import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/ArrayHelpers.sol"; import { ERC20TestToken } from "@balancer-labs/v3-solidity-utils/contracts/test/ERC20TestToken.sol"; import { WETHTestToken } from "@balancer-labs/v3-solidity-utils/contracts/test/WETHTestToken.sol"; import { BasicAuthorizerMock } from "@balancer-labs/v3-solidity-utils/contracts/test/BasicAuthorizerMock.sol"; import { EVMCallModeHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/EVMCallModeHelpers.sol"; +import { InputHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/InputHelpers.sol"; import { PoolMock } from "@balancer-labs/v3-vault/contracts/test/PoolMock.sol"; import { Router } from "@balancer-labs/v3-vault/contracts/Router.sol"; import { VaultMock } from "@balancer-labs/v3-vault/contracts/test/VaultMock.sol"; import { VaultExtensionMock } from "@balancer-labs/v3-vault/contracts/test/VaultExtensionMock.sol"; - -import { PriceImpact } from "../../contracts/PriceImpact.sol"; - +import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; import { VaultMockDeployer } from "@balancer-labs/v3-vault/test/foundry/utils/VaultMockDeployer.sol"; -import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; +import { PriceImpact } from "../../contracts/PriceImpact.sol"; contract PriceImpactTest is BaseVaultTest { using ArrayHelpers for *; - uint256 internal usdcAmountIn = 1e3 * 1e6; - uint256 internal daiAmountIn = 1e3 * 1e18; - uint256 internal daiAmountOut = 1e2 * 1e18; - uint256 internal ethAmountIn = 1e3 ether; - uint256 internal initBpt = 10e18; - uint256 internal bptAmountOut = 1e18; + uint256 constant USDC_AMOUNT = 1e4 * 1e18; + uint256 constant DAI_AMOUNT = 1e4 * 1e18; + + uint256 constant DELTA = 1e9; - PoolMock internal wethPool; + WeightedPoolFactory factory; + WeightedPool internal weightedPool; PriceImpact internal priceImpactHelper; @@ -51,12 +53,44 @@ contract PriceImpactTest is BaseVaultTest { priceImpactHelper = new PriceImpact(IVault(address(vault))); } + function createPool() internal override returns (address) { + factory = new WeightedPoolFactory(IVault(address(vault)), 365 days); + TokenConfig[] memory tokens = new TokenConfig[](2); + tokens[0].token = IERC20(dai); + tokens[1].token = IERC20(usdc); + + weightedPool = WeightedPool( + factory.create( + "ERC20 Pool", + "ERC20POOL", + vault.sortTokenConfig(tokens), + [uint256(0.50e18), uint256(0.50e18)].toMemoryArray(), + ZERO_BYTES32 + ) + ); + return address(weightedPool); + } + + function initPool() internal override { + uint256[] memory amountsIn = [uint256(DAI_AMOUNT), uint256(USDC_AMOUNT)].toMemoryArray(); + vm.prank(lp); + router.initialize( + pool, + InputHelpers.sortTokens([address(dai), address(usdc)].toMemoryArray().asIERC20()), + amountsIn, + // Account for the precision loss + DAI_AMOUNT - DELTA - 1e6, + false, + bytes("") + ); + } + function testPriceImpact() public { vm.prank(address(0), address(0)); uint256 priceImpact = priceImpactHelper.priceImpactForAddLiquidityUnbalanced( pool, - [poolInitAmount, 0].toMemoryArray() + [DAI_AMOUNT / 4, 0].toMemoryArray() ); - assertEq(priceImpact, 123, "Incorrect price impact for add liquidity unbalanced"); + assertEq(priceImpact, 52786404500042074, "Incorrect price impact for add liquidity unbalanced"); } } From 61c20b6d2abe4b473040075f312cd3c2dbe84b11 Mon Sep 17 00:00:00 2001 From: Bruno Eidam Guerios Date: Wed, 20 Mar 2024 14:04:42 -0300 Subject: [PATCH 11/18] Fix lint --- pkg/standalone-utils/contracts/PriceImpact.sol | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/standalone-utils/contracts/PriceImpact.sol b/pkg/standalone-utils/contracts/PriceImpact.sol index d4a6e2e4e..01411fb11 100644 --- a/pkg/standalone-utils/contracts/PriceImpact.sol +++ b/pkg/standalone-utils/contracts/PriceImpact.sol @@ -66,7 +66,8 @@ contract PriceImpact is ReentrancyGuard { // zero out deltas leaving only a remaining delta within a single token uint256 remaininDeltaIndex = _zeroOutDeltas(pool, deltas, deltaBPTs); // calculate price impact ABA with remaining delta and its respective exactAmountIn - uint256 delta = uint(deltas[remaininDeltaIndex] * -1); // remaining delta is always negative, so by multiplying by -1 we get a positive number + // remaining delta is always negative, so by multiplying by -1 we get a positive number + uint256 delta = uint(deltas[remaininDeltaIndex] * -1); return delta.divDown(exactAmountsIn[remaininDeltaIndex]) / 2; } From af7e093ba968424a60c6a4e15cf4378b1b170b95 Mon Sep 17 00:00:00 2001 From: Bruno Eidam Guerios Date: Fri, 22 Mar 2024 17:09:17 -0300 Subject: [PATCH 12/18] Improve tests --- .../test/foundry/PriceImpact.t.sol | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol index 650f71770..c654c5856 100644 --- a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol +++ b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol @@ -25,6 +25,7 @@ import { WETHTestToken } from "@balancer-labs/v3-solidity-utils/contracts/test/W import { BasicAuthorizerMock } from "@balancer-labs/v3-solidity-utils/contracts/test/BasicAuthorizerMock.sol"; import { EVMCallModeHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/EVMCallModeHelpers.sol"; import { InputHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/InputHelpers.sol"; +import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; import { PoolMock } from "@balancer-labs/v3-vault/contracts/test/PoolMock.sol"; import { Router } from "@balancer-labs/v3-vault/contracts/Router.sol"; @@ -37,6 +38,7 @@ import { PriceImpact } from "../../contracts/PriceImpact.sol"; contract PriceImpactTest is BaseVaultTest { using ArrayHelpers for *; + using FixedPoint for uint256; uint256 constant USDC_AMOUNT = 1e4 * 1e18; uint256 constant DAI_AMOUNT = 1e4 * 1e18; @@ -86,11 +88,34 @@ contract PriceImpactTest is BaseVaultTest { } function testPriceImpact() public { - vm.prank(address(0), address(0)); - uint256 priceImpact = priceImpactHelper.priceImpactForAddLiquidityUnbalanced( + vm.startPrank(address(0), address(0)); + + // calculate spotPrice + uint256 infinitesimalAmountIn = 1e5; + uint256 infinitesimalBptOut = priceImpactHelper.queryAddLiquidityUnbalanced( pool, - [DAI_AMOUNT / 4, 0].toMemoryArray() + [infinitesimalAmountIn, 0].toMemoryArray(), + 0, + bytes("") ); - assertEq(priceImpact, 52786404500042074, "Incorrect price impact for add liquidity unbalanced"); + uint256 spotPrice = infinitesimalAmountIn.divDown(infinitesimalBptOut); + + // calculate priceImpact + uint256 amountIn = DAI_AMOUNT / 4; + uint256[] memory amountsIn = [amountIn, 0].toMemoryArray(); + uint256 priceImpact = priceImpactHelper.priceImpactForAddLiquidityUnbalanced(pool, amountsIn); + + // calculate effectivePrice + uint256 bptOut = priceImpactHelper.queryAddLiquidityUnbalanced(pool, amountsIn, 0, bytes("")); + uint256 effectivePrice = amountIn.divDown(bptOut); + + // calculate expectedPriceImpact for comparison + uint256 expectedPriceImpact = effectivePrice.divDown(spotPrice) - 1e18; + + vm.stopPrank(); + + // assert within acceptable bounds of +-1% + assertLe(priceImpact, expectedPriceImpact + 0.01e18, "Price impact greater than expected"); + assertGe(priceImpact, expectedPriceImpact - 0.01e18, "Price impact smaller than expected"); } } From 4e796a01cfde702b09c17c65a078e4be8f8b33d6 Mon Sep 17 00:00:00 2001 From: Jeff Bennett Date: Mon, 9 Dec 2024 08:42:25 -0500 Subject: [PATCH 13/18] refactor: update to new signatures --- pkg/standalone-utils/contracts/PriceImpact.sol | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/standalone-utils/contracts/PriceImpact.sol b/pkg/standalone-utils/contracts/PriceImpact.sol index 01411fb11..548dfd89a 100644 --- a/pkg/standalone-utils/contracts/PriceImpact.sol +++ b/pkg/standalone-utils/contracts/PriceImpact.sol @@ -8,6 +8,7 @@ import { Address } from "@openzeppelin/contracts/utils/Address.sol"; import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; import { IVaultExtension } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExtension.sol"; +import { IRouterCommon } from "@balancer-labs/v3-interfaces/contracts/vault/IRouterCommon.sol"; import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; import { IBasePool } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePool.sol"; import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; @@ -177,7 +178,7 @@ contract PriceImpact is ReentrancyGuard { } (amountCalculated, amountIn, amountOut) = _vault.swap( - SwapParams({ + VaultSwapParams({ kind: params.kind, pool: params.pool, tokenIn: params.tokenIn, @@ -312,7 +313,7 @@ contract PriceImpact is ReentrancyGuard { _vault.quoteAndRevert( abi.encodeWithSelector( PriceImpact.queryAddLiquidityHook.selector, - IRouter.AddLiquidityHookParams({ + IRouterCommon.AddLiquidityHookParams({ // we use router as a sender to simplify basic query functions // but it is possible to add liquidity to any recipient sender: address(this), @@ -342,7 +343,7 @@ contract PriceImpact is ReentrancyGuard { * @return returnData Arbitrary (optional) data with encoded response from the pool */ function queryAddLiquidityHook( - IRouter.AddLiquidityHookParams calldata params + IRouterCommon.AddLiquidityHookParams calldata params ) external payable @@ -381,7 +382,7 @@ contract PriceImpact is ReentrancyGuard { _vault.quoteAndRevert( abi.encodeWithSelector( PriceImpact.queryRemoveLiquidityHook.selector, - IRouter.RemoveLiquidityHookParams({ + IRouterCommon.RemoveLiquidityHookParams({ // We use router as a sender to simplify basic query functions // but it is possible to remove liquidity from any sender sender: address(this), @@ -411,7 +412,7 @@ contract PriceImpact is ReentrancyGuard { * @return returnData Arbitrary (optional) data with encoded response from the pool */ function queryRemoveLiquidityHook( - IRouter.RemoveLiquidityHookParams calldata params + IRouterCommon.RemoveLiquidityHookParams calldata params ) external nonReentrant From ab5c279d0847e725530dcaa311f561ebf1e640df Mon Sep 17 00:00:00 2001 From: elshan_eth Date: Mon, 9 Dec 2024 18:28:57 +0100 Subject: [PATCH 14/18] fix repo configs --- pkg/standalone-utils/coverage.sh | 87 +------------------------------ pkg/standalone-utils/foundry.toml | 48 ++++++++++++++++- 2 files changed, 48 insertions(+), 87 deletions(-) mode change 100755 => 120000 pkg/standalone-utils/coverage.sh mode change 120000 => 100755 pkg/standalone-utils/foundry.toml diff --git a/pkg/standalone-utils/coverage.sh b/pkg/standalone-utils/coverage.sh deleted file mode 100755 index 252849717..000000000 --- a/pkg/standalone-utils/coverage.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/bin/bash - -set -e # exit on error - -# generates lcov.info -forge coverage --report lcov - -# Initialize variables -current_file="" -lines_found=0 -lines_hit=0 - -# Clear files_with_lines_coverage.txt before usage -> files_with_lines_coverage.txt - -# Process each line of the LCOV report -while IFS= read -r line -do - if [[ $line == LF:* ]]; then - # Get the line count - lines_found=${line#LF:} - elif [[ $line == LH:* ]]; then - # Get the line hit count - lines_hit=${line#LH:} - - # Check if lines_found is equal to lines_hit - if [[ $lines_found -eq $lines_hit ]]; then - # Remember the current file as having 100% coverage - echo "$current_file" >> files_with_lines_coverage.txt - fi - elif [[ $line == SF:* ]]; then - # If the line contains "SF:", it's the start of a new file. Save the filename. - current_file=${line#SF:} - fi -done < lcov.info - -# Create a space-separated string of all file patterns -patterns=$(cat files_with_lines_coverage.txt | tr '\n' ' ') - -# Now use single lcov --extract command with all file patterns -lcov --extract lcov.info $patterns --output-file lcov.info - -# generates coverage/lcov.info -yarn hardhat coverage - -# Foundry uses relative paths but Hardhat uses absolute paths. -# Convert absolute paths to relative paths for consistency. -sed -i -e "s/\/.*$(basename "$PWD").//g" coverage/lcov.info - -# Now use single lcov --remove command with all file patterns -lcov --remove coverage/lcov.info $patterns --output-file coverage/lcov.info - -# Merge lcov files -lcov \ - --rc lcov_branch_coverage=1 \ - --add-tracefile coverage/lcov.info \ - --add-tracefile lcov.info \ - --output-file merged-lcov.info \ - --no-checksum - -# Filter out node_modules, test, and mock files -lcov \ - --rc lcov_branch_coverage=1 \ - --remove merged-lcov.info \ - "*node_modules*" "*test*" "*mock*" \ - --output-file coverage/filtered-lcov.info - -# Generate summary -lcov \ - --rc lcov_branch_coverage=1 \ - --list coverage/filtered-lcov.info - -# Open more granular breakdown in browser -if [ "$HTML" == "true" ] -then - genhtml \ - --rc genhtml_branch_coverage=1 \ - --output-directory coverage \ - coverage/filtered-lcov.info - open coverage/index.html -fi - -# Delete temp files -rm lcov.info merged-lcov.info files_with_lines_coverage.txt - - diff --git a/pkg/standalone-utils/coverage.sh b/pkg/standalone-utils/coverage.sh new file mode 120000 index 000000000..16d4bc6a8 --- /dev/null +++ b/pkg/standalone-utils/coverage.sh @@ -0,0 +1 @@ +../../coverage.sh \ No newline at end of file diff --git a/pkg/standalone-utils/foundry.toml b/pkg/standalone-utils/foundry.toml deleted file mode 120000 index 2d554be76..000000000 --- a/pkg/standalone-utils/foundry.toml +++ /dev/null @@ -1 +0,0 @@ -../../foundry.toml \ No newline at end of file diff --git a/pkg/standalone-utils/foundry.toml b/pkg/standalone-utils/foundry.toml new file mode 100755 index 000000000..36f3a6f77 --- /dev/null +++ b/pkg/standalone-utils/foundry.toml @@ -0,0 +1,47 @@ +[profile.default] +src = 'contracts' +out = 'forge-artifacts' +libs = ['node_modules'] +test = 'test/foundry' +cache_path = 'forge-cache' +allow_paths = ['../', '../../node_modules/'] +ffi = true +fs_permissions = [{ access = "read-write", path = ".forge-snapshots/"}] +remappings = [ + 'vault/=../vault/', + 'pool-weighted/=../pool-weighted/', + 'solidity-utils/=../solidity-utils/', + 'ds-test/=../../node_modules/forge-std/lib/ds-test/src/', + 'forge-std/=../../node_modules/forge-std/src/', + '@openzeppelin/=../../node_modules/@openzeppelin/', + 'permit2/=../../node_modules/permit2/', + '@balancer-labs/=../../node_modules/@balancer-labs/', + 'forge-gas-snapshot/=../../node_modules/forge-gas-snapshot/src/' +] +optimizer = true +optimizer_runs = 999 +solc_version = '0.8.26' +auto_detect_solc = false +evm_version = 'cancun' +ignored_error_codes = [2394, 5574, 3860] # Transient storage, code size + +[fuzz] +runs = 10000 +max_test_rejects = 60000 + +[profile.forkfuzz.fuzz] +runs = 1000 +max_test_rejects = 60000 + +[profile.coverage.fuzz] +runs = 100 +max_test_rejects = 60000 + +[profile.intense.fuzz] +verbosity = 3 +runs = 100000 +max_test_rejects = 600000 + +[rpc_endpoints] + mainnet = "${MAINNET_RPC_URL}" + sepolia = "${SEPOLIA_RPC_URL}" From 55b596f9dab6b31462fb3ae3a877986747bfbbf8 Mon Sep 17 00:00:00 2001 From: elshan_eth Date: Mon, 9 Dec 2024 18:31:02 +0100 Subject: [PATCH 15/18] fix sol versions --- pkg/standalone-utils/contracts/PriceImpact.sol | 2 +- pkg/standalone-utils/test/foundry/PriceImpact.t.sol | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/standalone-utils/contracts/PriceImpact.sol b/pkg/standalone-utils/contracts/PriceImpact.sol index 548dfd89a..3da53d50b 100644 --- a/pkg/standalone-utils/contracts/PriceImpact.sol +++ b/pkg/standalone-utils/contracts/PriceImpact.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.4; +pragma solidity ^0.8.24; import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; diff --git a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol index c654c5856..3dac237c3 100644 --- a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol +++ b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.8.4; +pragma solidity ^0.8.24; import "forge-std/Test.sol"; import { GasSnapshot } from "forge-gas-snapshot/GasSnapshot.sol"; From 8ce837302625b19ab63bafa943833b57f08c4c1a Mon Sep 17 00:00:00 2001 From: elshan_eth Date: Tue, 10 Dec 2024 18:18:35 +0100 Subject: [PATCH 16/18] refactor --- .../contracts/PriceImpact.sol | 362 +++--------------- pkg/standalone-utils/foundry.toml | 5 +- .../test/foundry/PriceImpact.t.sol | 96 +++-- 3 files changed, 92 insertions(+), 371 deletions(-) diff --git a/pkg/standalone-utils/contracts/PriceImpact.sol b/pkg/standalone-utils/contracts/PriceImpact.sol index 3da53d50b..3138234d6 100644 --- a/pkg/standalone-utils/contracts/PriceImpact.sol +++ b/pkg/standalone-utils/contracts/PriceImpact.sol @@ -2,101 +2,88 @@ pragma solidity ^0.8.24; -import { ReentrancyGuard } from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { Address } from "@openzeppelin/contracts/utils/Address.sol"; - import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; -import { IVaultExtension } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExtension.sol"; -import { IRouterCommon } from "@balancer-labs/v3-interfaces/contracts/vault/IRouterCommon.sol"; -import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; -import { IBasePool } from "@balancer-labs/v3-interfaces/contracts/vault/IBasePool.sol"; import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; -import { IWETH } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/misc/IWETH.sol"; -import "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; - import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; -import { EnumerableSet } from "@balancer-labs/v3-solidity-utils/contracts/openzeppelin/EnumerableSet.sol"; -import { RevertCodec } from "@balancer-labs/v3-solidity-utils/contracts/helpers/RevertCodec.sol"; -contract PriceImpact is ReentrancyGuard { +contract PriceImpact { using FixedPoint for uint256; - error SwapDeadline(); - - IVault private immutable _vault; + IVault internal immutable _vault; + IRouter internal immutable _router; - modifier onlyVault() { - if (msg.sender != address(_vault)) { - revert IVaultErrors.SenderIsNotVault(msg.sender); - } - _; - } - - constructor(IVault vault) { + constructor(IVault vault, IRouter router) { _vault = vault; + _router = router; } /******************************************************************************* Price Impact *******************************************************************************/ - function priceImpactForAddLiquidityUnbalanced( + function calculateAddLiquidityUnbalancedPriceImpact( address pool, - uint256[] memory exactAmountsIn + uint256[] memory exactAmountsIn, + address sender ) external returns (uint256 priceImpact) { - // query addLiquidityUnbalanced - uint256 bptAmountOut = _queryAddLiquidityUnbalanced(pool, exactAmountsIn, 0, ""); - // query removeLiquidityProportional - uint256[] memory proportionalAmountsOut = _queryRemoveLiquidityProportional( + uint256 bptAmountOut = _router.queryAddLiquidityUnbalanced(pool, exactAmountsIn, sender, ""); + uint256[] memory proportionalAmountsOut = _router.queryRemoveLiquidityProportional( pool, bptAmountOut, - new uint256[](exactAmountsIn.length), + sender, "" ); + // get deltas between exactAmountsIn and proportionalAmountsOut int256[] memory deltas = new int256[](exactAmountsIn.length); for (uint256 i = 0; i < exactAmountsIn.length; i++) { deltas[i] = int(proportionalAmountsOut[i]) - int(exactAmountsIn[i]); } + // query add liquidity for each delta, so we know how unbalanced each amount in is in terms of BPT int256[] memory deltaBPTs = new int256[](exactAmountsIn.length); for (uint256 i = 0; i < exactAmountsIn.length; i++) { - deltaBPTs[i] = _queryAddLiquidityForTokenDelta(pool, i, deltas); + deltaBPTs[i] = _queryAddLiquidityUnbalancedForTokenDeltas(pool, i, deltas, sender); } + // zero out deltas leaving only a remaining delta within a single token - uint256 remaininDeltaIndex = _zeroOutDeltas(pool, deltas, deltaBPTs); + uint256 remainingDeltaIndex = _zeroOutDeltas(pool, deltas, deltaBPTs, sender); + // calculate price impact ABA with remaining delta and its respective exactAmountIn // remaining delta is always negative, so by multiplying by -1 we get a positive number - uint256 delta = uint(deltas[remaininDeltaIndex] * -1); - return delta.divDown(exactAmountsIn[remaininDeltaIndex]) / 2; + uint256 delta = uint(-deltas[remainingDeltaIndex]); + return delta.divDown(exactAmountsIn[remainingDeltaIndex]) / 2; } /******************************************************************************* Helpers *******************************************************************************/ - function _queryAddLiquidityForTokenDelta( + function _queryAddLiquidityUnbalancedForTokenDeltas( address pool, uint256 tokenIndex, - int256[] memory deltas + int256[] memory deltas, + address sender ) internal returns (int256 deltaBPT) { uint256[] memory zerosWithSingleDelta = new uint256[](deltas.length); - if (deltas[tokenIndex] == 0) { + int256 delta = deltas[tokenIndex]; + + if (delta == 0) { return 0; - } else if (deltas[tokenIndex] > 0) { - zerosWithSingleDelta[tokenIndex] = uint(deltas[tokenIndex]); - return int(_queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, "")); - } else { - zerosWithSingleDelta[tokenIndex] = uint(deltas[tokenIndex] * -1); - return int(_queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, 0, "")) * -1; } + + zerosWithSingleDelta[tokenIndex] = uint256(delta > 0 ? delta : -delta); + int256 result = int256(_router.queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, sender, "")); + + return delta > 0 ? result : -result; } function _zeroOutDeltas( address pool, int256[] memory deltas, - int256[] memory deltaBPTs + int256[] memory deltaBPTs, + address sender ) internal returns (uint256) { uint256 minNegativeDeltaIndex = 0; IERC20[] memory poolTokens = _vault.getPoolTokens(pool); @@ -110,24 +97,26 @@ contract PriceImpact is ReentrancyGuard { uint256 resultTokenIndex; uint256 resultAmount; - if (deltaBPTs[minPositiveDeltaIndex] < deltaBPTs[minNegativeDeltaIndex] * -1) { + if (deltaBPTs[minPositiveDeltaIndex] < -deltaBPTs[minNegativeDeltaIndex]) { givenTokenIndex = minPositiveDeltaIndex; resultTokenIndex = minNegativeDeltaIndex; - resultAmount = _querySwapSingleTokenExactIn( + resultAmount = _router.querySwapSingleTokenExactIn( pool, poolTokens[givenTokenIndex], poolTokens[resultTokenIndex], uint(deltas[givenTokenIndex]), + sender, "" ); } else { givenTokenIndex = minNegativeDeltaIndex; resultTokenIndex = minPositiveDeltaIndex; - resultAmount = _querySwapSingleTokenExactOut( + resultAmount = _router.querySwapSingleTokenExactOut( pool, poolTokens[resultTokenIndex], poolTokens[givenTokenIndex], - uint(deltas[givenTokenIndex] * -1), + uint(-deltas[givenTokenIndex]), + sender, "" ); } @@ -136,7 +125,12 @@ contract PriceImpact is ReentrancyGuard { deltas[givenTokenIndex] = 0; deltaBPTs[givenTokenIndex] = 0; deltas[resultTokenIndex] += int(resultAmount); - deltaBPTs[resultTokenIndex] = _queryAddLiquidityForTokenDelta(pool, resultTokenIndex, deltas); + deltaBPTs[resultTokenIndex] = _queryAddLiquidityUnbalancedForTokenDeltas( + pool, + resultTokenIndex, + deltas, + sender + ); } return minNegativeDeltaIndex; @@ -163,272 +157,4 @@ contract PriceImpact is ReentrancyGuard { } } } - - /******************************************************************************* - Pools - *******************************************************************************/ - - function _swapHook( - IRouter.SwapSingleTokenHookParams calldata params - ) internal returns (uint256 amountCalculated, uint256 amountIn, uint256 amountOut) { - // The deadline is timestamp-based: it should not be relied upon for sub-minute accuracy. - // solhint-disable-next-line not-rely-on-time - if (block.timestamp > params.deadline) { - revert SwapDeadline(); - } - - (amountCalculated, amountIn, amountOut) = _vault.swap( - VaultSwapParams({ - kind: params.kind, - pool: params.pool, - tokenIn: params.tokenIn, - tokenOut: params.tokenOut, - amountGivenRaw: params.amountGiven, - limitRaw: params.limit, - userData: params.userData - }) - ); - } - - /******************************************************************************* - Queries - *******************************************************************************/ - - function querySwapSingleTokenExactIn( - address pool, - IERC20 tokenIn, - IERC20 tokenOut, - uint256 exactAmountIn, - bytes calldata userData - ) external returns (uint256 amountCalculated) { - return _querySwapSingleTokenExactIn(pool, tokenIn, tokenOut, exactAmountIn, userData); - } - - function _querySwapSingleTokenExactIn( - address pool, - IERC20 tokenIn, - IERC20 tokenOut, - uint256 exactAmountIn, - bytes memory userData - ) internal returns (uint256 amountCalculated) { - try - _vault.quoteAndRevert( - abi.encodeWithSelector( - PriceImpact.querySwapHook.selector, - IRouter.SwapSingleTokenHookParams({ - sender: msg.sender, - kind: SwapKind.EXACT_IN, - pool: pool, - tokenIn: tokenIn, - tokenOut: tokenOut, - amountGiven: exactAmountIn, - limit: 0, - deadline: type(uint256).max, - wethIsEth: false, - userData: userData - }) - ) - ) - { - revert("Unexpected success"); - } catch (bytes memory result) { - amountCalculated = abi.decode(RevertCodec.catchEncodedResult(result), (uint256)); - return amountCalculated; - } - } - - function querySwapSingleTokenExactOut( - address pool, - IERC20 tokenIn, - IERC20 tokenOut, - uint256 exactAmountOut, - bytes calldata userData - ) external returns (uint256 amountCalculated) { - return _querySwapSingleTokenExactOut(pool, tokenIn, tokenOut, exactAmountOut, userData); - } - - function _querySwapSingleTokenExactOut( - address pool, - IERC20 tokenIn, - IERC20 tokenOut, - uint256 exactAmountOut, - bytes memory userData - ) internal returns (uint256 amountCalculated) { - try - _vault.quoteAndRevert( - abi.encodeWithSelector( - PriceImpact.querySwapHook.selector, - IRouter.SwapSingleTokenHookParams({ - sender: msg.sender, - kind: SwapKind.EXACT_OUT, - pool: pool, - tokenIn: tokenIn, - tokenOut: tokenOut, - amountGiven: exactAmountOut, - limit: type(uint256).max, - deadline: type(uint256).max, - wethIsEth: false, - userData: userData - }) - ) - ) - { - revert("Unexpected success"); - } catch (bytes memory result) { - amountCalculated = abi.decode(RevertCodec.catchEncodedResult(result), (uint256)); - return amountCalculated; - } - } - - /** - * @notice Hook for swap queries. - * @dev Can only be called by the Vault. Also handles native ETH. - * @param params Swap parameters (see IRouter for struct definition) - * @return Token amount calculated by the pool math (e.g., amountOut for a exact in swap) - */ - function querySwapHook( - IRouter.SwapSingleTokenHookParams calldata params - ) external payable nonReentrant onlyVault returns (uint256) { - (uint256 amountCalculated, , ) = _swapHook(params); - - return amountCalculated; - } - - function queryAddLiquidityUnbalanced( - address pool, - uint256[] memory exactAmountsIn, - uint256 minBptAmountOut, - bytes memory userData - ) external returns (uint256 bptAmountOut) { - return _queryAddLiquidityUnbalanced(pool, exactAmountsIn, minBptAmountOut, userData); - } - - function _queryAddLiquidityUnbalanced( - address pool, - uint256[] memory exactAmountsIn, - uint256 minBptAmountOut, - bytes memory userData - ) internal returns (uint256 bptAmountOut) { - try - _vault.quoteAndRevert( - abi.encodeWithSelector( - PriceImpact.queryAddLiquidityHook.selector, - IRouterCommon.AddLiquidityHookParams({ - // we use router as a sender to simplify basic query functions - // but it is possible to add liquidity to any recipient - sender: address(this), - pool: pool, - maxAmountsIn: exactAmountsIn, - minBptAmountOut: minBptAmountOut, - kind: AddLiquidityKind.UNBALANCED, - wethIsEth: false, - userData: userData - }) - ) - ) - { - revert("Unexpected success"); - } catch (bytes memory result) { - (, bptAmountOut, ) = abi.decode(RevertCodec.catchEncodedResult(result), (uint256[], uint256, bytes)); - return bptAmountOut; - } - } - - /** - * @notice Hook for add liquidity queries. - * @dev Can only be called by the Vault. - * @param params Add liquidity parameters (see IRouter for struct definition) - * @return amountsIn Actual token amounts in required as inputs - * @return bptAmountOut Expected pool tokens to be minted - * @return returnData Arbitrary (optional) data with encoded response from the pool - */ - function queryAddLiquidityHook( - IRouterCommon.AddLiquidityHookParams calldata params - ) - external - payable - nonReentrant - onlyVault - returns (uint256[] memory amountsIn, uint256 bptAmountOut, bytes memory returnData) - { - (amountsIn, bptAmountOut, returnData) = _vault.addLiquidity( - AddLiquidityParams({ - pool: params.pool, - to: params.sender, - maxAmountsIn: params.maxAmountsIn, - minBptAmountOut: params.minBptAmountOut, - kind: params.kind, - userData: params.userData - }) - ); - } - - function queryRemoveLiquidityProportional( - address pool, - uint256 exactBptAmountIn, - uint256[] memory minAmountsOut, - bytes memory userData - ) external returns (uint256[] memory amountsOut) { - return _queryRemoveLiquidityProportional(pool, exactBptAmountIn, minAmountsOut, userData); - } - - function _queryRemoveLiquidityProportional( - address pool, - uint256 exactBptAmountIn, - uint256[] memory minAmountsOut, - bytes memory userData - ) internal returns (uint256[] memory amountsOut) { - try - _vault.quoteAndRevert( - abi.encodeWithSelector( - PriceImpact.queryRemoveLiquidityHook.selector, - IRouterCommon.RemoveLiquidityHookParams({ - // We use router as a sender to simplify basic query functions - // but it is possible to remove liquidity from any sender - sender: address(this), - pool: pool, - minAmountsOut: minAmountsOut, - maxBptAmountIn: exactBptAmountIn, - kind: RemoveLiquidityKind.PROPORTIONAL, - wethIsEth: false, - userData: userData - }) - ) - ) - { - revert("Unexpected success"); - } catch (bytes memory result) { - (, amountsOut, ) = abi.decode(RevertCodec.catchEncodedResult(result), (uint256, uint256[], bytes)); - return amountsOut; - } - } - - /** - * @notice Hook for remove liquidity queries. - * @dev Can only be called by the Vault. - * @param params Remove liquidity parameters (see IRouter for struct definition) - * @return bptAmountIn Pool token amount to be burned for the output tokens - * @return amountsOut Expected token amounts to be transferred to the sender - * @return returnData Arbitrary (optional) data with encoded response from the pool - */ - function queryRemoveLiquidityHook( - IRouterCommon.RemoveLiquidityHookParams calldata params - ) - external - nonReentrant - onlyVault - returns (uint256 bptAmountIn, uint256[] memory amountsOut, bytes memory returnData) - { - return - _vault.removeLiquidity( - RemoveLiquidityParams({ - pool: params.pool, - from: params.sender, - maxBptAmountIn: params.maxBptAmountIn, - minAmountsOut: params.minAmountsOut, - kind: params.kind, - userData: params.userData - }) - ); - } } diff --git a/pkg/standalone-utils/foundry.toml b/pkg/standalone-utils/foundry.toml index 36f3a6f77..8b4179b43 100755 --- a/pkg/standalone-utils/foundry.toml +++ b/pkg/standalone-utils/foundry.toml @@ -6,7 +6,10 @@ test = 'test/foundry' cache_path = 'forge-cache' allow_paths = ['../', '../../node_modules/'] ffi = true -fs_permissions = [{ access = "read-write", path = ".forge-snapshots/"}] +fs_permissions = [ + { access = "read", path = "./artifacts/" }, + { access = "read-write", path = "./.forge-snapshots/"}, +] remappings = [ 'vault/=../vault/', 'pool-weighted/=../pool-weighted/', diff --git a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol index 3dac237c3..cff059e82 100644 --- a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol +++ b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol @@ -2,27 +2,16 @@ pragma solidity ^0.8.24; -import "forge-std/Test.sol"; -import { GasSnapshot } from "forge-gas-snapshot/GasSnapshot.sol"; - -import { IERC20Errors } from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; -import { IVaultExtension } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultExtension.sol"; -import { IVaultErrors } from "@balancer-labs/v3-interfaces/contracts/vault/IVaultErrors.sol"; -import { IERC20MultiToken } from "@balancer-labs/v3-interfaces/contracts/vault/IERC20MultiToken.sol"; import { TokenConfig } from "@balancer-labs/v3-interfaces/contracts/vault/VaultTypes.sol"; -import { IAuthentication } from "@balancer-labs/v3-interfaces/contracts/solidity-utils/helpers/IAuthentication.sol"; import { WeightedPool } from "@balancer-labs/v3-pool-weighted/contracts/WeightedPool.sol"; import { WeightedPoolFactory } from "@balancer-labs/v3-pool-weighted/contracts/WeightedPoolFactory.sol"; -import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/ArrayHelpers.sol"; -import { ERC20TestToken } from "@balancer-labs/v3-solidity-utils/contracts/test/ERC20TestToken.sol"; +import { ArrayHelpers } from "@balancer-labs/v3-solidity-utils/contracts/test/ArrayHelpers.sol"; import { WETHTestToken } from "@balancer-labs/v3-solidity-utils/contracts/test/WETHTestToken.sol"; -import { BasicAuthorizerMock } from "@balancer-labs/v3-solidity-utils/contracts/test/BasicAuthorizerMock.sol"; import { EVMCallModeHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/EVMCallModeHelpers.sol"; import { InputHelpers } from "@balancer-labs/v3-solidity-utils/contracts/helpers/InputHelpers.sol"; import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; @@ -32,7 +21,7 @@ import { Router } from "@balancer-labs/v3-vault/contracts/Router.sol"; import { VaultMock } from "@balancer-labs/v3-vault/contracts/test/VaultMock.sol"; import { VaultExtensionMock } from "@balancer-labs/v3-vault/contracts/test/VaultExtensionMock.sol"; import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; -import { VaultMockDeployer } from "@balancer-labs/v3-vault/test/foundry/utils/VaultMockDeployer.sol"; +import { InputHelpersMock } from "@balancer-labs/v3-vault/contracts/test/InputHelpersMock.sol"; import { PriceImpact } from "../../contracts/PriceImpact.sol"; @@ -42,21 +31,21 @@ contract PriceImpactTest is BaseVaultTest { uint256 constant USDC_AMOUNT = 1e4 * 1e18; uint256 constant DAI_AMOUNT = 1e4 * 1e18; - uint256 constant DELTA = 1e9; WeightedPoolFactory factory; - WeightedPool internal weightedPool; + WeightedPool weightedPool; + PriceImpact priceImpactHelper; - PriceImpact internal priceImpactHelper; + InputHelpersMock public immutable inputHelpersMock = new InputHelpersMock(); function setUp() public virtual override { BaseVaultTest.setUp(); - priceImpactHelper = new PriceImpact(IVault(address(vault))); + priceImpactHelper = new PriceImpact(vault, router); } - function createPool() internal override returns (address) { - factory = new WeightedPoolFactory(IVault(address(vault)), 365 days); + function createPool() internal override returns (address, bytes memory) { + factory = new WeightedPoolFactory(vault, 365 days, "v1", "v1"); TokenConfig[] memory tokens = new TokenConfig[](2); tokens[0].token = IERC20(dai); tokens[1].token = IERC20(usdc); @@ -65,25 +54,28 @@ contract PriceImpactTest is BaseVaultTest { factory.create( "ERC20 Pool", "ERC20POOL", - vault.sortTokenConfig(tokens), + inputHelpersMock.sortTokenConfig(tokens), [uint256(0.50e18), uint256(0.50e18)].toMemoryArray(), - ZERO_BYTES32 + _poolRoleAccounts[pool], + 1e16, + address(0), + false, + false, + "" ) ); - return address(weightedPool); - } - - function initPool() internal override { - uint256[] memory amountsIn = [uint256(DAI_AMOUNT), uint256(USDC_AMOUNT)].toMemoryArray(); - vm.prank(lp); - router.initialize( - pool, - InputHelpers.sortTokens([address(dai), address(usdc)].toMemoryArray().asIERC20()), - amountsIn, - // Account for the precision loss - DAI_AMOUNT - DELTA - 1e6, - false, - bytes("") + return ( + address(weightedPool), + abi.encode( + WeightedPool.NewPoolParams({ + name: "ERC20 Pool", + symbol: "ERC20POOL", + numTokens: tokens.length, + normalizedWeights: [uint256(0.50e18), uint256(0.50e18)].toMemoryArray(), + version: "" + }), + vault + ) ); } @@ -91,31 +83,31 @@ contract PriceImpactTest is BaseVaultTest { vm.startPrank(address(0), address(0)); // calculate spotPrice - uint256 infinitesimalAmountIn = 1e5; - uint256 infinitesimalBptOut = priceImpactHelper.queryAddLiquidityUnbalanced( - pool, - [infinitesimalAmountIn, 0].toMemoryArray(), - 0, - bytes("") - ); - uint256 spotPrice = infinitesimalAmountIn.divDown(infinitesimalBptOut); + // uint256 infinitesimalAmountIn = 1e5; + // uint256 infinitesimalBptOut = router.queryAddLiquidityUnbalanced( + // pool, + // [infinitesimalAmountIn, 1].toMemoryArray(), + // address(0), + // bytes("") + // ); + // uint256 spotPrice = infinitesimalAmountIn.divDown(infinitesimalBptOut); // calculate priceImpact uint256 amountIn = DAI_AMOUNT / 4; uint256[] memory amountsIn = [amountIn, 0].toMemoryArray(); - uint256 priceImpact = priceImpactHelper.priceImpactForAddLiquidityUnbalanced(pool, amountsIn); + uint256 priceImpact = priceImpactHelper.calculateAddLiquidityUnbalancedPriceImpact(pool, amountsIn, address(0)); // calculate effectivePrice - uint256 bptOut = priceImpactHelper.queryAddLiquidityUnbalanced(pool, amountsIn, 0, bytes("")); - uint256 effectivePrice = amountIn.divDown(bptOut); + // uint256 bptOut = router.queryAddLiquidityUnbalanced(pool, amountsIn, address(0), bytes("")); + // uint256 effectivePrice = amountIn.divDown(bptOut); - // calculate expectedPriceImpact for comparison - uint256 expectedPriceImpact = effectivePrice.divDown(spotPrice) - 1e18; + // // calculate expectedPriceImpact for comparison + // uint256 expectedPriceImpact = effectivePrice.divDown(spotPrice) - 1e18; - vm.stopPrank(); + // vm.stopPrank(); - // assert within acceptable bounds of +-1% - assertLe(priceImpact, expectedPriceImpact + 0.01e18, "Price impact greater than expected"); - assertGe(priceImpact, expectedPriceImpact - 0.01e18, "Price impact smaller than expected"); + // // assert within acceptable bounds of +-1% + // assertLe(priceImpact, expectedPriceImpact + 0.01e18, "Price impact greater than expected"); + // assertGe(priceImpact, expectedPriceImpact - 0.01e18, "Price impact smaller than expected"); } } From cd8aaf71e18c7fda8e060e0d8b4effdd11b4c443 Mon Sep 17 00:00:00 2001 From: elshan_eth Date: Fri, 13 Dec 2024 14:20:49 +0100 Subject: [PATCH 17/18] add unit tests --- ...{PriceImpact.sol => PriceImpactHelper.sol} | 2 +- .../contracts/test/PriceImpactHelperMock.sol | 38 ++++++++ .../test/foundry/PriceImpact.t.sol | 41 ++++---- .../test/foundry/PriceImpactUnit.t.sol | 94 +++++++++++++++++++ 4 files changed, 154 insertions(+), 21 deletions(-) rename pkg/standalone-utils/contracts/{PriceImpact.sol => PriceImpactHelper.sol} (99%) create mode 100644 pkg/standalone-utils/contracts/test/PriceImpactHelperMock.sol create mode 100644 pkg/standalone-utils/test/foundry/PriceImpactUnit.t.sol diff --git a/pkg/standalone-utils/contracts/PriceImpact.sol b/pkg/standalone-utils/contracts/PriceImpactHelper.sol similarity index 99% rename from pkg/standalone-utils/contracts/PriceImpact.sol rename to pkg/standalone-utils/contracts/PriceImpactHelper.sol index 3138234d6..46afd9f41 100644 --- a/pkg/standalone-utils/contracts/PriceImpact.sol +++ b/pkg/standalone-utils/contracts/PriceImpactHelper.sol @@ -7,7 +7,7 @@ import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol" import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; -contract PriceImpact { +contract PriceImpactHelper { using FixedPoint for uint256; IVault internal immutable _vault; diff --git a/pkg/standalone-utils/contracts/test/PriceImpactHelperMock.sol b/pkg/standalone-utils/contracts/test/PriceImpactHelperMock.sol new file mode 100644 index 000000000..9d0739bd2 --- /dev/null +++ b/pkg/standalone-utils/contracts/test/PriceImpactHelperMock.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.24; + +import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; +import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; + +import { PriceImpactHelper } from "../PriceImpactHelper.sol"; + +contract PriceImpactHelperMock is PriceImpactHelper { + constructor(IVault vault, IRouter router) PriceImpactHelper(vault, router) {} + + function queryAddLiquidityUnbalancedForTokenDeltas( + address pool, + uint256 tokenIndex, + int256[] memory deltas, + address sender + ) external returns (int256 deltaBPT) { + return _queryAddLiquidityUnbalancedForTokenDeltas(pool, tokenIndex, deltas, sender); + } + + function zeroOutDeltas( + address pool, + int256[] memory deltas, + int256[] memory deltaBPTs, + address sender + ) external returns (uint256) { + return _zeroOutDeltas(pool, deltas, deltaBPTs, sender); + } + + function minPositiveIndex(int256[] memory array) external pure returns (uint256) { + return _minPositiveIndex(array); + } + + function maxNegativeIndex(int256[] memory array) external pure returns (uint256) { + return _maxNegativeIndex(array); + } +} diff --git a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol index cff059e82..2b149b4b5 100644 --- a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol +++ b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.24; +import "forge-std/console.sol"; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol"; @@ -23,25 +24,21 @@ import { VaultExtensionMock } from "@balancer-labs/v3-vault/contracts/test/Vault import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; import { InputHelpersMock } from "@balancer-labs/v3-vault/contracts/test/InputHelpersMock.sol"; -import { PriceImpact } from "../../contracts/PriceImpact.sol"; +import { PriceImpactHelper } from "../../contracts/PriceImpactHelper.sol"; contract PriceImpactTest is BaseVaultTest { using ArrayHelpers for *; using FixedPoint for uint256; - uint256 constant USDC_AMOUNT = 1e4 * 1e18; - uint256 constant DAI_AMOUNT = 1e4 * 1e18; - uint256 constant DELTA = 1e9; - WeightedPoolFactory factory; WeightedPool weightedPool; - PriceImpact priceImpactHelper; + PriceImpactHelper priceImpactHelper; InputHelpersMock public immutable inputHelpersMock = new InputHelpersMock(); function setUp() public virtual override { BaseVaultTest.setUp(); - priceImpactHelper = new PriceImpact(vault, router); + priceImpactHelper = new PriceImpactHelper(vault, router); } function createPool() internal override returns (address, bytes memory) { @@ -83,26 +80,30 @@ contract PriceImpactTest is BaseVaultTest { vm.startPrank(address(0), address(0)); // calculate spotPrice - // uint256 infinitesimalAmountIn = 1e5; - // uint256 infinitesimalBptOut = router.queryAddLiquidityUnbalanced( - // pool, - // [infinitesimalAmountIn, 1].toMemoryArray(), - // address(0), - // bytes("") - // ); - // uint256 spotPrice = infinitesimalAmountIn.divDown(infinitesimalBptOut); + uint256 infinitesimalAmountIn = 1e10; + uint256 infinitesimalBptOut = router.queryAddLiquidityUnbalanced( + pool, + [infinitesimalAmountIn, 0].toMemoryArray(), + address(0), + bytes("") + ); + uint256 spotPrice = infinitesimalAmountIn.divDown(infinitesimalBptOut); // calculate priceImpact - uint256 amountIn = DAI_AMOUNT / 4; + uint256 amountIn = poolInitAmount / 2; uint256[] memory amountsIn = [amountIn, 0].toMemoryArray(); uint256 priceImpact = priceImpactHelper.calculateAddLiquidityUnbalancedPriceImpact(pool, amountsIn, address(0)); // calculate effectivePrice - // uint256 bptOut = router.queryAddLiquidityUnbalanced(pool, amountsIn, address(0), bytes("")); - // uint256 effectivePrice = amountIn.divDown(bptOut); + uint256 bptOut = router.queryAddLiquidityUnbalanced(pool, amountsIn, address(0), bytes("")); + uint256 effectivePrice = amountIn.divDown(bptOut); + + console.log("spotPrice", spotPrice); + console.log("priceImpact", priceImpact); + console.log("effectivePrice", effectivePrice); - // // calculate expectedPriceImpact for comparison - // uint256 expectedPriceImpact = effectivePrice.divDown(spotPrice) - 1e18; + // // calculate expectedPriceImpact for comparison + // uint256 expectedPriceImpact = effectivePrice.divDown(spotPrice) - 1e18; // vm.stopPrank(); diff --git a/pkg/standalone-utils/test/foundry/PriceImpactUnit.t.sol b/pkg/standalone-utils/test/foundry/PriceImpactUnit.t.sol new file mode 100644 index 000000000..fbeaa3153 --- /dev/null +++ b/pkg/standalone-utils/test/foundry/PriceImpactUnit.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.24; + +import { BaseVaultTest } from "@balancer-labs/v3-vault/test/foundry/utils/BaseVaultTest.sol"; +import { PriceImpactHelperMock } from "../../contracts/test/PriceImpactHelperMock.sol"; + +contract PriceImpactUnitTest is BaseVaultTest { + PriceImpactHelperMock priceImpactHelper; + + function setUp() public virtual override { + BaseVaultTest.setUp(); + priceImpactHelper = new PriceImpactHelperMock(vault, router); + } + + function testMaxNegativeIndex__Fuzz(int256[10] memory arrayRaw, uint256 maxIndex) public view { + int256[] memory array = new int256[](arrayRaw.length); + + maxIndex = bound(maxIndex, 0, arrayRaw.length - 1); + for (uint256 i = 0; i < arrayRaw.length; i++) { + array[i] = bound(arrayRaw[i], type(int256).min, -2); + } + array[maxIndex] = -1; + + uint256 expectedIndex = priceImpactHelper.maxNegativeIndex(array); + assertEq(expectedIndex, maxIndex, "expected max value index wrong"); + } + + function testMinPositiveIndex__Fuzz(int256[10] memory arrayRaw, uint256 minIndex) public view { + int256[] memory array = new int256[](arrayRaw.length); + + minIndex = bound(minIndex, 0, arrayRaw.length - 1); + for (uint256 i = 0; i < arrayRaw.length; i++) { + array[i] = bound(arrayRaw[i], 2, type(int256).max); + } + array[minIndex] = 1; + + uint256 expectedIndex = priceImpactHelper.minPositiveIndex(array); + assertEq(expectedIndex, minIndex, "expected min value index wrong"); + } + + function testQueryAddLiquidityUnbalancedForTokenDeltas__Fuzz( + int256[10] memory deltasRaw, + uint256 length, + uint256 tokenIndex, + uint256 mockResult + ) public { + length = bound(length, 2, deltasRaw.length); + tokenIndex = bound(tokenIndex, 0, length - 1); + mockResult = bound(mockResult, 0, MAX_UINT128); + + int256[] memory deltas = new int256[](length); + for (uint256 i = 0; i < length; i++) { + deltas[i] = bound(deltasRaw[i], type(int256).min + 1, type(int256).max); + } + + int256 delta = deltas[tokenIndex]; + if (delta == 0) { + assertEq( + priceImpactHelper.queryAddLiquidityUnbalancedForTokenDeltas(pool, tokenIndex, deltas, address(this)), + 0, + "queryAddLiquidityUnbalancedForTokenDeltas should return 0" + ); + } else { + uint256[] memory mockDeltas = new uint256[](length); + mockDeltas[tokenIndex] = uint256(delta > 0 ? delta : -delta); + + vm.mockCall( + address(router), + abi.encodeWithSelector( + router.queryAddLiquidityUnbalanced.selector, + pool, + mockDeltas, + address(this), + "" + ), + abi.encode(mockResult) + ); + + int256 expectedResult = priceImpactHelper.queryAddLiquidityUnbalancedForTokenDeltas( + pool, + tokenIndex, + deltas, + address(this) + ); + + assertEq( + expectedResult, + delta > 0 ? int256(mockResult) : -int256(mockResult), + "expected queryAddLiquidityUnbalancedForTokenDeltas result is wrong" + ); + } + } +} From e1d0a7081e4936f7a212d952c39579000a4b0e4c Mon Sep 17 00:00:00 2001 From: elshan_eth Date: Fri, 13 Dec 2024 15:37:46 +0100 Subject: [PATCH 18/18] fix calls and add tests --- .../contracts/CallAndRevert.sol | 39 ++++++ .../contracts/PriceImpactHelper.sol | 121 +++++++++++++++--- .../test/foundry/PriceImpact.t.sol | 32 ++--- 3 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 pkg/standalone-utils/contracts/CallAndRevert.sol diff --git a/pkg/standalone-utils/contracts/CallAndRevert.sol b/pkg/standalone-utils/contracts/CallAndRevert.sol new file mode 100644 index 000000000..d3f070a47 --- /dev/null +++ b/pkg/standalone-utils/contracts/CallAndRevert.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pragma solidity ^0.8.24; + +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; + +import { RevertCodec } from "@balancer-labs/v3-solidity-utils/contracts/helpers/RevertCodec.sol"; + +contract CallAndRevert { + error QuoteResultSpoofed(); + + function _callAndRevert(address target, bytes memory data) internal returns (bytes memory) { + try CallAndRevert(address(this)).callAndRevertHook(target, data) { + revert("Unexpected success"); + } catch (bytes memory result) { + return RevertCodec.catchEncodedResult(result); + } + } + + function callAndRevertHook(address target, bytes memory data) external returns (uint256) { + // solhint-disable-next-line avoid-low-level-calls + (bool success, bytes memory result) = (target).call(data); + if (success) { + // This will only revert if result is empty and sender account has no code. + Address.verifyCallResultFromTarget(msg.sender, success, result); + // Send result in revert reason. + revert RevertCodec.Result(result); + } else { + // If the call reverted with a spoofed `QuoteResult`, we catch it and bubble up a different reason. + bytes4 errorSelector = RevertCodec.parseSelector(result); + if (errorSelector == RevertCodec.Result.selector) { + revert QuoteResultSpoofed(); + } + + // Otherwise we bubble up the original revert reason. + RevertCodec.bubbleUpRevert(result); + } + } +} diff --git a/pkg/standalone-utils/contracts/PriceImpactHelper.sol b/pkg/standalone-utils/contracts/PriceImpactHelper.sol index 46afd9f41..06dd9453a 100644 --- a/pkg/standalone-utils/contracts/PriceImpactHelper.sol +++ b/pkg/standalone-utils/contracts/PriceImpactHelper.sol @@ -7,7 +7,9 @@ import { IVault } from "@balancer-labs/v3-interfaces/contracts/vault/IVault.sol" import { IRouter } from "@balancer-labs/v3-interfaces/contracts/vault/IRouter.sol"; import { FixedPoint } from "@balancer-labs/v3-solidity-utils/contracts/math/FixedPoint.sol"; -contract PriceImpactHelper { +import { CallAndRevert } from "./CallAndRevert.sol"; + +contract PriceImpactHelper is CallAndRevert { using FixedPoint for uint256; IVault internal immutable _vault; @@ -27,13 +29,8 @@ contract PriceImpactHelper { uint256[] memory exactAmountsIn, address sender ) external returns (uint256 priceImpact) { - uint256 bptAmountOut = _router.queryAddLiquidityUnbalanced(pool, exactAmountsIn, sender, ""); - uint256[] memory proportionalAmountsOut = _router.queryRemoveLiquidityProportional( - pool, - bptAmountOut, - sender, - "" - ); + uint256 bptAmountOut = _queryAddLiquidityUnbalanced(pool, exactAmountsIn, sender); + uint256[] memory proportionalAmountsOut = _queryRemoveLiquidityProportional(pool, bptAmountOut, sender); // get deltas between exactAmountsIn and proportionalAmountsOut int256[] memory deltas = new int256[](exactAmountsIn.length); @@ -56,6 +53,102 @@ contract PriceImpactHelper { return delta.divDown(exactAmountsIn[remainingDeltaIndex]) / 2; } + /******************************************************************************* + Router Queries + *******************************************************************************/ + + function _queryAddLiquidityUnbalanced( + address pool, + uint256[] memory exactAmountsIn, + address sender + ) internal returns (uint256) { + return + abi.decode( + _callAndRevert( + address(_router), + abi.encodeWithSelector( + _router.queryAddLiquidityUnbalanced.selector, + pool, + exactAmountsIn, + sender, + "" + ) + ), + (uint256) + ); + } + + function _queryRemoveLiquidityProportional( + address pool, + uint256 bptAmountOut, + address sender + ) internal returns (uint256[] memory) { + return + abi.decode( + _callAndRevert( + address(_router), + abi.encodeWithSelector( + _router.queryRemoveLiquidityProportional.selector, + pool, + bptAmountOut, + sender, + "" + ) + ), + (uint256[]) + ); + } + + function _querySwapSingleTokenExactIn( + address pool, + IERC20 tokenIn, + IERC20 tokenOut, + uint256 amountIn, + address sender + ) internal returns (uint256) { + return + abi.decode( + _callAndRevert( + address(_router), + abi.encodeWithSelector( + _router.querySwapSingleTokenExactIn.selector, + pool, + tokenIn, + tokenOut, + amountIn, + sender, + "" + ) + ), + (uint256) + ); + } + + function _querySwapSingleTokenExactOut( + address pool, + IERC20 tokenIn, + IERC20 tokenOut, + uint256 amountOut, + address sender + ) internal returns (uint256) { + return + abi.decode( + _callAndRevert( + address(_router), + abi.encodeWithSelector( + _router.querySwapSingleTokenExactOut.selector, + pool, + tokenIn, + tokenOut, + amountOut, + sender, + "" + ) + ), + (uint256) + ); + } + /******************************************************************************* Helpers *******************************************************************************/ @@ -74,7 +167,7 @@ contract PriceImpactHelper { } zerosWithSingleDelta[tokenIndex] = uint256(delta > 0 ? delta : -delta); - int256 result = int256(_router.queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, sender, "")); + int256 result = int256(_queryAddLiquidityUnbalanced(pool, zerosWithSingleDelta, sender)); return delta > 0 ? result : -result; } @@ -100,24 +193,22 @@ contract PriceImpactHelper { if (deltaBPTs[minPositiveDeltaIndex] < -deltaBPTs[minNegativeDeltaIndex]) { givenTokenIndex = minPositiveDeltaIndex; resultTokenIndex = minNegativeDeltaIndex; - resultAmount = _router.querySwapSingleTokenExactIn( + resultAmount = _querySwapSingleTokenExactIn( pool, poolTokens[givenTokenIndex], poolTokens[resultTokenIndex], uint(deltas[givenTokenIndex]), - sender, - "" + sender ); } else { givenTokenIndex = minNegativeDeltaIndex; resultTokenIndex = minPositiveDeltaIndex; - resultAmount = _router.querySwapSingleTokenExactOut( + resultAmount = _querySwapSingleTokenExactOut( pool, poolTokens[resultTokenIndex], poolTokens[givenTokenIndex], uint(-deltas[givenTokenIndex]), - sender, - "" + sender ); } diff --git a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol index 2b149b4b5..cca6f11b8 100644 --- a/pkg/standalone-utils/test/foundry/PriceImpact.t.sol +++ b/pkg/standalone-utils/test/foundry/PriceImpact.t.sol @@ -78,37 +78,39 @@ contract PriceImpactTest is BaseVaultTest { function testPriceImpact() public { vm.startPrank(address(0), address(0)); + uint256 snapshot = vm.snapshot(); + + // calculate priceImpact + uint256 amountIn = poolInitAmount / 2; + uint256[] memory amountsIn = [amountIn, 0].toMemoryArray(); + + uint256 priceImpact = priceImpactHelper.calculateAddLiquidityUnbalancedPriceImpact(pool, amountsIn, address(0)); + vm.revertTo(snapshot); // calculate spotPrice uint256 infinitesimalAmountIn = 1e10; + uint256 infinitesimalBptOut = router.queryAddLiquidityUnbalanced( pool, [infinitesimalAmountIn, 0].toMemoryArray(), address(0), bytes("") ); - uint256 spotPrice = infinitesimalAmountIn.divDown(infinitesimalBptOut); + vm.revertTo(snapshot); - // calculate priceImpact - uint256 amountIn = poolInitAmount / 2; - uint256[] memory amountsIn = [amountIn, 0].toMemoryArray(); - uint256 priceImpact = priceImpactHelper.calculateAddLiquidityUnbalancedPriceImpact(pool, amountsIn, address(0)); + uint256 spotPrice = infinitesimalAmountIn.divDown(infinitesimalBptOut); // calculate effectivePrice uint256 bptOut = router.queryAddLiquidityUnbalanced(pool, amountsIn, address(0), bytes("")); uint256 effectivePrice = amountIn.divDown(bptOut); - console.log("spotPrice", spotPrice); - console.log("priceImpact", priceImpact); - console.log("effectivePrice", effectivePrice); - - // // calculate expectedPriceImpact for comparison - // uint256 expectedPriceImpact = effectivePrice.divDown(spotPrice) - 1e18; + // calculate expectedPriceImpact for comparison + uint256 expectedPriceImpact = effectivePrice.divDown(spotPrice) - 1e18; - // vm.stopPrank(); + // assert within acceptable bounds of +-1% + assertLe(priceImpact, expectedPriceImpact + 0.01e18, "Price impact greater than expected"); + assertGe(priceImpact, expectedPriceImpact - 0.01e18, "Price impact smaller than expected"); - // // assert within acceptable bounds of +-1% - // assertLe(priceImpact, expectedPriceImpact + 0.01e18, "Price impact greater than expected"); - // assertGe(priceImpact, expectedPriceImpact - 0.01e18, "Price impact smaller than expected"); + vm.stopPrank(); } }