diff --git a/.gitmodules b/.gitmodules index d72d544..b83ed6d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,9 @@ [submodule "lib/forge-std"] path = lib/forge-std - url = git@github.com:foundry-rs/forge-std.git + url = https://github.com/foundry-rs/forge-std.git [submodule "lib/oev-contracts"] path = lib/oev-contracts - url = git@github.com:UMAprotocol/oev-contracts.git + url = https://github.com/UMAprotocol/oev-contracts.git +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/README.md b/README.md index efac030..9457853 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,27 @@ -#

Forge Template

-**Template repository for getting started quickly with Foundry projects** +#

OEV Share HoneyPot Demo

-![Github Actions](https://github.com/foundry-rs/forge-template/workflows/CI/badge.svg) +**This repository is a demonstration of the OEV Share system and a HoneyPot mechanism. It showcases how a backrunner can liquidate a position, in this particular case, how a HoneyPot can be emptied given a specific price change.** -## Getting Started - -Click "Use this template" on [GitHub](https://github.com/foundry-rs/forge-template) to create a new repository with this repo as the initial state. - -Or, if your repo already exists, run: -```sh -forge init -forge build -forge test -``` +![Github Actions](https://github.com/UMAprotocol/oev-demo/workflows/CI/badge.svg) -## Writing your first test +## Introduction -All you need is to `import forge-std/Test.sol` and then inherit it from your test contract. Forge-std's Test contract comes with a pre-instatiated [cheatcodes environment](https://book.getfoundry.sh/cheatcodes/), the `vm`. It also has support for [ds-test](https://book.getfoundry.sh/reference/ds-test.html)-style logs and assertions. Finally, it supports Hardhat's [console.log](https://github.com/brockelmore/forge-std/blob/master/src/console.sol). The logging functionalities require `-vvvv`. +The HoneyPot mechanism is a unique setup where funds are kept in a contract that is designed to be emptied out based on specific criteria, in this case, a change in the price from an oracle. -```solidity -pragma solidity 0.8.10; - -import "forge-std/Test.sol"; +## Getting Started -contract ContractTest is Test { - function testExample() public { - vm.roll(100); - console.log(1); - emit log("hi"); - assertTrue(true); - } -} +To test the demo run the following commands: +``` +forge install +export RPC_MAINNET=https://mainnet.infura.io/v3/ +forge test` ``` -## Development +## Contracts Overview -This project uses [Foundry](https://getfoundry.sh). See the [book](https://book.getfoundry.sh/getting-started/installation.html) for instructions on how to install and use Foundry. +- **HoneyPot**: Represents the honey pot, which can be emptied when a price oracle returns a value different from a pre-defined liquidation price. The honey pot's funds can also be reset by its owner. + +- **HoneyPotOEVShare**: Acts as the oracle which retrieves prices from various sources like Chainlink, Chronicle, and Pyth. + +- **Test Contract**: Sets up the environment, including simulating price changes and testing the mechanisms for creating and emptying the HoneyPot. \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 93d2364..16a3581 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,2 +1,11 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +solc = "0.8.17" + [profile.ci.fuzz] runs = 10_000 + +[rpc_endpoints] +mainnet = "${RPC_MAINNET}" \ No newline at end of file diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..e50c24f --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit e50c24f5839db17f46991478384bfda14acfb830 diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 0000000..2053123 --- /dev/null +++ b/remappings.txt @@ -0,0 +1,4 @@ +ds-test/=lib/forge-std/lib/ds-test/src/ +forge-std/=lib/forge-std/src/ +oev-contracts/=lib/oev-contracts/src/ +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ \ No newline at end of file diff --git a/src/Contract.sol b/src/Contract.sol deleted file mode 100644 index 45cf848..0000000 --- a/src/Contract.sol +++ /dev/null @@ -1,4 +0,0 @@ -// SPDX-License-Identifier: Unlicense -pragma solidity ^0.8.13; - -contract Contract { } diff --git a/src/HoneyPot.sol b/src/HoneyPot.sol new file mode 100644 index 0000000..8a3c88e --- /dev/null +++ b/src/HoneyPot.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {IAggregatorV3Source} from "oev-contracts/interfaces/chainlink/IAggregatorV3Source.sol"; + +contract HoneyPot is Ownable { + struct HoneyPotDetails { + int256 liquidationPrice; + uint256 balance; + } + + mapping(address => HoneyPotDetails) public honeyPots; + IAggregatorV3Source public oracle; // OEV Share serving as a Chainlink oracle + + event OracleUpdated(address indexed newOracle); + event HoneyPotCreated( + address indexed creator, + int256 liquidationPrice, + uint256 initialBalance + ); + event HoneyPotEmptied( + address indexed honeyPotCreator, + address indexed trigger, + uint256 amount + ); + event PotReset(address indexed owner, uint256 amount); + + constructor(IAggregatorV3Source _oracle) { + oracle = _oracle; + } + + function setOracle(IAggregatorV3Source _oracle) external onlyOwner { + oracle = _oracle; + emit OracleUpdated(address(_oracle)); + } + + function createHoneyPot(int256 _liquidationPrice) external payable { + require( + honeyPots[msg.sender].liquidationPrice == 0, + "Liquidation price already set for this user" + ); + require(_liquidationPrice > 0, "Liquidation price cannot be zero"); + + honeyPots[msg.sender].liquidationPrice = _liquidationPrice; + honeyPots[msg.sender].balance = msg.value; + + emit HoneyPotCreated(msg.sender, _liquidationPrice, msg.value); + } + + function _emptyPotForUser( + address honeyPotCreator, + address recipient + ) internal { + HoneyPotDetails storage userPot = honeyPots[honeyPotCreator]; + + uint256 amount = userPot.balance; + userPot.balance = 0; // reset the balance + userPot.liquidationPrice = 0; // reset the liquidation price + Address.sendValue(payable(recipient), amount); + } + + function emptyHoneyPot(address honeyPotCreator) external { + (, int256 currentPrice, , , ) = oracle.latestRoundData(); + require(currentPrice >= 0, "Invalid price from oracle"); + + HoneyPotDetails storage userPot = honeyPots[honeyPotCreator]; + + require( + currentPrice != userPot.liquidationPrice, + "Liquidation price reached for this user" + ); + + _emptyPotForUser(honeyPotCreator, msg.sender); + emit HoneyPotEmptied(honeyPotCreator, msg.sender, userPot.balance); + } + + function resetPot() external { + _emptyPotForUser(msg.sender, msg.sender); + emit PotReset(msg.sender, honeyPots[msg.sender].balance); + } +} diff --git a/src/HoneyPotOEVShare.sol b/src/HoneyPotOEVShare.sol new file mode 100644 index 0000000..997a4cb --- /dev/null +++ b/src/HoneyPotOEVShare.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {BoundedUnionSourceAdapter} from "oev-contracts/adapters/source-adapters/BoundedUnionSourceAdapter.sol"; +import {BaseController} from "oev-contracts/controllers/BaseController.sol"; +import {ChainlinkDestinationAdapter} from "oev-contracts/adapters/destination-adapters/ChainlinkDestinationAdapter.sol"; +import {IAggregatorV3Source} from "oev-contracts/interfaces/chainlink/IAggregatorV3Source.sol"; +import {IMedian} from "oev-contracts/interfaces/chronicle/IMedian.sol"; +import {IPyth} from "oev-contracts/interfaces/pyth/IPyth.sol"; + +contract HoneyPotOEVShare is + BaseController, + BoundedUnionSourceAdapter, + ChainlinkDestinationAdapter +{ + constructor( + address chainlinkSource, + address chronicleSource, + address pythSource, + bytes32 pythPriceId, + uint8 decimals + ) + BoundedUnionSourceAdapter( + IAggregatorV3Source(chainlinkSource), + IMedian(chronicleSource), + IPyth(pythSource), + pythPriceId, + 0.1e18 + ) + BaseController() + ChainlinkDestinationAdapter(decimals) + {} +} diff --git a/test/Common.sol b/test/Common.sol new file mode 100644 index 0000000..cc7995d --- /dev/null +++ b/test/Common.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {Test} from "forge-std/Test.sol"; + +contract CommonTest is Test { + address public constant owner = address(0x1); + address public constant permissionedUnlocker = address(0x2); + address public constant liquidator = address(0x3); + address public constant account1 = address(0x4); + address public constant account2 = address(0x5); + address public constant random = address(0x6); +} diff --git a/test/Contract.t.sol b/test/Contract.t.sol deleted file mode 100644 index d7d6076..0000000 --- a/test/Contract.t.sol +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: Unlicense -pragma solidity ^0.8.13; - -import "forge-std/Test.sol"; - -import "src/Contract.sol"; - -contract TestContract is Test { - Contract c; - - function setUp() public { - c = new Contract(); - } - - function testBar() public { - assertEq(uint256(1), uint256(1), "ok"); - } - - function testFoo(uint256 x) public { - vm.assume(x < type(uint128).max); - assertEq(x + x, x * 2); - } -} diff --git a/test/HoneyPot.sol b/test/HoneyPot.sol new file mode 100644 index 0000000..5d01bec --- /dev/null +++ b/test/HoneyPot.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.17; + +import {CommonTest} from "./Common.sol"; +import {IAggregatorV3Source} from "oev-contracts/interfaces/chainlink/IAggregatorV3Source.sol"; +import {IMedian} from "oev-contracts/interfaces/chronicle/IMedian.sol"; +import {IPyth} from "oev-contracts/interfaces/pyth/IPyth.sol"; + +import {HoneyPotOEVShare} from "../src/HoneyPotOEVShare.sol"; +import {HoneyPot} from "../src/HoneyPot.sol"; + +contract HoneyPotTest is CommonTest { + IAggregatorV3Source chainlink = + IAggregatorV3Source(0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419); + IMedian chronicle = IMedian(0x64DE91F5A373Cd4c28de3600cB34C7C6cE410C85); + IPyth pyth = IPyth(0x4305FB66699C3B2702D4d05CF36551390A4c69C6); + bytes32 pythPriceId = + 0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace; + + HoneyPotOEVShare oevShare; + HoneyPot honeyPot; + + uint256 public constant liquidationPrice = 0.1e18; + uint256 public honeyPotBalance = 1 ether; + + function setUp() public { + vm.createSelectFork("mainnet", 18419040); // Recent block on mainnet + oevShare = new HoneyPotOEVShare( + address(chainlink), + address(chronicle), + address(pyth), + pythPriceId, + 8 + ); + + honeyPot = new HoneyPot(IAggregatorV3Source(address(oevShare))); + _whitelistOnChronicle(); + oevShare.setUnlocker(address(this), true); + } + + receive() external payable {} + + function _whitelistOnChronicle() internal { + vm.startPrank(0xBE8E3e3618f7474F8cB1d074A26afFef007E98FB); // DSPause that is a ward (can add kiss to chronicle) + chronicle.kiss(address(oevShare)); + chronicle.kiss(address(this)); // So that we can read Chronicle directly. + vm.stopPrank(); + } + + function mockChainlinkPriceChange() public { + ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) = chainlink.latestRoundData(); + vm.mockCall( + address(chainlink), + abi.encodeWithSelector(chainlink.latestRoundData.selector), + abi.encode( + roundId + 1, + (answer * 103) / 100, // 3% increase + startedAt + 1, + updatedAt + 1, + answeredInRound + 1 + ) + ); + } + + function testHoneyPotCreationAndReset() public { + uint256 balanceBefore = address(this).balance; + + // Create HoneyPot for the caller + (, int256 latestAnswer, , , ) = oevShare.latestRoundData(); + honeyPot.createHoneyPot{value: honeyPotBalance}(latestAnswer); + + (, uint256 testhoneyPotBalance) = honeyPot.honeyPots(address(this)); + assertTrue(testhoneyPotBalance == honeyPotBalance); + assertTrue(address(this).balance == balanceBefore - honeyPotBalance); + + // Reset HoneyPot for the caller + honeyPot.resetPot(); + (, uint256 testhoneyPotBalanceReset) = honeyPot.honeyPots( + address(this) + ); + assertTrue(testhoneyPotBalanceReset == 0); + assertTrue(address(this).balance == balanceBefore); + } + + function testCrackHoneyPot() public { + // Create HoneyPot for the caller + (, int256 latestAnswer, , , ) = oevShare.latestRoundData(); + honeyPot.createHoneyPot{value: honeyPotBalance}( + latestAnswer + ); + (, uint256 testhoneyPotBalance) = honeyPot.honeyPots(address(this)); + assertTrue(testhoneyPotBalance == honeyPotBalance); + + vm.prank(liquidator); + vm.expectRevert("Liquidation price reached for this user"); + honeyPot.emptyHoneyPot(address(this)); // emptyHoneyPot now requires the creator's address + + // Simulate price change + mockChainlinkPriceChange(); + + // Unlock the latest value + oevShare.unlockLatestValue(); + + uint256 liquidatorBalanceBefore = liquidator.balance; + + vm.prank(liquidator); + honeyPot.emptyHoneyPot(address(this)); // emptyHoneyPot now requires the creator's address + + uint256 liquidatorBalanceAfter = liquidator.balance; + + assertTrue( + liquidatorBalanceAfter == liquidatorBalanceBefore + honeyPotBalance + ); + + // Create HoneyPot can be called again + (, int256 latestAnswerNew, , , ) = oevShare.latestRoundData(); + honeyPot.createHoneyPot{value: honeyPotBalance}(latestAnswerNew); + (, uint256 testhoneyPotBalanceTwo) = honeyPot.honeyPots(address(this)); + assertTrue(testhoneyPotBalanceTwo == honeyPotBalance); + } +}