From faee9b943fc54369da9b7e77ce6a9c43cc2b2389 Mon Sep 17 00:00:00 2001 From: Anirudh Suresh Date: Mon, 19 Feb 2024 16:43:24 -0500 Subject: [PATCH] Test improvements (#17) * precom changes * testing changes to accomodate updates * package changes * precom fix * clearer error raising on smart contracts * price updates in mock pyth format * address comments * fix poetry version mismatch * changes to clones and write locks * add initial batch of happy path tests * eliminate separation of nonce from rest of signature fields, simplify signatures * additional happy path tests and cleanup of forge test file * add documentation on happy path testing for whole system * better naming of script functions * address comments * Refactor tests * more refactoring * comments + reorg --------- Co-authored-by: Anirudh Suresh Co-authored-by: Amin Moghaddam Co-authored-by: Anirudh Suresh --- README.md | 25 + auction-server/src/liquidation_adapter.rs | 8 +- per_multicall/README.md | 2 +- per_multicall/script/Vault.s.sol | 14 +- per_multicall/src/Errors.sol | 3 - per_multicall/src/LiquidationAdapter.sol | 4 +- per_multicall/src/PERMulticall.sol | 20 +- per_multicall/src/SearcherVault.sol | 29 +- per_multicall/src/SigVerify.sol | 32 +- per_multicall/test/PERIntegration.t.sol | 1169 +++++++++++++++++ per_multicall/test/PERVault.t.sol | 674 ---------- .../test/helpers/MulticallHelpers.sol | 41 + per_multicall/test/helpers/PriceHelpers.sol | 64 + per_multicall/test/helpers/Signatures.sol | 47 +- .../test/helpers/TestParsingHelpers.sol | 77 ++ per_sdk/searcher/searcher_utils.py | 15 +- 16 files changed, 1460 insertions(+), 764 deletions(-) create mode 100644 per_multicall/test/PERIntegration.t.sol delete mode 100644 per_multicall/test/PERVault.t.sol create mode 100644 per_multicall/test/helpers/MulticallHelpers.sol create mode 100644 per_multicall/test/helpers/PriceHelpers.sol create mode 100644 per_multicall/test/helpers/TestParsingHelpers.sol diff --git a/README.md b/README.md index 1072570b..9373167e 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,28 @@ The checks are also performed in the CI to ensure the code follows consistent fo ### Development with Tilt Run `tilt up --namespace dev-` to start tilt. + +## Testing + +You can run forge tests from `per_multicall/` with the `--via-ir` flag. This only tests the smart contracts and can be used to evaluate whether any changes to the smart contracts preserve the desired behavior specified by the tests. + +To run a happy path test of the on-chain contracts plus the off-chain services, follow the following steps. You will need a valid EVM private key saved as SK_TX_SENDER to submit forge transactions from. + +1. Run `anvil --gas-limit 500000000000000000 --block-time 2`. Retrieve the localhost url and save as ANVIL_RPC_URL. +2. Run `forge script script/Vault.s.sol --via-ir --fork-url ${ANVIL_RPC_URL} --private-key ${SK_TX_SENDER} -vvv --sig 'setUpHappyPath()' --broadcast` from `per_multicall/`. +3. Run `forge script script/Vault.s.sol --via-ir --fork-url ${ANVIL_RPC_URL} --private-key ${SK_TX_SENDER} -vvv --sig 'getVault(uint256)' 0 --broadcast` from `per_multicall/`. Confirm that the logged vault amounts are nonzero. +4. Retrieve the following information from `per_multicall/latestEnvironment.json`: + a. Retrieve the address saved under "multicall" and save as MULTICALL. + b. Retrieve the address saved under "liquidationAdapter" and save as ADAPTER. + c. Retrieve the address saved under "tokenVault" and save as TOKEN_VAULT. + d. Retrieve the address saved under "weth" and save as WETH. + e. Retreive the number saved under "perOperatorSk", convert to a hex string, and save as OPERATOR_SK. You can perform this conversion in Python by calling hex() on the number. + f. Retrieve the number saved under "searcherAOwnerSk", convert to a hex string, and save as SEARCHER_SK. You can perform this conversion in Python by calling hex on the number. +5. Create a file `auction-server/config.yaml`. Follow the format in the template `auction-server/config.sample.yaml`. Under the chain `development`, set + a. `geth_rpc_addr` to the value stored in ANVIL_RPC_URL + b. `per_contract` to the value stored in MULTICALL + c. `adapter_contract` to the value stored in ADAPTER +6. Run `cargo run -- run --per-private-key ${OPERATOR_SK}` from `auction-server/`. This should start up the auction server. +7. Run `python3 -m per_sdk.protocols.token_vault_monitor --chain-id development --rpc-url ${ANVIL_RPC_URL} --vault-contract ${TOKEN_VAULT} --weth-contract ${WETH} --liquidation-server-url http://localhost:9000/liquidation/submit_opportunity --mock-pyth`. This should start up the monitor script that exposes liquidatable vaults to the liquidation monitor server. +8. Run `python3 -m per_sdk.searcher.simple_searcher --private-key ${SEARCHER_SK} --chain-id development --verbose --liquidation-server-url http://localhost:9000`. +9. Run `forge script script/Vault.s.sol --via-ir --fork-url ${ANVIL_RPC_URL} --private-key ${SK_TX_SENDER} -vvv --sig 'getVault(uint256)' 0 --broadcast` from `per_multicall/`. Confirm that the logged vault amounts are now 0--this indicates that the vault was properly liquidated. diff --git a/auction-server/src/liquidation_adapter.rs b/auction-server/src/liquidation_adapter.rs index 89812e68..a0832ed8 100644 --- a/auction-server/src/liquidation_adapter.rs +++ b/auction-server/src/liquidation_adapter.rs @@ -202,13 +202,9 @@ fn get_liquidation_digest(params: liquidation_adapter::LiquidationCallParams) -> params.data.into_token(), params.value.into_token(), params.bid.into_token(), + params.valid_until.into_token(), ])); - // encode packed does not work correctly for U256 so we need to convert it to bytes first - let nonce_bytes = Bytes::from(<[u8; 32]>::from(params.valid_until)); - let digest = H256(keccak256(abi::encode_packed(&[ - data.into_token(), - nonce_bytes.into_token(), - ])?)); + let digest = H256(keccak256(data)); Ok(digest) } diff --git a/per_multicall/README.md b/per_multicall/README.md index 570c23ba..0d766155 100644 --- a/per_multicall/README.md +++ b/per_multicall/README.md @@ -31,7 +31,7 @@ To run the script runs in `Vault.s.sol`, you should startup the local validator 1. Set up contracts and save to an environment JSON. ```shell -$ forge script script/Vault.s.sol --via-ir --fork-url http://localhost:8545 --private-key 0xf46ea803192f16ef1c4f1d5fb0d6060535dbd571ea1afc7db6816f28961ba78a -vvv --sig 'setUpContracts()' --broadcast +$ forge script script/Vault.s.sol --via-ir --fork-url http://localhost:8545 --private-key 0xf46ea803192f16ef1c4f1d5fb0d6060535dbd571ea1afc7db6816f28961ba78a -vvv --sig 'setUpLocalnet()' --broadcast ``` 2. Set oracle prices to allow for vault creation. diff --git a/per_multicall/script/Vault.s.sol b/per_multicall/script/Vault.s.sol index ce224a0a..d222ff5b 100644 --- a/per_multicall/script/Vault.s.sol +++ b/per_multicall/script/Vault.s.sol @@ -147,7 +147,12 @@ contract VaultScript is Script { vm.writeJson(finalJSON, latestEnvironmentPath); } - function setUpContracts() public { + /** + @notice Sets up the localnet environment for testing purposes + deploys WETH, PER, LiquidationAdapter, MockPyth, TokenVault and 2 ERC-20 tokens to use as collateral and debt tokens + Also creates and funds searcher wallets and contracts + */ + function setUpLocalnet() public { SearcherVault searcherA; SearcherVault searcherB; @@ -527,4 +532,11 @@ contract VaultScript is Script { ); return LiquidationAdapter(payable(liquidationAdapter)).getWeth(); } + + function setUpHappyPath() public { + setUpLocalnet(); + setOraclePrice(110, 110, 190); + setUpVault(100, 80, true); + setOraclePrice(110, 200, 200); + } } diff --git a/per_multicall/src/Errors.sol b/per_multicall/src/Errors.sol index 578b18e2..65f71f94 100644 --- a/per_multicall/src/Errors.sol +++ b/per_multicall/src/Errors.sol @@ -25,9 +25,6 @@ error InvalidPERSignature(); // Signature: 0xb7d09497 error InvalidTimestamp(); -// Signature: 0xc6388ef7 -error InvalidBid(); - // Signature: 0xaba47339 error NotRegistered(); diff --git a/per_multicall/src/LiquidationAdapter.sol b/per_multicall/src/LiquidationAdapter.sol index b44a9dad..ae2daf7f 100644 --- a/per_multicall/src/LiquidationAdapter.sol +++ b/per_multicall/src/LiquidationAdapter.sol @@ -70,9 +70,9 @@ contract LiquidationAdapter is SigVerify { params.contractAddress, params.data, params.value, - params.bid + params.bid, + params.validUntil ), - params.validUntil, params.signatureLiquidator ); if (!validSignature) { diff --git a/per_multicall/src/PERMulticall.sol b/per_multicall/src/PERMulticall.sol index f55b3c67..b661f154 100644 --- a/per_multicall/src/PERMulticall.sol +++ b/per_multicall/src/PERMulticall.sol @@ -66,7 +66,8 @@ contract PERMulticall { function _bytesToAddress( bytes memory bys ) private pure returns (address addr) { - (addr, ) = abi.decode(bys, (address, bytes)); + // this does not assume the struct fields of the permission key + addr = address(uint160(uint256(bytes32(bys)))); } /** @@ -105,7 +106,11 @@ contract PERMulticall { } catch Error(string memory reason) { multicallStatuses[i].multicallRevertReason = reason; } - totalBid += bids[i]; + + // only count bid if call was successful (and bid was paid out) + if (multicallStatuses[i].externalSuccess) { + totalBid += bids[i]; + } } // use the first 20 bytes of permission as fee receiver @@ -150,12 +155,11 @@ contract PERMulticall { uint256 balanceFinalEth = address(this).balance; // ensure that PER operator was paid at least bid ETH - if ( - (balanceFinalEth - balanceInitEth < bid) || - (balanceFinalEth < balanceInitEth) - ) { - revert InvalidBid(); - } + require( + (balanceFinalEth - balanceInitEth >= bid) && + (balanceFinalEth >= balanceInitEth), + "invalid bid" + ); } return (success, result); diff --git a/per_multicall/src/SearcherVault.sol b/per_multicall/src/SearcherVault.sol index 97564689..2cd99e9b 100644 --- a/per_multicall/src/SearcherVault.sol +++ b/per_multicall/src/SearcherVault.sol @@ -35,13 +35,6 @@ contract SearcherVault is SigVerify { tokenVault = protocolAddress; } - function _updatePriceFeed(bytes calldata updateData) internal { - bytes[] memory updateDatas = new bytes[](1); - updateDatas[0] = updateData; - address oracle = TokenVault(payable(tokenVault)).getOracle(); - MockPyth(oracle).updatePriceFeeds(updateDatas); - } - /** * @notice doLiquidatePER function - liquidates a vault through PER * @@ -65,8 +58,7 @@ contract SearcherVault is SigVerify { if (msg.sender == perMulticall) { bool validSignatureSearcher = verifyCalldata( owner, - abi.encodePacked(vaultID, bid), - validUntil, + abi.encode(vaultID, bid, validUntil), signatureSearcher ); if (!validSignatureSearcher) { @@ -80,10 +72,6 @@ contract SearcherVault is SigVerify { } } - if (updateData.length > 0) { - _updatePriceFeed(updateData); - } - address payable vaultContract = payable(tokenVault); Vault memory vault = TokenVault(vaultContract).getVault(vaultID); @@ -92,8 +80,12 @@ contract SearcherVault is SigVerify { uint256 tokenAmount = vault.amountDebt; IERC20(tokenDebt).approve(vaultContract, tokenAmount); - - TokenVault(vaultContract).liquidate(vaultID); + bytes[] memory updateDatas = new bytes[](1); + updateDatas[0] = updateData; + TokenVault(vaultContract).liquidateWithPriceUpdate( + vaultID, + updateDatas + ); if (bid > 0) { payable(perMulticall).transfer(bid); } @@ -102,6 +94,13 @@ contract SearcherVault is SigVerify { _signatureUsed[signatureSearcher] = true; } + function withdrawEth(uint256 amount) public { + if (msg.sender != owner) { + revert Unauthorized(); + } + payable(owner).transfer(amount); + } + receive() external payable { emit ReceivedETH(msg.sender, msg.value); } diff --git a/per_multicall/src/SigVerify.sol b/per_multicall/src/SigVerify.sol index 6edfea16..e38a792d 100644 --- a/per_multicall/src/SigVerify.sol +++ b/per_multicall/src/SigVerify.sol @@ -5,42 +5,12 @@ import "./Errors.sol"; import "forge-std/console.sol"; contract SigVerify { - function getMessageDigest( - string memory _message, - uint _nonce - ) public pure returns (bytes32) { - return keccak256(abi.encodePacked(_message, _nonce)); - } - - // TODO: This should not be here if only used on tests - function getPERSignedMessageDigest( - bytes32 _messageHash - ) public pure returns (bytes32) { - /* - Signature is produced by signing a keccak256 hash with the following format: - "\x19PER Signed Message\n" + msg - */ - return - keccak256( - abi.encodePacked("\x19PER Signed Message:\n66", _messageHash) - ); - } - - function getCalldataDigest( - bytes memory _data, - uint _nonce - ) public pure returns (bytes32) { - // TODO: fold nonce back to the rest of data, it does not need to be treated differently - return keccak256(abi.encodePacked(_data, _nonce)); - } - function verifyCalldata( address _signer, bytes memory _data, - uint _nonce, bytes memory signature ) public pure returns (bool) { - bytes32 calldataHash = getCalldataDigest(_data, _nonce); + bytes32 calldataHash = keccak256(_data); return recoverSigner(calldataHash, signature) == _signer; } diff --git a/per_multicall/test/PERIntegration.t.sol b/per_multicall/test/PERIntegration.t.sol new file mode 100644 index 00000000..5788552f --- /dev/null +++ b/per_multicall/test/PERIntegration.t.sol @@ -0,0 +1,1169 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console2} from "forge-std/Test.sol"; +import "../src/SigVerify.sol"; +import "forge-std/console.sol"; +import "forge-std/StdMath.sol"; + +import {TokenVault} from "../src/TokenVault.sol"; +import {SearcherVault} from "../src/SearcherVault.sol"; +import {PERMulticall} from "../src/PERMulticall.sol"; +import {WETH9} from "../src/WETH9.sol"; +import {LiquidationAdapter} from "../src/LiquidationAdapter.sol"; +import {MyToken} from "../src/MyToken.sol"; +import "../src/Errors.sol"; +import "../src/TokenVaultErrors.sol"; +import "../src/Structs.sol"; + +import "@pythnetwork/pyth-sdk-solidity/MockPyth.sol"; + +import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; + +import "openzeppelin-contracts/contracts/utils/Strings.sol"; + +import "./helpers/Signatures.sol"; +import "./helpers/PriceHelpers.sol"; +import "./helpers/TestParsingHelpers.sol"; +import "./helpers/MulticallHelpers.sol"; + +/** + * @title PERIntegrationTest + * + * PERIntegrationTest is a contract that tests the integration of the various contracts in the PER stack. + * This includes the PERMulticall entrypoint contract for all PER interactions, the TokenVault dummy lending protocol contract, individual searcher contracts programmed to perform liquidations, the LiquidationAdapter contract used to facilitate liquidations directly from searcher EOAs, and the relevant token contracts. + * We test the integration of these contracts by creating vaults in the TokenVault protocol, simulating undercollateralization of these vaults to trigger liquidations, constructing the necessary liquidation data, and then calling liquidation through LiquidationAdapter or the searcher contracts. + * + * The focus in these tests is ensuring that liquidation succeeds (or fails as expected) through the PERMulticall contrct routing to the searcher contracts or the LiquidationAdapter contract. + */ +contract PERIntegrationTest is + Test, + TestParsingHelpers, + Signatures, + PriceHelpers, + MulticallHelpers +{ + TokenVault public tokenVault; + SearcherVault public searcherA; + SearcherVault public searcherB; + PERMulticall public multicall; + WETH9 public weth; + LiquidationAdapter public liquidationAdapter; + MockPyth public mockPyth; + + MyToken public token1; + MyToken public token2; + + bytes32 idToken1; + bytes32 idToken2; + + int32 constant tokenExpo = 0; + + address perOperatorAddress; + uint256 perOperatorSk; + address searcherAOwnerAddress; + uint256 searcherAOwnerSk; + address searcherBOwnerAddress; + uint256 searcherBOwnerSk; + address tokenVaultDeployer; + uint256 tokenVaultDeployerSk; + + uint256 constant healthPrecision = 10 ** 16; + + address depositor; // address of the initial depositor into the token vault + + uint256 constant amountToken1DepositorInit = 1_000_000; // amount of token 1 initially owned by the vault depositor + uint256 constant amountToken2DepositorInit = 1_000_000; // amount of token 2 initially owned by the vault depositor + uint256 constant amountToken1AInit = 2_000_000; // amount of token 1 initially owned by searcher A contract + uint256 constant amountToken2AInit = 2_000_000; // amount of token 2 initially owned by searcher A contract + uint256 constant amountToken1BInit = 3_000_000; // amount of token 1 initially owned by searcher B contract + uint256 constant amountToken2BInit = 3_000_000; // amount of token 2 initially owned by searcher B contract + uint256 constant amountToken2TokenVaultInit = 500_000; // amount of token 2 initially owned by the token vault contract (necessary to allow depositor to borrow token 2) + + address[] tokensCollateral; // addresses of collateral, index corresponds to vault number + address[] tokensDebt; // addresses of debt, index corresponds to vault number + uint256[] amountsCollateral; // amounts of collateral, index corresponds to vault number + uint256[] amountsDebt; // amounts of debt, index corresponds to vault number + bytes32[] idsCollateral; // pyth price feed ids of collateral, index corresponds to vault number + bytes32[] idsDebt; // pyth price feed ids of debt, index corresponds to vault number + + // initial token oracle info + int64 constant token1PriceInitial = 100; + uint64 constant token1ConfInitial = 1; + int64 constant token2PriceInitial = 100; + uint64 constant token2ConfInitial = 1; + uint64 constant publishTimeInitial = 1_000_000; + uint64 constant prevPublishTimeInitial = 0; + + int64[] tokenDebtPricesLiqPER; + int64[] tokenDebtPricesLiqPermissionless; + + uint256 constant defaultFeeSplitProtocol = 50 * 10 ** 16; + + uint256 feeSplitTokenVault; + uint256 constant feeSplitPrecisionTokenVault = 10 ** 18; + + /** + * @notice setUp function - sets up the contracts, wallets, tokens, oracle feeds, and vaults for the test + * + * This function creates the entire environment for the start of each test. It is called before each test. + * This function creates the PERMulticall, WETH9, LiquidationAdapter, MockPyth, TokenVault, SearcherVault, and two ERC-20 token contracts. The two ERC-20 tokens are used as collateral and debt tokens for the vaults that will be created. + * It also sets up the initial token amounts for the depositor, searcher A, searcher B, and the token vault. Additionally, it sets the initial oracle prices for the tokens. + * The function then sets up two vaults in the TokenVault contract. Each vault's collateral and debt tokens are set, as well as the amounts of each token in the vault. Based on the amounts in the vault and the initial token prices, we back out the liquidation threshold prices--these are used later in the tests to set prices that trigger liquidation. + * Finally, the function funds the searcher wallets with Eth and tokens. It also creates the allowances from the searchers' wallets to the liquidation adapter to use the searcher wallets' tokens and weth to liquidate vaults. + */ + function setUp() public { + setUpWallets(); + setUpContracts(); + setUpTokensAndOracle(); + setUpVaults(); + fundSearcherWallets(); + } + + /** + * @notice setUpWallets function - sets up the wallets for the test + * + * Sets up per operator, searcher, initial token vault deployer, and initial vault depositor wallets + */ + function setUpWallets() public { + (perOperatorAddress, perOperatorSk) = makeAddrAndKey("perOperator"); + + (searcherAOwnerAddress, searcherAOwnerSk) = makeAddrAndKey("searcherA"); + (searcherBOwnerAddress, searcherBOwnerSk) = makeAddrAndKey("searcherB"); + + (tokenVaultDeployer, tokenVaultDeployerSk) = makeAddrAndKey( + "tokenVaultDeployer" + ); + + (depositor, ) = makeAddrAndKey("depositor"); + } + + /** + * @notice setUpContracts function - sets up the contracts for the test + * + * Sets up the PERMulticall, WETH9, LiquidationAdapter, MockPyth, TokenVault, SearcherVault, and ERC-20 token contracts + */ + function setUpContracts() public { + // instantiate multicall contract with PER operator as the deployer + vm.prank(perOperatorAddress, perOperatorAddress); + multicall = new PERMulticall( + perOperatorAddress, + defaultFeeSplitProtocol + ); + + vm.prank(perOperatorAddress, perOperatorAddress); + weth = new WETH9(); + + vm.prank(perOperatorAddress, perOperatorAddress); + liquidationAdapter = new LiquidationAdapter( + address(multicall), + address(weth) + ); + + vm.prank(perOperatorAddress, perOperatorAddress); + mockPyth = new MockPyth(1_000_000, 0); + + vm.prank(tokenVaultDeployer, tokenVaultDeployer); // we prank here to standardize the value of the token contract address across different runs + tokenVault = new TokenVault(address(multicall), address(mockPyth)); + console.log("contract of token vault is", address(tokenVault)); + feeSplitTokenVault = defaultFeeSplitProtocol; + + // instantiate searcher A's contract with searcher A's wallet as the deployer + vm.prank(searcherAOwnerAddress, searcherAOwnerAddress); + searcherA = new SearcherVault(address(multicall), address(tokenVault)); + console.log("contract of searcher A is", address(searcherA)); + + // instantiate searcher B's contract with searcher B's wallet as the deployer + vm.prank(searcherBOwnerAddress, searcherBOwnerAddress); + searcherB = new SearcherVault(address(multicall), address(tokenVault)); + console.log("contract of searcher B is", address(searcherB)); + + vm.prank(perOperatorAddress, perOperatorAddress); + token1 = new MyToken("token1", "T1"); + vm.prank(perOperatorAddress, perOperatorAddress); + token2 = new MyToken("token2", "T2"); + console.log("contract of token1 is", address(token1)); + console.log("contract of token2 is", address(token2)); + } + + /** + * @notice setUpTokensAndOracle function - sets up the tokens for the test and their initial oracle feeds + * + * Sets up the initial token amounts for the depositor, searcher A, searcher B, and the token vault + * Also sets the initial oracle prices for the tokens + */ + function setUpTokensAndOracle() public { + // mint tokens to the depositor address + token1.mint(depositor, amountToken1DepositorInit); + token2.mint(depositor, amountToken2DepositorInit); + + // mint tokens to searcher A contract + token1.mint(address(searcherA), amountToken1AInit); + token2.mint(address(searcherA), amountToken2AInit); + + // mint tokens to searcher B contract + token1.mint(address(searcherB), amountToken1BInit); + token2.mint(address(searcherB), amountToken2BInit); + + // mint token 2 to the vault contract (to allow creation of initial vault with outstanding debt position) + token2.mint(address(tokenVault), amountToken2TokenVaultInit); + + // create token price feed IDs + idToken1 = bytes32(uint256(uint160(address(token1)))); + idToken2 = bytes32(uint256(uint160(address(token2)))); + + vm.warp(publishTimeInitial); + bytes[] memory updateData = new bytes[](2); + updateData[0] = mockPyth.createPriceFeedUpdateData( + idToken1, + token1PriceInitial, + token1ConfInitial, + tokenExpo, + token1PriceInitial, + token1ConfInitial, + publishTimeInitial, + prevPublishTimeInitial + ); + updateData[1] = mockPyth.createPriceFeedUpdateData( + idToken2, + token2PriceInitial, + token2ConfInitial, + tokenExpo, + token2PriceInitial, + token2ConfInitial, + publishTimeInitial, + prevPublishTimeInitial + ); + + mockPyth.updatePriceFeeds(updateData); + } + + /** + * @notice setUpVaults function - sets up the vaults for the test and stores relevant info per vault + */ + function setUpVaults() public { + // set which tokens are collateral and which are debt for each vault + tokensCollateral = new address[](2); + idsCollateral = new bytes32[](2); + tokensCollateral[0] = address(token1); + idsCollateral[0] = idToken1; + tokensCollateral[1] = address(token1); + idsCollateral[1] = idToken1; + + tokensDebt = new address[](2); + idsDebt = new bytes32[](2); + tokensDebt[0] = address(token2); + idsDebt[0] = idToken2; + tokensDebt[1] = address(token2); + idsDebt[1] = idToken2; + + amountsCollateral = new uint256[](2); + amountsCollateral[0] = 100; + amountsCollateral[1] = 200; + + amountsDebt = new uint256[](2); + amountsDebt[0] = 80; + amountsDebt[1] = 150; + + // create vault 0 + uint256 minCollatPERVault0 = 110 * healthPrecision; + uint256 minCollatPermissionlessVault0 = 100 * healthPrecision; + vm.prank(depositor, depositor); + MyToken(tokensCollateral[0]).approve( + address(tokenVault), + amountsCollateral[0] + ); + vm.prank(depositor, depositor); + tokenVault.createVault( + tokensCollateral[0], + tokensDebt[0], + amountsCollateral[0], + amountsDebt[0], + minCollatPERVault0, + minCollatPermissionlessVault0, + idsCollateral[0], + idsDebt[0], + new bytes[](0) + ); + + // create vault 1 + uint256 minCollatPERVault1 = 110 * healthPrecision; + uint256 minCollatPermissionlessVault1 = 100 * healthPrecision; + vm.prank(depositor, depositor); + MyToken(tokensCollateral[1]).approve( + address(tokenVault), + amountsCollateral[1] + ); + vm.prank(depositor, depositor); + tokenVault.createVault( + tokensCollateral[1], + tokensDebt[1], + amountsCollateral[1], + amountsDebt[1], + minCollatPERVault1, + minCollatPermissionlessVault1, + idsCollateral[1], + idsDebt[1], + new bytes[](0) + ); + + int64 priceCollateralVault0; + int64 priceCollateralVault1; + + if (tokensCollateral[0] == address(token1)) { + priceCollateralVault0 = token1PriceInitial; + } else { + priceCollateralVault0 = token2PriceInitial; + } + + int64 tokenDebtPriceLiqPermissionlessVault0; + int64 tokenDebtPriceLiqPERVault0; + int64 tokenDebtPriceLiqPermissionlessVault1; + int64 tokenDebtPriceLiqPERVault1; + + tokenDebtPriceLiqPermissionlessVault0 = getDebtLiquidationPrice( + amountsCollateral[0], + amountsDebt[0], + minCollatPermissionlessVault0, + healthPrecision, + priceCollateralVault0 + ); + + tokenDebtPriceLiqPERVault0 = getDebtLiquidationPrice( + amountsCollateral[0], + amountsDebt[0], + minCollatPERVault0, + healthPrecision, + priceCollateralVault0 + ); + + if (tokensCollateral[1] == address(token1)) { + priceCollateralVault1 = token1PriceInitial; + } else { + priceCollateralVault1 = token2PriceInitial; + } + + tokenDebtPriceLiqPermissionlessVault1 = getDebtLiquidationPrice( + amountsCollateral[1], + amountsDebt[1], + minCollatPermissionlessVault1, + healthPrecision, + priceCollateralVault1 + ); + + tokenDebtPriceLiqPERVault1 = getDebtLiquidationPrice( + amountsCollateral[1], + amountsDebt[1], + minCollatPERVault1, + healthPrecision, + priceCollateralVault1 + ); + + tokenDebtPricesLiqPER = new int64[](2); + tokenDebtPricesLiqPER[0] = tokenDebtPriceLiqPERVault0; + tokenDebtPricesLiqPER[1] = tokenDebtPriceLiqPERVault1; + + tokenDebtPricesLiqPermissionless = new int64[](2); + tokenDebtPricesLiqPermissionless[ + 0 + ] = tokenDebtPriceLiqPermissionlessVault0; + tokenDebtPricesLiqPermissionless[ + 1 + ] = tokenDebtPriceLiqPermissionlessVault1; + } + + /** + * @notice fundSearcherWallets function - funds the searcher wallets with Eth, tokens, and allowances + * + * Funding enables searchers' wallets to directly liquidate via the liquidation adapter + */ + function fundSearcherWallets() public { + // fund searcher A and searcher B + vm.deal(address(searcherA), 1 ether); + vm.deal(address(searcherB), 1 ether); + + address[] memory searchers = new address[](2); + searchers[0] = address(searcherAOwnerAddress); + searchers[1] = address(searcherBOwnerAddress); + + for (uint256 i = 0; i < searchers.length; i++) { + address searcher = searchers[i]; + + // mint tokens to searcher wallet so it can liquidate vaults + MyToken(tokensDebt[0]).mint(address(searcher), amountsDebt[0]); + MyToken(tokensDebt[1]).mint(address(searcher), amountsDebt[1]); + + vm.startPrank(searcher, searcher); + + // create allowance for liquidation adapter + if (tokensDebt[0] == tokensDebt[1]) { + MyToken(tokensDebt[0]).approve( + address(liquidationAdapter), + amountsDebt[0] + amountsDebt[1] + ); + } else { + MyToken(tokensDebt[0]).approve( + address(liquidationAdapter), + amountsDebt[0] + ); + MyToken(tokensDebt[1]).approve( + address(liquidationAdapter), + amountsDebt[1] + ); + } + + // deposit eth into the weth contract + vm.deal(searcher, (i + 1) * 100 ether); + weth.deposit{value: (i + 1) * 100 ether}(); + + // create allowance for liquidation adapter (weth) + weth.approve(address(liquidationAdapter), (i + 1) * 100 ether); + + vm.stopPrank(); + } + + // fast forward to enable price updates in the below tests + vm.warp(publishTimeInitial + 100); + } + + /** + * @notice getMulticallInfoSearcherContracts function - creates necessary permission and data for multicall to searcher contracts + */ + function getMulticallInfoSearcherContracts( + uint256 vaultNumber, + BidInfo[] memory bidInfos + ) public returns (bytes memory permission, bytes[] memory data) { + vm.roll(2); + + // get permission key + permission = abi.encode( + address(tokenVault), + abi.encodePacked(vaultNumber) + ); + + // raise price of debt token to make vault undercollateralized + bytes memory tokenDebtUpdateData = createPriceFeedUpdateSimple( + mockPyth, + idsDebt[vaultNumber], + tokenDebtPricesLiqPER[vaultNumber], + tokenExpo + ); + + data = new bytes[](bidInfos.length); + + for (uint i = 0; i < bidInfos.length; i++) { + // create searcher signature + bytes memory signatureSearcher = createSearcherSignature( + vaultNumber, + bidInfos[i].bid, + bidInfos[i].validUntil, + bidInfos[i].liquidatorSk + ); + data[i] = abi.encodeWithSelector( + searcherA.doLiquidate.selector, + vaultNumber, + bidInfos[i].bid, + bidInfos[i].validUntil, + tokenDebtUpdateData, + signatureSearcher + ); + } + } + + /** + * @notice getMulticallInfoLiquidationAdapter function - creates necessary permission and data for multicall to liquidation adapter contract + */ + function getMulticallInfoLiquidationAdapter( + uint256 vaultNumber, + BidInfo[] memory bidInfos + ) public returns (bytes memory permission, bytes[] memory data) { + vm.roll(2); + + // get permission key + permission = abi.encode( + address(tokenVault), + abi.encodePacked(vaultNumber) + ); + + // raise price of debt token to make vault undercollateralized + bytes[] memory updateDatas = new bytes[](1); + updateDatas[0] = createPriceFeedUpdateSimple( + mockPyth, + idsDebt[vaultNumber], + tokenDebtPricesLiqPER[vaultNumber], + tokenExpo + ); + + TokenQty[] memory repayTokens = new TokenQty[](1); + repayTokens[0] = TokenQty( + tokensDebt[vaultNumber], + amountsDebt[vaultNumber] + ); + TokenQty[] memory expectedReceiptTokens = new TokenQty[](1); + expectedReceiptTokens[0] = TokenQty( + tokensCollateral[vaultNumber], + amountsCollateral[vaultNumber] + ); + + bytes memory calldataVault = abi.encodeWithSelector( + tokenVault.liquidateWithPriceUpdate.selector, + vaultNumber, + updateDatas + ); + + uint256 value = 0; + address contractAddress = address(tokenVault); + + data = new bytes[](bidInfos.length); + + for (uint i = 0; i < bidInfos.length; i++) { + // create liquidation call params struct + bytes memory signatureLiquidator = createLiquidationSignature( + repayTokens, + expectedReceiptTokens, + contractAddress, + calldataVault, + value, + bidInfos[i].bid, + bidInfos[i].validUntil, + bidInfos[i].liquidatorSk + ); + LiquidationCallParams + memory liquidationCallParams = LiquidationCallParams( + repayTokens, + expectedReceiptTokens, + bidInfos[i].liquidator, + contractAddress, + calldataVault, + value, + bidInfos[i].validUntil, + bidInfos[i].bid, + signatureLiquidator + ); + + data[i] = abi.encodeWithSelector( + liquidationAdapter.callLiquidation.selector, + liquidationCallParams + ); + } + } + + /** + * @notice assertExpectedBidPayment function - checks that the expected bid payment is equal to the actual bid payment + */ + function assertExpectedBidPayment( + uint256 balancePre, + uint256 balancePost, + BidInfo[] memory bidInfos, + MulticallStatus[] memory multicallStatuses + ) public { + require( + bidInfos.length == multicallStatuses.length, + "bidInfos and multicallStatuses must have the same length" + ); + + uint256 totalBid = 0; + string memory emptyRevertReasonString = ""; + + for (uint i = 0; i < bidInfos.length; i++) { + bool externalSuccess = multicallStatuses[i].externalSuccess; + bool emptyRevertReason = compareStrings( + multicallStatuses[i].multicallRevertReason, + emptyRevertReasonString + ); + + if (externalSuccess && emptyRevertReason) { + totalBid += + (bidInfos[i].bid * feeSplitTokenVault) / + feeSplitPrecisionTokenVault; + } + } + + assertEq(balancePost, balancePre + totalBid); + } + + function testLiquidateNoPER() public { + uint vaultNumber = 0; + // test permissionless liquidation (success) + // raise price of debt token to make vault 0 undercollateralized + bytes memory tokenDebtUpdateData = createPriceFeedUpdateSimple( + mockPyth, + idsDebt[vaultNumber], + tokenDebtPricesLiqPermissionless[vaultNumber], + tokenExpo + ); + + bytes memory signatureSearcher; + + uint256 validUntil = 1_000_000_000_000; + + AccountBalance memory balancesAPre = getBalances( + address(searcherA), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + vm.prank(searcherAOwnerAddress, searcherAOwnerAddress); + searcherA.doLiquidate( + 0, + 0, + validUntil, + tokenDebtUpdateData, + signatureSearcher + ); + + AccountBalance memory balancesAPost = getBalances( + address(searcherA), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + assertEq( + balancesAPost.collateral, + balancesAPre.collateral + amountsCollateral[vaultNumber] + ); + assertEq( + balancesAPost.debt, + balancesAPre.debt - amountsDebt[vaultNumber] + ); + } + + function testLiquidateNoPERFail() public { + uint vaultNumber = 0; + // test permissionless liquidation (failure) + // raise price of debt token to make vault 0 undercollateralized + bytes memory tokenDebtUpdateData = createPriceFeedUpdateSimple( + mockPyth, + idsDebt[vaultNumber], + tokenDebtPricesLiqPER[vaultNumber], + tokenExpo + ); + + bytes memory signatureSearcher; + + uint256 validUntil = 1_000_000_000_000; + + vm.expectRevert(abi.encodeWithSelector(InvalidLiquidation.selector)); + vm.prank(searcherAOwnerAddress, searcherAOwnerAddress); + searcherA.doLiquidate( + 0, + 0, + validUntil, + tokenDebtUpdateData, + signatureSearcher + ); + } + + function testLiquidateSingle() public { + // test PER path liquidation (via multicall, per operator calls) with searcher contract + uint256 vaultNumber = 0; + + address[] memory contracts = new address[](1); + BidInfo[] memory bidInfos = new BidInfo[](1); + + contracts[0] = address(searcherA); + bidInfos[0] = makeBidInfo(15, searcherAOwnerSk); + + ( + bytes memory permission, + bytes[] memory data + ) = getMulticallInfoSearcherContracts(vaultNumber, bidInfos); + + uint256 balanceProtocolPre = address(tokenVault).balance; + AccountBalance memory balancesAPre = getBalances( + address(searcherA), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + vm.prank(perOperatorAddress, perOperatorAddress); + MulticallStatus[] memory multicallStatuses = multicall.multicall( + permission, + contracts, + data, + extractBidAmounts(bidInfos) + ); + + uint256 balanceProtocolPost = address(tokenVault).balance; + AccountBalance memory balancesAPost = getBalances( + address(searcherA), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + assertEq( + balancesAPost.collateral, + balancesAPre.collateral + amountsCollateral[vaultNumber] + ); + assertEq( + balancesAPost.debt, + balancesAPre.debt - amountsDebt[vaultNumber] + ); + + assertEq(multicallStatuses[0].externalSuccess, true); + + assertExpectedBidPayment( + balanceProtocolPre, + balanceProtocolPost, + bidInfos, + multicallStatuses + ); + } + + /** + * @notice Test a multicall with two calls, where the second is expected to fail + * + * The first call should succeed and liquidate the vault. The second should therefore fail, bc the vault is already liquidated. + */ + function testLiquidateMultipleFailSecond() public { + uint256 vaultNumber = 0; + + address[] memory contracts = new address[](2); + BidInfo[] memory bidInfos = new BidInfo[](2); + + contracts[0] = address(searcherA); + bidInfos[0] = makeBidInfo(15, searcherAOwnerSk); + + contracts[1] = address(searcherB); + bidInfos[1] = makeBidInfo(10, searcherAOwnerSk); + + ( + bytes memory permission, + bytes[] memory data + ) = getMulticallInfoSearcherContracts(vaultNumber, bidInfos); + + uint256 balanceProtocolPre = address(tokenVault).balance; + AccountBalance memory balancesAPre = getBalances( + address(searcherA), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + AccountBalance memory balancesBPre = getBalances( + address(searcherB), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + vm.prank(perOperatorAddress, perOperatorAddress); + MulticallStatus[] memory multicallStatuses = multicall.multicall( + permission, + contracts, + data, + extractBidAmounts(bidInfos) + ); + uint256 balanceProtocolPost = address(tokenVault).balance; + AccountBalance memory balancesAPost = getBalances( + address(searcherA), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + AccountBalance memory balancesBPost = getBalances( + address(searcherB), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + assertEq( + balancesAPost.collateral, + balancesAPre.collateral + amountsCollateral[vaultNumber] + ); + assertEq( + balancesAPost.debt, + balancesAPre.debt - amountsDebt[vaultNumber] + ); + + assertEq(balancesBPost.collateral, balancesBPre.collateral); + assertEq(balancesBPost.debt, balancesBPre.debt); + + logMulticallStatuses(multicallStatuses); + + // only the first bid should be paid + assertExpectedBidPayment( + balanceProtocolPre, + balanceProtocolPost, + bidInfos, + multicallStatuses + ); + } + + /** + * @notice Test a multicall with two calls, where the first is expected to fail + * + * The first call should fail, bc the searcher contract has no Eth to pay the PER operator. The second should therefore succeed in liquidating the vault. + */ + function testLiquidateMultipleFailFirst() public { + uint256 vaultNumber = 0; + + address[] memory contracts = new address[](2); + BidInfo[] memory bidInfos = new BidInfo[](2); + + contracts[0] = address(searcherA); + bidInfos[0] = makeBidInfo(15, searcherAOwnerSk); + contracts[1] = address(searcherB); + bidInfos[1] = makeBidInfo(10, searcherBOwnerSk); + + ( + bytes memory permission, + bytes[] memory data + ) = getMulticallInfoSearcherContracts(vaultNumber, bidInfos); + + uint256 balanceProtocolPre = address(tokenVault).balance; + AccountBalance memory balancesAPre = getBalances( + address(searcherA), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + AccountBalance memory balancesBPre = getBalances( + address(searcherB), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + // drain searcherA contract of Eth, so that the first liquidation fails + vm.prank(searcherAOwnerAddress, searcherAOwnerAddress); + searcherA.withdrawEth(address(searcherA).balance); + + vm.prank(perOperatorAddress, perOperatorAddress); + MulticallStatus[] memory multicallStatuses = multicall.multicall( + permission, + contracts, + data, + extractBidAmounts(bidInfos) + ); + + uint256 balanceProtocolPost = address(tokenVault).balance; + + AccountBalance memory balancesAPost = getBalances( + address(searcherA), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + AccountBalance memory balancesBPost = getBalances( + address(searcherB), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + assertEq(balancesAPost.collateral, balancesAPre.collateral); + assertEq(balancesAPost.debt, balancesAPre.debt); + + assertEq( + balancesBPost.collateral, + balancesBPre.collateral + amountsCollateral[vaultNumber] + ); + assertEq( + balancesBPost.debt, + balancesBPre.debt - amountsDebt[vaultNumber] + ); + + logMulticallStatuses(multicallStatuses); + + // only the second bid should be paid + assertExpectedBidPayment( + balanceProtocolPre, + balanceProtocolPost, + bidInfos, + multicallStatuses + ); + } + + function testLiquidateWrongPermission() public { + uint256 vaultNumber = 0; + + address[] memory contracts = new address[](1); + BidInfo[] memory bidInfos = new BidInfo[](1); + + contracts[0] = address(searcherA); + bidInfos[0] = makeBidInfo(15, searcherAOwnerSk); + + ( + bytes memory permission, + bytes[] memory data + ) = getMulticallInfoSearcherContracts(vaultNumber, bidInfos); + + // wrong permisison key + permission = abi.encode(address(0)); + + AccountBalance memory balancesAPre = getBalances( + address(searcherA), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + vm.prank(perOperatorAddress, perOperatorAddress); + MulticallStatus[] memory multicallStatuses = multicall.multicall( + permission, + contracts, + data, + extractBidAmounts(bidInfos) + ); + + AccountBalance memory balancesAPost = getBalances( + address(searcherA), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + assertEq(balancesAPost.collateral, balancesAPre.collateral); + assertEq(balancesAPost.debt, balancesAPre.debt); + + assertFailedExternal(multicallStatuses[0], "InvalidLiquidation()"); + } + + function testLiquidateMismatchedBid() public { + uint256 vaultNumber = 0; + + address[] memory contracts = new address[](1); + BidInfo[] memory bidInfos = new BidInfo[](1); + + contracts[0] = address(searcherA); + bidInfos[0] = makeBidInfo(15, searcherAOwnerSk); + + ( + bytes memory permission, + bytes[] memory data + ) = getMulticallInfoSearcherContracts(vaultNumber, bidInfos); + + // mismatched bid--multicall expects higher bid than what is paid out by the searcher + bidInfos[0].bid = bidInfos[0].bid + 1; + + AccountBalance memory balancesAPre = getBalances( + address(searcherA), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + vm.prank(perOperatorAddress, perOperatorAddress); + MulticallStatus[] memory multicallStatuses = multicall.multicall( + permission, + contracts, + data, + extractBidAmounts(bidInfos) + ); + + AccountBalance memory balancesAPost = getBalances( + address(searcherA), + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + assertEq(balancesAPost.collateral, balancesAPre.collateral); + assertEq(balancesAPost.debt, balancesAPre.debt); + + assertFailedMulticall(multicallStatuses[0], "invalid bid"); + } + + function testLiquidateLiquidationAdapter() public { + uint256 vaultNumber = 0; + + address[] memory contracts = new address[](1); + BidInfo[] memory bidInfos = new BidInfo[](1); + + contracts[0] = address(liquidationAdapter); + bidInfos[0] = makeBidInfo(15, searcherAOwnerSk); + + ( + bytes memory permission, + bytes[] memory data + ) = getMulticallInfoLiquidationAdapter(vaultNumber, bidInfos); + + AccountBalance memory balancesAPre = getBalances( + searcherAOwnerAddress, + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + uint256 balanceProtocolPre = address(tokenVault).balance; + + vm.prank(perOperatorAddress, perOperatorAddress); + MulticallStatus[] memory multicallStatuses = multicall.multicall( + permission, + contracts, + data, + extractBidAmounts(bidInfos) + ); + + uint256 balanceProtocolPost = address(tokenVault).balance; + + AccountBalance memory balancesAPost = getBalances( + searcherAOwnerAddress, + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + assertEq( + balancesAPost.collateral, + balancesAPre.collateral + amountsCollateral[vaultNumber] + ); + assertEq( + balancesAPost.debt, + balancesAPre.debt - amountsDebt[vaultNumber] + ); + + assertEq(multicallStatuses[0].externalSuccess, true); + + assertExpectedBidPayment( + balanceProtocolPre, + balanceProtocolPost, + bidInfos, + multicallStatuses + ); + } + + function testLiquidateLiquidationAdapterFailInvalidSignature() public { + uint256 vaultNumber = 0; + + address[] memory contracts = new address[](1); + BidInfo[] memory bidInfos = new BidInfo[](1); + + contracts[0] = address(liquidationAdapter); + bidInfos[0] = makeBidInfo(15, searcherBOwnerSk); + bidInfos[0].liquidator = searcherAOwnerAddress; // use wrong liquidator address to induce invalid signature + + ( + bytes memory permission, + bytes[] memory data + ) = getMulticallInfoLiquidationAdapter(vaultNumber, bidInfos); + + AccountBalance memory balancesAPre = getBalances( + searcherAOwnerAddress, + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + uint256 balanceProtocolPre = address(tokenVault).balance; + + vm.prank(perOperatorAddress, perOperatorAddress); + MulticallStatus[] memory multicallStatuses = multicall.multicall( + permission, + contracts, + data, + extractBidAmounts(bidInfos) + ); + + AccountBalance memory balancesAPost = getBalances( + searcherAOwnerAddress, + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + uint256 balanceProtocolPost = address(tokenVault).balance; + + assertEqBalances(balancesAPost, balancesAPre); + assertEq(balanceProtocolPre, balanceProtocolPost); + + assertFailedExternal( + multicallStatuses[0], + "InvalidSearcherSignature()" + ); + } + + function testLiquidateLiquidationAdapterFailExpiredSignature() public { + uint256 vaultNumber = 0; + + address[] memory contracts = new address[](1); + BidInfo[] memory bidInfos = new BidInfo[](1); + + contracts[0] = address(liquidationAdapter); + bidInfos[0] = makeBidInfo(15, searcherAOwnerSk); + bidInfos[0].validUntil = block.number - 1; // use old block number for the validUntil field to create expired signature + + ( + bytes memory permission, + bytes[] memory data + ) = getMulticallInfoLiquidationAdapter(vaultNumber, bidInfos); + + AccountBalance memory balancesAPre = getBalances( + searcherAOwnerAddress, + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + uint256 balanceProtocolPre = address(tokenVault).balance; + + vm.prank(perOperatorAddress, perOperatorAddress); + MulticallStatus[] memory multicallStatuses = multicall.multicall( + permission, + contracts, + data, + extractBidAmounts(bidInfos) + ); + + AccountBalance memory balancesAPost = getBalances( + searcherAOwnerAddress, + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + uint256 balanceProtocolPost = address(tokenVault).balance; + + assertEqBalances(balancesAPost, balancesAPre); + assertEq(balanceProtocolPre, balanceProtocolPost); + assertFailedExternal(multicallStatuses[0], "ExpiredSignature()"); + } + + /** + * @notice Test a multicall with two calls to liquidate the same vault, where the second is expected to fail + * + * The second call should fail with the expected error message, bc the vault is already liquidated. + */ + function testLiquidateLiquidationAdapterFailLiquidationCall() public { + uint256 vaultNumber = 0; + + address[] memory contracts = new address[](2); + BidInfo[] memory bidInfos = new BidInfo[](2); + + contracts[0] = address(liquidationAdapter); + contracts[1] = address(liquidationAdapter); + bidInfos[0] = makeBidInfo(15, searcherAOwnerSk); + bidInfos[1] = makeBidInfo(10, searcherBOwnerSk); + + ( + bytes memory permission, + bytes[] memory data + ) = getMulticallInfoLiquidationAdapter(vaultNumber, bidInfos); + + AccountBalance memory balancesAPre = getBalances( + searcherAOwnerAddress, + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + AccountBalance memory balancesBPre = getBalances( + searcherBOwnerAddress, + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + vm.prank(perOperatorAddress, perOperatorAddress); + MulticallStatus[] memory multicallStatuses = multicall.multicall( + permission, + contracts, + data, + extractBidAmounts(bidInfos) + ); + + AccountBalance memory balancesAPost = getBalances( + searcherAOwnerAddress, + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + AccountBalance memory balancesBPost = getBalances( + searcherBOwnerAddress, + tokensCollateral[vaultNumber], + tokensDebt[vaultNumber] + ); + + assertEq( + balancesAPost.collateral, + balancesAPre.collateral + amountsCollateral[vaultNumber] + ); + assertEq( + balancesAPost.debt, + balancesAPre.debt - amountsDebt[vaultNumber] + ); + assertEqBalances(balancesBPost, balancesBPre); + + assertEq(multicallStatuses[0].externalSuccess, true); + assertFailedExternal( + multicallStatuses[1], + "LiquidationCallFailed(string)" + ); + } +} diff --git a/per_multicall/test/PERVault.t.sol b/per_multicall/test/PERVault.t.sol deleted file mode 100644 index 1cfa654b..00000000 --- a/per_multicall/test/PERVault.t.sol +++ /dev/null @@ -1,674 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console2} from "forge-std/Test.sol"; -import "../src/SigVerify.sol"; -import "forge-std/console.sol"; -import "forge-std/StdMath.sol"; - -import {TokenVault} from "../src/TokenVault.sol"; -import {SearcherVault} from "../src/SearcherVault.sol"; -import {PERMulticall} from "../src/PERMulticall.sol"; -import {WETH9} from "../src/WETH9.sol"; -import {LiquidationAdapter} from "../src/LiquidationAdapter.sol"; -import {MyToken} from "../src/MyToken.sol"; -import "../src/Structs.sol"; - -import "@pythnetwork/pyth-sdk-solidity/MockPyth.sol"; - -import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; -import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; - -import "openzeppelin-contracts/contracts/utils/Strings.sol"; - -import "../src/Errors.sol"; - -import "./helpers/Signatures.sol"; - -contract PERVaultTest is Test, Signatures { - TokenVault public tokenVault; - SearcherVault public searcherA; - SearcherVault public searcherB; - PERMulticall public multicall; - WETH9 public weth; - LiquidationAdapter public liquidationAdapter; - MockPyth public mockPyth; - - MyToken public token1; - MyToken public token2; - - bytes32 _idToken1; - bytes32 _idToken2; - - address _perOperatorAddress; - uint256 _perOperatorSk; // address public immutable _perOperatorAddress = address(88); - address _searcherAOwnerAddress; - uint256 _searcherAOwnerSk; - address _searcherBOwnerAddress; - uint256 _searcherBOwnerSk; - address _tokenVaultDeployer; - uint256 _tokenVaultDeployerSk; - - uint256 public healthPrecision = 10 ** 16; - - address _depositor = address(44); - - uint256 _q1Depositor; - uint256 _q2Depositor; - uint256 _q1A; - uint256 _q2A; - uint256 _q1B; - uint256 _q2B; - uint256 _q2TokenVault; - uint256 _q1Vault0; - uint256 _q2Vault0; - uint256 _q1Vault1; - uint256 _q2Vault1; - - uint256 _defaultFeeSplitProtocol; - - uint256 _feeSplitTokenVault; - uint256 _feeSplitPrecisionTokenVault = 10 ** 18; - - uint256 _signaturePerVersionNumber = 0; - - function setUp() public { - // make PER operator wallet - (_perOperatorAddress, _perOperatorSk) = makeAddrAndKey("perOperator"); - console.log("pk per operator", _perOperatorSk); - - _defaultFeeSplitProtocol = 50 * 10 ** 16; - - // instantiate multicall contract with PER operator as sender/origin - vm.prank(_perOperatorAddress, _perOperatorAddress); - multicall = new PERMulticall( - _perOperatorAddress, - _defaultFeeSplitProtocol - ); - - // instantiate weth contract - vm.prank(_perOperatorAddress, _perOperatorAddress); - weth = new WETH9(); - - // instantiate liquidation adapter contract - vm.prank(_perOperatorAddress, _perOperatorAddress); - liquidationAdapter = new LiquidationAdapter( - address(multicall), - address(weth) - ); - - // make searcherA and searcherB wallets - (_searcherAOwnerAddress, _searcherAOwnerSk) = makeAddrAndKey( - "searcherA" - ); - (_searcherBOwnerAddress, _searcherBOwnerSk) = makeAddrAndKey( - "searcherB" - ); - console.log("pk searcherA", _searcherAOwnerSk); - console.log("pk searcherB", _searcherBOwnerSk); - - (_tokenVaultDeployer, _tokenVaultDeployerSk) = makeAddrAndKey( - "tokenVaultDeployer" - ); - console.log("pk token vault deployer", _tokenVaultDeployerSk); - - // instantiate mock pyth contract - vm.prank(_perOperatorAddress, _perOperatorAddress); - mockPyth = new MockPyth(1_000_000, 0); - - // instantiate token vault contract - vm.prank(_tokenVaultDeployer, _tokenVaultDeployer); // we prank here to standardize the value of the token contract address across different runs - tokenVault = new TokenVault(address(multicall), address(mockPyth)); - console.log("contract of token vault is", address(tokenVault)); - _feeSplitTokenVault = _defaultFeeSplitProtocol; - - // instantiate searcher A's contract with searcher A as sender/origin - vm.prank(_searcherAOwnerAddress, _searcherAOwnerAddress); - searcherA = new SearcherVault(address(multicall), address(tokenVault)); - console.log("contract of searcher A is", address(searcherA)); - - // instantiate searcher B's contract with searcher B as sender/origin - vm.prank(_searcherBOwnerAddress, _searcherBOwnerAddress); - searcherB = new SearcherVault(address(multicall), address(tokenVault)); - console.log("contract of searcher B is", address(searcherB)); - - // instantiate ERC-20 tokens - vm.prank(_perOperatorAddress, _perOperatorAddress); - token1 = new MyToken("token1", "T1"); - vm.prank(_perOperatorAddress, _perOperatorAddress); - token2 = new MyToken("token2", "T2"); - console.log("contract of token1 is", address(token1)); - console.log("contract of token2 is", address(token2)); - - _q1Depositor = 1_000_000; - _q2Depositor = 1_000_000; - _q1A = 2_000_000; - _q2A = 2_000_000; - _q1B = 3_000_000; - _q2B = 3_000_000; - _q2TokenVault = 500_000; - - // mint tokens to the _depositor address - token1.mint(_depositor, _q1Depositor); - token2.mint(_depositor, _q2Depositor); - - // mint tokens to searcher A contract - token1.mint(address(searcherA), _q1A); - token2.mint(address(searcherA), _q2A); - - // mint tokens to searcher B contract - token1.mint(address(searcherB), _q1B); - token2.mint(address(searcherB), _q2B); - - // mint token 2 to the vault contract (to allow creation of initial vault with outstanding debt position) - token2.mint(address(tokenVault), _q2TokenVault); - - // create token price feed IDs - _idToken1 = bytes32(uint256(uint160(address(token1)))); - _idToken2 = bytes32(uint256(uint160(address(token2)))); - - // set initial oracle prices - int64 token1Price = 100; - uint64 token1Conf = 1; - int32 token1Expo = 0; - - int64 token2Price = 100; - uint64 token2Conf = 1; - int32 token2Expo = 0; - - uint64 publishTime = 1_000_000; - uint64 prevPublishTime = 0; - - vm.warp(publishTime); - bytes memory token1UpdateData = mockPyth.createPriceFeedUpdateData( - _idToken1, - token1Price, - token1Conf, - token1Expo, - token1Price, - token1Conf, - publishTime, - prevPublishTime - ); - bytes memory token2UpdateData = mockPyth.createPriceFeedUpdateData( - _idToken2, - token2Price, - token2Conf, - token2Expo, - token2Price, - token2Conf, - publishTime, - prevPublishTime - ); - - bytes[] memory updateData = new bytes[](2); - - updateData[0] = token1UpdateData; - updateData[1] = token2UpdateData; - - mockPyth.updatePriceFeeds(updateData); - - // create vault 0 - _q1Vault0 = 100; - _q2Vault0 = 80; - vm.prank(_depositor, _depositor); - token1.approve(address(tokenVault), _q1Vault0); - vm.prank(_depositor, _depositor); - tokenVault.createVault( - address(token1), - address(token2), - _q1Vault0, - _q2Vault0, - 110 * healthPrecision, - 100 * healthPrecision, - _idToken1, - _idToken2, - new bytes[](0) - ); - _q1Depositor -= _q1Vault0; - _q2Depositor += _q2Vault0; - - // create vault 1 - _q1Vault1 = 200; - _q2Vault1 = 150; - vm.prank(_depositor, _depositor); - token1.approve(address(tokenVault), _q1Vault1); - vm.prank(_depositor, _depositor); - tokenVault.createVault( - address(token1), - address(token2), - _q1Vault1, - _q2Vault1, - 110 * healthPrecision, - 100 * healthPrecision, - _idToken1, - _idToken2, - new bytes[](0) - ); - _q1Depositor -= _q1Vault0; - _q2Depositor += _q2Vault0; - - // fund searcher A and searcher B - vm.deal(address(searcherA), 1 ether); - vm.deal(address(searcherB), 1 ether); - - // fast forward to enable price updates in the below tests - vm.warp(publishTime + 100); - } - - function testLiquidate() public { - // test slow path liquidation - // raise price of token 2 to make vault 0 undercollateralized, delayed oracle feed - bytes memory token2UpdateData = mockPyth.createPriceFeedUpdateData( - _idToken2, - 200, - 1, - 0, - 200, - 1, - uint64(block.timestamp), - 0 - ); - bytes memory signatureSearcher; - - uint256 validUntil = 1_000_000_000_000; - - vm.prank(_searcherAOwnerAddress, _searcherAOwnerAddress); - searcherA.doLiquidate( - 0, - 0, - validUntil, - token2UpdateData, - signatureSearcher - ); - assertEq(token1.balanceOf(address(searcherA)), _q1A + _q1Vault0); - assertEq(token2.balanceOf(address(searcherA)), _q2A - _q2Vault0); - } - - function testLiquidatePERSingleContract() public { - // test PER path liquidation (via multicall, per operator calls) with searcher contract - uint256 bid = 15; - uint256 validUntil = 1_000_000_000_000; // TODO: need a test for historical validUntil values - - vm.roll(2); - - uint256 vaultNumber = 0; - - // get permission key - bytes memory permission = abi.encode( - address(tokenVault), - abi.encodePacked(vaultNumber) - ); - - // create searcher signature - bytes memory signatureSearcher = createSearcherSignature( - vaultNumber, - bid, - validUntil, - _searcherAOwnerSk - ); - - address[] memory contracts = new address[](1); - bytes[] memory data = new bytes[](1); - uint256[] memory bids = new uint256[](1); - address[] memory protocols = new address[](1); - - // raise price of token 2 to make vault 0 undercollateralized, fast oracle feed - bytes memory token2UpdateData = mockPyth.createPriceFeedUpdateData( - _idToken2, - 200, - 1, - 0, - 200, - 1, - uint64(block.timestamp), - 0 - ); - - contracts[0] = address(searcherA); - data[0] = abi.encodeWithSignature( - "doLiquidate(uint256,uint256,uint256,bytes,bytes)", - 0, - bid, - validUntil, - token2UpdateData, - signatureSearcher - ); - bids[0] = bid; - protocols[0] = address(tokenVault); - - uint256 balanceProtocolPre = address(tokenVault).balance; - - vm.prank(_perOperatorAddress, _perOperatorAddress); - MulticallStatus[] memory multicallStatuses = multicall.multicall( - permission, - contracts, - data, - bids - ); - - uint256 balanceProtocolPost = address(tokenVault).balance; - - assertEq(token1.balanceOf(address(searcherA)), _q1A + _q1Vault0); - assertEq(token2.balanceOf(address(searcherA)), _q2A - _q2Vault0); - - console.log("Success"); - console.log(multicallStatuses[0].externalSuccess); - console.log("Result"); - console.logBytes(multicallStatuses[0].externalResult); - console.log("Revert reason"); - console.log(multicallStatuses[0].multicallRevertReason); - - assertEq( - balanceProtocolPost - balanceProtocolPre, - (bid * _feeSplitTokenVault) / _feeSplitPrecisionTokenVault - ); - } - - // function testLiquidateFastWrongContractAuction() public { - // // test fast path liquidation (via multicall, per operator calls) - // uint256 bid = 10; - - // uint256 vaultNumber = 0; - - // // create searcher signature - // bytes memory signatureSearcher = abi.encodePacked(vaultNumber, bid, block.number, _searcherAOwnerSk); - - // // create PER signature, for the wrong contract address - // bytes memory signaturePer = createPerSignature(_signaturePerVersionNumber, address(4444), block.number, _perOperatorSk); - - // address[] memory contracts = new address[](1); - // bytes[] memory data = new bytes[](1); - // uint256[] memory bids = new uint256[](1); - // address[] memory protocols = new address[](1); - - // // raise price of token 2 to make vault 0 undercollateralized, fast oracle feed - // bytes memory token2UpdateData = mockPyth.createPriceFeedUpdateData(_idToken2, 200, 1, 0, 200, 1, uint64(block.timestamp), 0); - - // contracts[0] = address(searcherA); - // data[0] = abi.encodeWithSignature("doLiquidatePER(bytes,uint256,bytes,uint256,bytes)", signaturePer, 0, signatureSearcher, bid, token2UpdateData); - // bids[0] = bid; - // protocols[0] = address(tokenVault); - - // vm.prank(_perOperatorAddress, _perOperatorAddress); - // (,, string[] memory multicallRevertReasons) = multicall.multicall(contracts, data, bids, protocols); - - // assertEq(token1.balanceOf(address(searcherA)), _q1A); - // assertEq(token2.balanceOf(address(searcherA)), _q2A); - - // assertEq(multicallRevertReasons[0], "invalid signature"); // there should be a revert error msg bc the PER signature is invalid - // } - - // function testLiquidateFastWrongFunctionSignature() public { - // // test fast path liquidation (via multicall, per operator calls) - // uint256 bid = 10; - - // uint256 vaultNumber = 0; - - // // create searcher signature - // bytes memory signatureSearcher = createSearcherSignature(vaultNumber, bid, block.number, _searcherAOwnerSk); - - // // create PER signature - // bytes memory signaturePer = createPerSignature(_signaturePerVersionNumber, address(tokenVault), block.number, _perOperatorSk); - - // address[] memory contracts = new address[](1); - // bytes[] memory data = new bytes[](1); - // uint256[] memory bids = new uint256[](1); - // address[] memory protocols = new address[](1); - - // // raise price of token 2 to make vault 0 undercollateralized, fast oracle feed - // bytes memory token2UpdateData = mockPyth.createPriceFeedUpdateData(_idToken2, 200, 1, 0, 200, 1, uint64(block.timestamp), 0); - - // contracts[0] = address(searcherA); - // data[0] = abi.encodeWithSignature("fakeFunctionSignature(bytes,uint256,bytes,uint256,bytes)", signaturePer, 0, signatureSearcher, bid, token2UpdateData); - // bids[0] = bid; - // protocols[0] = address(tokenVault); - - // vm.prank(_perOperatorAddress, _perOperatorAddress); - // (bool[] memory externalSuccess, bytes[] memory externalResults, string[] memory multicallRevertReasons) = multicall.multicall(contracts, data, bids, protocols); - - // assertEq(token1.balanceOf(address(searcherA)), _q1A); - // assertEq(token2.balanceOf(address(searcherA)), _q2A); - - // console.logBytes(externalResults[0]); - // console.log("multi revert reason", multicallRevertReasons[0]); - - // assert(!externalSuccess[0]); - // assertEq(externalResults[0], abi.encodePacked(hex"")); // there should be no external failure reason bc this function signature is invalid - // } - - // function testLiquidateFastMultiple() public { - // // test fast path liquidation on multiple vaults - // uint256 bid0 = 10; - // uint256 bid1 = 20; - - // uint256 vaultNumber0 = 0; - // uint256 vaultNumber1 = 1; - - // // create searcher signature - // bytes memory signatureSearcher0 = createSearcherSignature(vaultNumber0, bid0, block.number, _searcherAOwnerSk); - - // // create searcher signature - // bytes memory signatureSearcher1 = createSearcherSignature(vaultNumber1, bid1, block.number, _searcherBOwnerSk); - - // // create PER signature - // bytes memory signaturePer = createPerSignature(_signaturePerVersionNumber, address(tokenVault), block.number, _perOperatorSk); - - // bytes memory token2UpdateData0 = mockPyth.createPriceFeedUpdateData(_idToken2, 200, 1, 0, 200, 1, uint64(block.timestamp-1), 0); - // bytes memory token2UpdateData1 = mockPyth.createPriceFeedUpdateData(_idToken2, 220, 1, 0, 220, 1, uint64(block.timestamp), 0); - - // address[] memory contracts = new address[](2); - // bytes[] memory data = new bytes[](2); - // uint256[] memory bids = new uint256[](2); - // address[] memory protocols = new address[](2); - - // contracts[0] = address(searcherA); - // contracts[1] = address(searcherB); - // data[0] = abi.encodeWithSignature("doLiquidatePER(bytes,uint256,bytes,uint256,bytes)", signaturePer, 0, signatureSearcher0, bid0, token2UpdateData0); - // data[1] = abi.encodeWithSignature("doLiquidatePER(bytes,uint256,bytes,uint256,bytes)", signaturePer, 1, signatureSearcher1, bid1, token2UpdateData1); - // bids[0] = bid0; - // bids[1] = bid1; - // protocols[0] = address(tokenVault); - // protocols[1] = address(tokenVault); - - // uint256 balanceProtocolPre = address(tokenVault).balance; - - // vm.prank(_perOperatorAddress, _perOperatorAddress); - // multicall.multicall(contracts, data, bids, protocols); - - // uint256 balanceProtocolPost = address(tokenVault).balance; - - // uint256 token1AAfter = token1.balanceOf(address(searcherA)); - // uint256 token2AAfter = token2.balanceOf(address(searcherA)); - // assertEq(token1AAfter, _q1A + _q1Vault0); - // assertEq(token2AAfter, _q2A - _q2Vault0); - - // uint256 token1BAfter = token1.balanceOf(address(searcherB)); - // uint256 token2BAfter = token2.balanceOf(address(searcherB)); - // assertEq(token1BAfter, _q1B + _q1Vault1); - // assertEq(token2BAfter, _q2B - _q2Vault1); - - // assertEq(balanceProtocolPost - balanceProtocolPre, bid0 * _feeSplitTokenVault / _feeSplitPrecisionTokenVault + bid1 * _feeSplitTokenVault / _feeSplitPrecisionTokenVault); - // } - - // function testLiquidateFastMultipleWithFail() public { - // // test fast path liquidation on multiple vaults, with the second one failing due to earlier tx in the block that recollateralizes the vault - // uint256 bid0 = 10; - // uint256 bid1 = 30; - - // uint256 vaultNumber0 = 0; - // uint256 vaultNumber1 = 1; - - // // create searcher signature - // bytes memory signatureSearcher0 = createSearcherSignature(vaultNumber0, bid0, block.number, _searcherAOwnerSk); - - // // create searcher signature - // bytes memory signatureSearcher1 = createSearcherSignature(vaultNumber1, bid1, block.number, _searcherBOwnerSk); - - // // create PER signature - // bytes memory signaturePer = createPerSignature(_signaturePerVersionNumber, address(tokenVault), block.number, _perOperatorSk); - - // bytes memory token2UpdateData0 = mockPyth.createPriceFeedUpdateData(_idToken2, 200, 1, 0, 200, 1, uint64(block.timestamp-1), 0); - // bytes memory token2UpdateData1 = mockPyth.createPriceFeedUpdateData(_idToken2, 220, 1, 0, 200, 1, uint64(block.timestamp), 0); - - // address[] memory contracts = new address[](2); - // bytes[] memory data = new bytes[](2); - // uint256[] memory bids = new uint256[](2); - // address[] memory protocols = new address[](2); - - // contracts[0] = address(searcherA); - // contracts[1] = address(searcherB); - // data[0] = abi.encodeWithSignature("doLiquidatePER(bytes,uint256,bytes,uint256,bytes)", signaturePer, 0, signatureSearcher0, bid0, token2UpdateData0); - // data[1] = abi.encodeWithSignature("doLiquidatePER(bytes,uint256,bytes,uint256,bytes)", signaturePer, 1, signatureSearcher1, bid1, token2UpdateData1); - // bids[0] = bid0; - // bids[1] = bid1; - // protocols[0] = address(tokenVault); - // protocols[1] = address(tokenVault); - - // // frontrun in the block with an update to vault 1 - // int256 deltaCollateral = int256(_q1Vault1 / 2); - // int256 deltaDebt = -1 * int256(_q2Vault1 / 2); - // vm.prank(_depositor, _depositor); - // token1.approve(address(tokenVault), stdMath.abs(deltaCollateral)); - // vm.prank(_depositor, _depositor); - // token2.approve(address(tokenVault), stdMath.abs(deltaDebt)); - // vm.prank(_depositor, _depositor); - // tokenVault.updateVault(1, deltaCollateral, deltaDebt); - - // vm.prank(_perOperatorAddress, _perOperatorAddress); - // (bool[] memory externalSuccess, bytes[] memory externalResults, string[] memory multicallRevertReasons) = multicall.multicall(contracts, data, bids, protocols); - - // assertEq(token1.balanceOf(address(searcherA)), _q1A + _q1Vault0); - // assertEq(token2.balanceOf(address(searcherA)), _q2A - _q2Vault0); - - // assertEq(token1.balanceOf(address(searcherB)), _q1B); - // assertEq(token2.balanceOf(address(searcherB)), _q2B); - - // assert(externalSuccess[0]); - // assert(!externalSuccess[1]); // this should be false bc searcher contract call failed - - // assertEq(externalResults[0], abi.encodePacked(hex"")); - // assertNotEq0(externalResults[1], abi.encodePacked(hex"")); // there should be a revert error code bc searcher contract call failed - - // assertEq(multicallRevertReasons[0], ""); - // assertEq(multicallRevertReasons[1], ""); - // } - - // function testLiquidateFastMultipleWithSecondFalseBid() public { - // // test fast path liquidation on multiple vaults, with the second one failing due to searcher not meeting bid condition - // uint256 bid0 = 10; - // uint256 bid1 = 30; - - // uint256 vaultNumber0 = 0; - // uint256 vaultNumber1 = 1; - - // // create searcher signature - // bytes memory signatureSearcher0 = createSearcherSignature(vaultNumber0, bid0, block.number, _searcherAOwnerSk); - - // // create searcher signature - // bytes memory signatureSearcher1 = createSearcherSignature(vaultNumber1, bid1, block.number, _searcherBOwnerSk); - - // // create PER signature - // bytes memory signaturePer = createPerSignature(_signaturePerVersionNumber, address(tokenVault), block.number, _perOperatorSk); - - // bytes memory token2UpdateData0 = mockPyth.createPriceFeedUpdateData(_idToken2, 200, 1, 0, 200, 1, uint64(block.timestamp-1), 0); - // bytes memory token2UpdateData1 = mockPyth.createPriceFeedUpdateData(_idToken2, 200, 1, 0, 200, 1, uint64(block.timestamp), 0); - - // address[] memory contracts = new address[](2); - // bytes[] memory data = new bytes[](2); - // uint256[] memory bids = new uint256[](2); - // address[] memory protocols = new address[](2); - - // contracts[0] = address(searcherA); - // contracts[1] = address(searcherB); - // data[0] = abi.encodeWithSignature("doLiquidatePER(bytes,uint256,bytes,uint256,bytes)", signaturePer, 0, signatureSearcher0, bid0, token2UpdateData0); - // data[1] = abi.encodeWithSignature("doLiquidatePER(bytes,uint256,bytes,uint256,bytes)", signaturePer, 1, signatureSearcher1, bid1, token2UpdateData1); - // bids[0] = bid0; - // bids[1] = bid1+1; // actual promised bid was 1 wei higher than what searcher pays--should fail - // protocols[0] = address(tokenVault); - // protocols[1] = address(tokenVault); - - // vm.prank(_perOperatorAddress, _perOperatorAddress); - // (, bytes[] memory externalResults, string[] memory multicallRevertReasons) = multicall.multicall(contracts, data, bids, protocols); - - // uint256[] memory tokensAfter = new uint256[](4); - // tokensAfter[0] = token1.balanceOf(address(searcherA)); - // tokensAfter[1] = token2.balanceOf(address(searcherA)); - // tokensAfter[2] = token1.balanceOf(address(searcherB)); - // tokensAfter[3] = token2.balanceOf(address(searcherB)); - - // assertEq(tokensAfter[0], _q1A + _q1Vault0); - // assertEq(tokensAfter[1], _q2A - _q2Vault0); - - // assertEq(tokensAfter[2], _q1B); - // assertEq(tokensAfter[3], _q2B); - - // assertEq(externalResults[0], abi.encodePacked(hex"")); - // assertEq(externalResults[1], abi.encodePacked(hex"")); - - // assertEq(multicallRevertReasons[0], ""); - // assertEq(multicallRevertReasons[1], "invalid bid"); // searcher B's tx should fail bc payment amount doesn't match bid - // } - - // function testLiquidateFastInputFromEnvironVars() public { - // // test fast path liquidation with arbitrary calls, checking expected behavior - // // use environment variables to store the relevant inputs and expected outputs - // string memory delimiter = ","; - - // // read in bundle contracts - // string memory keyContracts = "PERBUNDLE_contracts"; - // address[] memory contracts = vm.envAddress(keyContracts, delimiter); - - // // read in bundle calldata - // string memory keyData = "PERBUNDLE_data"; - // bytes[] memory data = vm.envBytes(keyData, delimiter); - - // // read in bundle bids - // string memory keyBids = "PERBUNDLE_bids"; - // uint256[] memory bids = vm.envUint(keyBids, delimiter); - - // // read in bundle protocols - // string memory keyProtocols = "PERBUNDLE_protocols"; - // address[] memory protocols = vm.envAddress(keyProtocols, delimiter); - - // // read in block number - // string memory keyBlockNumber = "PERBUNDLE_blockNumber"; - // uint256 blockNumber = vm.envUint(keyBlockNumber); - - // // roll to the block number specified in environ vars - // vm.roll(blockNumber); - - // console.log("vault token 1 balance before:", token1.balanceOf(address(tokenVault))); - // console.log("vault token 2 balance before:", token2.balanceOf(address(tokenVault))); - - // console.log("searcher A token 1 balance before:", token1.balanceOf(address(searcherA))); - // console.log("searcher A token 2 balance before:", token2.balanceOf(address(searcherA))); - - // console.log("searcher B token 1 balance before:", token1.balanceOf(address(searcherB))); - // console.log("searcher B token 2 balance before:", token2.balanceOf(address(searcherB))); - - // // now run multicall on the payload - // vm.prank(_perOperatorAddress, _perOperatorAddress); - // (bool[] memory externalSuccess, bytes[] memory externalResults, string[] memory multicallRevertReasons) = multicall.multicall(contracts, data, bids, protocols); - - // console.log("vault token 1 balance after:", token1.balanceOf(address(tokenVault))); - // console.log("vault token 2 balance after:", token2.balanceOf(address(tokenVault))); - - // console.log("searcher A token 1 balance after:", token1.balanceOf(address(searcherA))); - // console.log("searcher A token 2 balance after:", token2.balanceOf(address(searcherA))); - - // console.log("searcher B token 1 balance after:", token1.balanceOf(address(searcherB))); - // console.log("searcher B token 2 balance after:", token2.balanceOf(address(searcherB))); - - // for (uint i = 0; i < data.length; ++i) { - // console.log("success call %d", i); - // console.log(externalSuccess[i]); - - // console.log("result call %d:", i); - // console.logBytes(externalResults[i]); - - // console.log("revert reason call %d:", i); - // console.log(multicallRevertReasons[i]); - // } - // } -} diff --git a/per_multicall/test/helpers/MulticallHelpers.sol b/per_multicall/test/helpers/MulticallHelpers.sol new file mode 100644 index 00000000..5dbe20c2 --- /dev/null +++ b/per_multicall/test/helpers/MulticallHelpers.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console2} from "forge-std/Test.sol"; +import "forge-std/console.sol"; + +import "./TestParsingHelpers.sol"; + +contract MulticallHelpers is Test, TestParsingHelpers { + function assertFailedMulticall( + MulticallStatus memory status, + string memory reason + ) internal { + // assert the multicall revert reason matches the expected reason + assertEq(status.multicallRevertReason, reason); + } + + function assertFailedExternal( + MulticallStatus memory status, + string memory reason + ) internal { + assertEq( + abi.encodePacked(bytes4(status.externalResult)), + keccakHash(reason) + ); + } + + function logMulticallStatuses( + MulticallStatus[] memory multicallStatuses + ) internal view { + for (uint256 i = 0; i < multicallStatuses.length; i++) { + console.log("External Success:"); + console.log(multicallStatuses[i].externalSuccess); + console.log("External Result:"); + console.logBytes(multicallStatuses[i].externalResult); + console.log("Multicall Revert reason:"); + console.log(multicallStatuses[i].multicallRevertReason); + console.log("----------------------------"); + } + } +} diff --git a/per_multicall/test/helpers/PriceHelpers.sol b/per_multicall/test/helpers/PriceHelpers.sol new file mode 100644 index 00000000..dd53bffc --- /dev/null +++ b/per_multicall/test/helpers/PriceHelpers.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import "@pythnetwork/pyth-sdk-solidity/MockPyth.sol"; + +contract PriceHelpers { + function getDebtLiquidationPrice( + uint256 amountCollateral, + uint256 amountDebt, + uint256 thresholdHealthRatio, + uint256 healthPrecision, + int64 priceCollateral + ) public pure returns (int64) { + return + int64( + uint64( + (amountCollateral * + uint256(uint64(priceCollateral)) * + 100 * + healthPrecision) / + (amountDebt * thresholdHealthRatio) + + 1 + ) + ); + } + + function getCollateralLiquidationPrice( + uint256 amountCollateral, + uint256 amountDebt, + uint256 thresholdHealthRatio, + uint256 healthPrecision, + int64 priceDebt + ) public pure returns (int64) { + return + int64( + uint64( + (amountDebt * + uint256(uint64(priceDebt)) * + thresholdHealthRatio) / + (amountCollateral * 100 * healthPrecision) - + 1 + ) + ); + } + + function createPriceFeedUpdateSimple( + MockPyth mockPyth, + bytes32 id, + int64 price, + int32 expo + ) public view returns (bytes memory) { + return + mockPyth.createPriceFeedUpdateData( + id, + price, + 1, // bogus confidence + expo, + 1, // bogus ema price + 1, // bogus ema confidence + uint64(block.timestamp), + 0 // bogus previous timestamp + ); + } +} diff --git a/per_multicall/test/helpers/Signatures.sol b/per_multicall/test/helpers/Signatures.sol index 36c6d2b4..57cc2cbe 100644 --- a/per_multicall/test/helpers/Signatures.sol +++ b/per_multicall/test/helpers/Signatures.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.13; +import "../../src/Structs.sol"; import "../../src/SigVerify.sol"; import {Test} from "forge-std/Test.sol"; @@ -10,11 +11,12 @@ contract Signatures is Test, SigVerify { function createSearcherSignature( uint256 dataNumber, uint256 bid, - uint256 blockNumber, + uint256 validUntil, uint256 searcherSk ) public pure returns (bytes memory) { - bytes memory dataSearcher = abi.encodePacked(dataNumber, bid); - bytes32 calldataHash = getCalldataDigest(dataSearcher, blockNumber); + bytes32 calldataHash = keccak256( + abi.encode(dataNumber, bid, validUntil) + ); (uint8 vSearcher, bytes32 rSearcher, bytes32 sSearcher) = vm.sign( searcherSk, calldataHash @@ -22,24 +24,31 @@ contract Signatures is Test, SigVerify { return abi.encodePacked(rSearcher, sSearcher, vSearcher); } - function createPerSignature( - uint256 signaturePerVersionNumber, - address protocolAddress, - uint256 blockNumber, - uint256 perOperatorSk + function createLiquidationSignature( + TokenQty[] memory repayTokens, + TokenQty[] memory expectedReceiptTokens, + address contractAddress, + bytes memory data, + uint256 value, + uint256 bid, + uint256 validUntil, + uint256 liquidatorSk ) public pure returns (bytes memory) { - string memory messagePer = Strings.toHexString( - uint160(protocolAddress), - 20 - ); - bytes32 messageDigestPer = getMessageDigest(messagePer, blockNumber); - bytes32 signedMessageDigestPer = getPERSignedMessageDigest( - messageDigestPer + bytes32 calldataDigestLiquidator = keccak256( + abi.encode( + repayTokens, + expectedReceiptTokens, + contractAddress, + data, + value, + bid, + validUntil + ) ); - (uint8 vPer, bytes32 rPer, bytes32 sPer) = vm.sign( - perOperatorSk, - signedMessageDigestPer + (uint8 vLiquidator, bytes32 rLiquidator, bytes32 sLiquidator) = vm.sign( + liquidatorSk, + calldataDigestLiquidator ); - return abi.encodePacked(signaturePerVersionNumber, rPer, sPer, vPer); + return abi.encodePacked(rLiquidator, sLiquidator, vLiquidator); } } diff --git a/per_multicall/test/helpers/TestParsingHelpers.sol b/per_multicall/test/helpers/TestParsingHelpers.sol new file mode 100644 index 00000000..baab1d25 --- /dev/null +++ b/per_multicall/test/helpers/TestParsingHelpers.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test, console2} from "forge-std/Test.sol"; +import "forge-std/console.sol"; + +import {MyToken} from "../../src/MyToken.sol"; +import "../../src/Structs.sol"; + +contract TestParsingHelpers is Test { + struct AccountBalance { + uint256 collateral; + uint256 debt; + } + + function keccakHash( + string memory functionInterface + ) public pure returns (bytes memory) { + return abi.encodePacked(bytes4(keccak256(bytes(functionInterface)))); + } + + struct BidInfo { + uint256 bid; + uint256 validUntil; + address liquidator; + uint256 liquidatorSk; + } + + function extractBidAmounts( + BidInfo[] memory bids + ) public pure returns (uint256[] memory bidAmounts) { + bidAmounts = new uint256[](bids.length); + for (uint i = 0; i < bids.length; i++) { + bidAmounts[i] = bids[i].bid; + } + } + + function makeBidInfo( + uint256 bid, + uint256 liquidatorSk + ) internal pure returns (BidInfo memory) { + return + BidInfo( + bid, + 1_000_000_000_000, + vm.addr(liquidatorSk), + liquidatorSk + ); + } + + function assertEqBalances( + AccountBalance memory a, + AccountBalance memory b + ) internal { + assertEq(a.collateral, b.collateral); + assertEq(a.debt, b.debt); + } + + function getBalances( + address account, + address tokenCollateral, + address tokenDebt + ) public view returns (AccountBalance memory) { + return + AccountBalance( + MyToken(tokenCollateral).balanceOf(account), + MyToken(tokenDebt).balanceOf(account) + ); + } + + function compareStrings( + string memory a, + string memory b + ) public pure returns (bool) { + return keccak256(abi.encodePacked(a)) == keccak256(abi.encodePacked(b)); + } +} diff --git a/per_sdk/searcher/searcher_utils.py b/per_sdk/searcher/searcher_utils.py index 9bd5f752..0c8550e4 100644 --- a/per_sdk/searcher/searcher_utils.py +++ b/per_sdk/searcher/searcher_utils.py @@ -44,12 +44,19 @@ def construct_signature_liquidator( "bytes", "uint256", "uint256", + "uint256", + ], + [ + repay_tokens, + receipt_tokens, + address, + liq_calldata, + value, + bid_info["bid"], + bid_info["valid_until"], ], - [repay_tokens, receipt_tokens, address, liq_calldata, value, bid_info["bid"]], - ) - msg_data = web3.Web3.solidity_keccak( - ["bytes", "uint256"], [digest, bid_info["valid_until"]] ) + msg_data = web3.Web3.solidity_keccak(["bytes"], [digest]) signature = w3.eth.account.signHash(msg_data, private_key=secret_key) return signature