diff --git a/.gitmodules b/.gitmodules index 80b9773..679065d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,3 +2,12 @@ path = lib/forge-std url = https://github.com/foundry-rs/forge-std branch = v1.5.2 +[submodule "lib/v3-periphery"] + path = lib/v3-periphery + url = https://github.com/Uniswap/v3-periphery +[submodule "lib/v3-core"] + path = lib/v3-core + url = https://github.com/Uniswap/v3-core +[submodule "lib/openzeppelin-contracts"] + path = lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/foundry.toml b/foundry.toml index e6810b2..3a32944 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,5 +2,6 @@ src = 'src' out = 'out' libs = ['lib'] +remappings=['@uniswap/v3-periphery/=lib/v3-periphery/', '@uniswap/v3-core/=lib/v3-core/', '@openzeppelin/=lib/openzeppelin-contracts/'] -# See more config options https://github.com/foundry-rs/foundry/tree/master/config \ No newline at end of file +# See more config options https://github.com/foundry-rs/foundry/tree/master/config diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts new file mode 160000 index 0000000..d00acef --- /dev/null +++ b/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit d00acef4059807535af0bd0dd0ddf619747a044b diff --git a/lib/v3-core b/lib/v3-core new file mode 160000 index 0000000..e3589b1 --- /dev/null +++ b/lib/v3-core @@ -0,0 +1 @@ +Subproject commit e3589b192d0be27e100cd0daaf6c97204fdb1899 diff --git a/lib/v3-periphery b/lib/v3-periphery new file mode 160000 index 0000000..80f26c8 --- /dev/null +++ b/lib/v3-periphery @@ -0,0 +1 @@ +Subproject commit 80f26c86c57b8a5e4b913f42844d4c8bd274d058 diff --git a/script/Deploy.s.sol b/script/Deploy.s.sol index a1dc166..a058161 100644 --- a/script/Deploy.s.sol +++ b/script/Deploy.s.sol @@ -11,7 +11,12 @@ contract BitSignalScript is Script { function run() public { vm.startBroadcast(); - BitSignal bitsignal = new BitSignal(address(0x1), address(0x2)); + BitSignal bitsignal = new BitSignal( + address(0x53Cfaa403a214c9be35011B3Dcfb75D81D2F7B6B), + address(0x83d47D101881A1E52Ae9C6A2272f499601b8fBCF), + 0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6, + 0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c + ); vm.stopBroadcast(); diff --git a/src/BitSignal.sol b/src/BitSignal.sol index d13cdf3..45eb9fd 100644 --- a/src/BitSignal.sol +++ b/src/BitSignal.sol @@ -1,12 +1,10 @@ // SPDX-License-Identifier: CC0-1.0 pragma solidity ^0.8.19; -interface ERC20 { - function approve(address spender, uint256 amount) external; - function transfer(address recipient, uint256 amount) external; - function transferFrom(address sender, address recipient, uint256 amount) external; - function balanceOf(address holder) external returns (uint256); -} +import '@uniswap/v3-periphery/contracts/interfaces/ISwapRouter.sol'; +import '@uniswap/v3-periphery/contracts/libraries/TransferHelper.sol'; +import '@openzeppelin/contracts/access/Ownable.sol'; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; interface AggregatorV3Interface { function decimals() external view returns (uint8); @@ -23,20 +21,28 @@ interface AggregatorV3Interface { ); } -contract BitSignal { +contract BitSignal is Ownable { + + event BetStarted(); + event BetSettled(address winner); uint256 constant BET_LENGTH = 90 days; uint256 constant PRICE_THRESHOLD = 1_000_000; // 1 million USD per BTC uint256 constant USDC_AMOUNT = 1_000_000e6; uint256 constant WBTC_AMOUNT = 1e8; + uint256 constant STABLECOIN_MIN_PRICE = 97000000; // if price drops below 97 cents consider it as a depeg and permit swap - ERC20 constant USDC = ERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); // 6 decimals - ERC20 constant WBTC = ERC20(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599); // 8 decimals + address constant UNISWAP_ROUTER = 0xE592427A0AEce92De3Edee1F18E0157C05861564; + address constant WETH_CONTRACT = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; + IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); // 6 decimals + IERC20 constant WBTC = IERC20(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599); // 8 decimals - AggregatorV3Interface priceFeed = AggregatorV3Interface(0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c); // 8 decimals + AggregatorV3Interface immutable btcPriceFeed; + AggregatorV3Interface immutable usdcPriceFeed; address public immutable balajis; address public immutable counterparty; + address public winner; bool internal usdcDeposited; bool internal wbtcDeposited; @@ -44,9 +50,63 @@ contract BitSignal { uint256 public startTimestamp; - constructor(address _balajis, address _counterparty) { + address[] STABLECOIN_CONTRACTS = [ + address(USDC), // Circle USDC + 0xdAC17F958D2ee523a2206206994597C13D831ec7, // Tether USDT + 0x4Fabb145d64652a948d72533023f6E7A623C7C53, // Binance BUSD + 0x8E870D67F660D95d5be530380D0eC0bd388289E1, // Paxos USDP + 0x6B175474E89094C44Da98b954EedeAC495271d0F // DAI stablecoin + ]; + + modifier swapAllowed(address[] calldata hops) { + // this function will be used only in case of emergency + // that`s why its better to consume more gas here due to search in array + // rather than building map in constructor + require(betInitiated, "bet is not initiated"); + uint256 usdcPrice = chainlinkPrice(usdcPriceFeed); + require(usdcPrice <= STABLECOIN_MIN_PRICE, "Collateral coin haven`t lost its peg"); + require(hops.length > 1, "Should be at leas one hoop"); + address token = hops[hops.length-1]; + bool found; + for (uint i=0; i<5; i++) { + if (STABLECOIN_CONTRACTS[i] == token) { + found = true; + } + } + require(found, "swap to choosen token is not allowed"); + _; + } + + constructor(address _balajis, address _counterparty, address _usdcPriceFeedAddress, address _btcPriceFeedAddress) Ownable() { balajis = _balajis; counterparty = _counterparty; + usdcPriceFeed = AggregatorV3Interface(_usdcPriceFeedAddress); // 8 decimals + btcPriceFeed = AggregatorV3Interface(_btcPriceFeedAddress); // 8 decimals + } + + function _encodePathV3(address[] calldata _hops, uint24[] calldata _fees) internal view returns (bytes memory path) { + require(_fees.length == _hops.length, "Wrong fees count"); + path = abi.encodePacked(address(USDC)); + for(uint i = 0; i < _hops.length; i++){ + path = abi.encodePacked(path, _fees[i], _hops[i]); + } + return path; + } + + /// @notice Let arbitor to swap collateral in case deposited stablecoin starts to loose it's peg + function swapCollateral(uint256 amountMinimum, address[] calldata hops, uint24[] calldata fees) external onlyOwner swapAllowed(hops) returns (uint256) { + ISwapRouter swapRouter = ISwapRouter(UNISWAP_ROUTER); + TransferHelper.safeApprove(address(USDC), UNISWAP_ROUTER, USDC_AMOUNT); + ISwapRouter.ExactInputParams memory params = + ISwapRouter.ExactInputParams({ + path: _encodePathV3(hops, fees), + recipient: address(this), + deadline: block.timestamp, + amountIn: USDC.balanceOf(address(this)), + amountOutMinimum: amountMinimum + }); + uint256 output = swapRouter.exactInput(params); + return output; } /// @notice Deposit USDC collateral. This initiates the bet if WBTC already deposited @@ -58,6 +118,7 @@ contract BitSignal { if (wbtcDeposited) { betInitiated = true; startTimestamp = block.timestamp; + emit BetStarted(); } } @@ -69,6 +130,7 @@ contract BitSignal { if (usdcDeposited) { betInitiated = true; + emit BetStarted(); startTimestamp = block.timestamp; } } @@ -88,6 +150,14 @@ contract BitSignal { } } + /// @notice This will let the winner to take out any token from contract + function claim(address token) external { + require(msg.sender == winner, "Imposter!"); + // any contract cound be passed as an input - looks as a security breach + // but there is no need for safety check - we already checked that sender is a winner and he should be allowed to do whatewer + SafeERC20.safeTransfer(IERC20(token), winner, IERC20(token).balanceOf(address(this))); + } + /// @notice Once 90 days have passed, query Chainlink BTC/USD price feed to determine the winner and send them both collaterals. function settle() external { require(betInitiated, "bet not initiated"); @@ -95,21 +165,19 @@ contract BitSignal { betInitiated = false; - uint256 wbtcPrice = chainlinkPrice() / 10**priceFeed.decimals(); + uint256 wbtcPrice = chainlinkPrice(btcPriceFeed) / 10**btcPriceFeed.decimals(); - address winner; if (wbtcPrice >= PRICE_THRESHOLD) { winner = balajis; } else { winner = counterparty; } - USDC.transfer(winner, USDC.balanceOf(address(this))); - WBTC.transfer(winner, WBTC.balanceOf(address(this))); + emit BetSettled(winner); } - /// @notice Fetch the BTCUSD price with 8 decimals included - function chainlinkPrice() public view returns (uint256) { + /// @notice Fetch the token price with 8 decimals included + function chainlinkPrice(AggregatorV3Interface priceFeed) public view returns (uint256) { ( /* uint80 roundID */, int price, diff --git a/test/BitSignal.t.sol b/test/BitSignal.t.sol index e16b7f6..4980aa0 100644 --- a/test/BitSignal.t.sol +++ b/test/BitSignal.t.sol @@ -2,44 +2,77 @@ pragma solidity ^0.8.19; import "forge-std/Test.sol"; -import {BitSignal, ERC20} from "../src/BitSignal.sol"; + +import "forge-std/interfaces/IERC20.sol"; +import {BitSignal, AggregatorV3Interface} from "../src/BitSignal.sol"; +import {MockPriceFeed} from "./MockPriceFeed.sol"; contract BigSignalTest is Test { BitSignal public bitsignal; - ERC20 constant USDC = ERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); // 6 decimals - ERC20 constant WBTC = ERC20(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599); // 8 decimals - address public balajis = address(0x1); - address public counterparty = address(0x2); - address usdcWhale = 0xF977814e90dA44bFA03b6295A0616a897441aceC; // arbitrary holder addresses chosen to seed our tests - address wbtcWhale = 0x6daB3bCbFb336b29d06B9C793AEF7eaA57888922; + MockPriceFeed public mockUsdcPriceFeed; + MockPriceFeed public mockBtcPriceFeed; + IERC20 constant USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); // 6 decimals + IERC20 constant WBTC = IERC20(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599); // 8 decimals + IERC20 constant USDT = IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7); + IERC20 constant WETH9 = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); + address public balajis = address(0x53Cfaa403a214c9be35011B3Dcfb75D81D2F7B6B); + address public counterparty = address(0x83d47D101881A1E52Ae9C6A2272f499601b8fBCF); + address public arbitor = address(0xc37B6361aff0A159Ebc4926285C9DDc8a9D0d1bA); + address usdcWhale = 0x0A59649758aa4d66E25f08Dd01271e891fe52199; // arbitrary holder addresses chosen to seed our tests + address wbtcWhale = 0x9ff58f4fFB29fA2266Ab25e75e2A8b3503311656; + address USDC_PRICE_FEED_ADDRESS = 0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6; + + uint24[] fees; + address[] hops; + function setUp() public { - bitsignal = new BitSignal(balajis, counterparty); + mockUsdcPriceFeed = new MockPriceFeed(8); + mockBtcPriceFeed = new MockPriceFeed(8); + vm.prank(arbitor); + fees.push(uint24(500)); + fees.push(uint24(3000)); + hops.push(address(WETH9)); + hops.push(address(USDT)); + bitsignal = new BitSignal(balajis, counterparty, address(mockUsdcPriceFeed), address(mockBtcPriceFeed)); } - function testSettle() public { - uint256 price = bitsignal.chainlinkPrice() / 1e8; - console2.log(price); + function testSettle() view public { + AggregatorV3Interface btcPriceFeed = AggregatorV3Interface(0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c); // 8 decimals + uint256 price = bitsignal.chainlinkPrice(btcPriceFeed) / 1e8; + console2.log("BTC price: %s", price); } - function testDepositAndInitiateBetAndSettle() public { - // Fund balajis and counterparty for test prep - vm.prank(usdcWhale); - USDC.transfer(balajis, 1_000_000e6); - vm.prank(wbtcWhale); - WBTC.transfer(counterparty, 1e8); + function fundParticipantWallets() private { + uint256 usdcWhaleBalance = USDC.balanceOf(usdcWhale); + console2.log('USDC whale balance: %d', usdcWhaleBalance); + uint256 wbtcWhaleBalance = WBTC.balanceOf(wbtcWhale); + console2.log('WBTC whale balance: %d', wbtcWhaleBalance); + // Fund balajis and counterparty for test prep + vm.prank(usdcWhale); + USDC.transfer(balajis, 1_000_000e6); + console2.logString('USDC been transferred'); + vm.prank(wbtcWhale); + WBTC.transfer(counterparty, 1e8); + console.logString('WBTC been transferred'); + } - // Now simulate the deposits - vm.startPrank(balajis); - USDC.approve(address(bitsignal), type(uint256).max); - bitsignal.depositUSDC(); - vm.stopPrank(); + function startBet() private { + // Now simulate the deposits + vm.startPrank(balajis); + USDC.approve(address(bitsignal), type(uint256).max); + bitsignal.depositUSDC(); + vm.stopPrank(); - vm.startPrank(counterparty); - WBTC.approve(address(bitsignal), type(uint256).max); - bitsignal.depositWBTC(); - vm.stopPrank(); + vm.startPrank(counterparty); + WBTC.approve(address(bitsignal), type(uint256).max); + bitsignal.depositWBTC(); + vm.stopPrank(); + } + function testDepositAndInitiateBetAndSettleWhenCounterpartyWins() public { + fundParticipantWallets(); + startBet(); // Prevent settlement before the bet has expired vm.startPrank(counterparty); vm.expectRevert("bet not finished"); @@ -50,19 +83,61 @@ contract BigSignalTest is Test { // Successfully settle after expiry vm.warp(block.timestamp + 100 days); + mockBtcPriceFeed.setAnswer(999_999 * 1e8); bitsignal.settle(); + // check that lost party cannot claim reward + vm.stopPrank(); + vm.startPrank(balajis); + vm.expectRevert("Imposter!"); + bitsignal.claim(address(USDC)); + vm.expectRevert("Imposter!"); + bitsignal.claim(address(WBTC)); + + // claim reward + vm.stopPrank(); + vm.startPrank(counterparty); + bitsignal.claim(address(USDC)); + bitsignal.claim(address(WBTC)); + // Check that winnings received assertEq(USDC.balanceOf(counterparty), usdcBeforeSettlement + 1_000_000e6); assertEq(WBTC.balanceOf(counterparty), wbtcBeforeSettlement + 1e8); } + function testDepositAndInitiateBetAndSettleWhenBalajisWins() public { + fundParticipantWallets(); + startBet(); + + uint256 usdcBeforeSettlement = USDC.balanceOf(balajis); + uint256 wbtcBeforeSettlement = WBTC.balanceOf(balajis); + + // Successfully settle after expiry + vm.warp(block.timestamp + 100 days); + mockBtcPriceFeed.setAnswer(1_000_001 * 1e8); + bitsignal.settle(); + + vm.startPrank(counterparty); + vm.expectRevert("Imposter!"); + bitsignal.claim(address(USDC)); + vm.expectRevert("Imposter!"); + bitsignal.claim(address(WBTC)); + + //claim reward + vm.stopPrank(); + vm.startPrank(balajis); + bitsignal.claim(address(USDC)); + bitsignal.claim(address(WBTC)); + + + // Check that winnings received + assertEq(USDC.balanceOf(balajis), usdcBeforeSettlement + 1_000_000e6); + assertEq(WBTC.balanceOf(balajis), wbtcBeforeSettlement + 1e8); + } + + function testDepositAndCancelBeforeInitiating() public { - // Fund balajis and counterparty for test prep - vm.prank(usdcWhale); - USDC.transfer(balajis, 1_000_000e6); - vm.prank(wbtcWhale); - WBTC.transfer(counterparty, 1e8); + fundParticipantWallets(); // Now simulate the deposits vm.startPrank(balajis); @@ -76,4 +151,134 @@ contract BigSignalTest is Test { vm.stopPrank(); } + function testSwap() public { + fundParticipantWallets(); + vm.prank(arbitor); + vm.expectRevert("bet is not initiated"); + bitsignal.swapCollateral(900_000e6, hops, fees); + + startBet(); + mockUsdcPriceFeed.setAnswer(99000000); + + vm.prank(arbitor); + vm.expectRevert("Collateral coin haven`t lost its peg"); + + bitsignal.swapCollateral(900_000e6, hops, fees); + + + mockUsdcPriceFeed.setAnswer(95000000); + vm.prank(balajis); + vm.expectRevert("Ownable: caller is not the owner"); + bitsignal.swapCollateral(900_000e6, hops, fees); + + vm.prank(arbitor); + vm.expectRevert("swap to choosen token is not allowed"); + hops.push(address(WBTC)); + fees.push(3000); + bitsignal.swapCollateral(900_000e6, hops, fees); + hops.pop(); + fees.pop(); + + vm.prank(arbitor); + uint256 swapOutput = bitsignal.swapCollateral(900_000e6, hops, fees); + + console2.log("SwapCollateral output: %d", swapOutput); + console2.log("USDC balance after swap: %d", USDC.balanceOf(address(bitsignal))); + console2.log("USDT balance after swap: %d", USDT.balanceOf(address(bitsignal))); + console2.log("balajis address: %s", balajis); + console2.log("counterparty address: %s", counterparty); + + uint256 usdtBeforeSettlement = USDT.balanceOf(counterparty); + uint256 wbtcBeforeSettlement = WBTC.balanceOf(counterparty); + + // Successfully settle after expiry + vm.warp(block.timestamp + 100 days); + mockBtcPriceFeed.setAnswer(999_999 * 1e8); + bitsignal.settle(); + + //claim reward + vm.startPrank(counterparty); + bitsignal.claim(address(USDT)); + bitsignal.claim(address(WBTC)); + + // Check that winnings received + assertEq(USDT.balanceOf(counterparty), usdtBeforeSettlement + swapOutput); + assertEq(WBTC.balanceOf(counterparty), wbtcBeforeSettlement + 1e8); + } + + function testClaimChange() public { + fundParticipantWallets(); + startBet(); + mockUsdcPriceFeed.setAnswer(95000000); + vm.prank(arbitor); + uint256 swapOutput = bitsignal.swapCollateral(900_000e6, hops, fees); + // simulate non-complete swap + uint256 change = 10_000*10**USDC.decimals(); + vm.prank(usdcWhale); + USDC.transfer(address(bitsignal), change); + + + uint256 usdtBeforeSettlement = USDT.balanceOf(counterparty); + uint256 usdcBeforeSettlement = USDC.balanceOf(counterparty); + uint256 wbtcBeforeSettlement = WBTC.balanceOf(counterparty); + + // Successfully settle after expiry + vm.warp(block.timestamp + 100 days); + mockBtcPriceFeed.setAnswer(999_999 * 1e8); + bitsignal.settle(); + + //claim reward + vm.startPrank(counterparty); + bitsignal.claim(address(USDC)); + bitsignal.claim(address(USDT)); + bitsignal.claim(address(WBTC)); + + + // Check that winnings received + assertEq(USDT.balanceOf(counterparty), usdtBeforeSettlement + swapOutput); + assertEq(USDC.balanceOf(counterparty), usdcBeforeSettlement + change); + assertEq(WBTC.balanceOf(counterparty), wbtcBeforeSettlement + 1e8); + + } + + function testOnRealPriceFeeds() public { + bitsignal = new BitSignal( + balajis, + counterparty, + address(0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6), + address(0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c) + ); + + AggregatorV3Interface btcPriceFeed = AggregatorV3Interface(0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c); // 8 decimals + AggregatorV3Interface usdcPriceFeed = AggregatorV3Interface(0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6); // 8 decimals + uint256 btcPrice = bitsignal.chainlinkPrice(btcPriceFeed) / 1e8; + uint256 usdcPrice = bitsignal.chainlinkPrice(usdcPriceFeed); + console2.log("BTC price: %d", btcPrice); + console2.log("USDC price: %d", usdcPrice); + fundParticipantWallets(); + startBet(); + + // Successfully settle after expiry + vm.warp(block.timestamp + 100 days); + bitsignal.settle(); + + if (bitsignal.winner() == balajis) { + vm.startPrank(balajis); + } else { + vm.startPrank(counterparty); + } + + //claim reward + bitsignal.claim(address(USDC)); + bitsignal.claim(address(USDT)); + bitsignal.claim(address(WBTC)); + + console2.log("WBTC balance of balajis: %d", WBTC.balanceOf(balajis) / 10**WBTC.decimals()); + console2.log("USDC balance of balajis: %d", USDC.balanceOf(balajis) / 10**USDC.decimals()); + console2.log("WBTC balance of counterparty: %d", WBTC.balanceOf(counterparty) / 10**WBTC.decimals()); + console2.log("USDC balance of counterparty: %d", USDC.balanceOf(counterparty) / 10**USDC.decimals()); + } + + + } diff --git a/test/MockPriceFeed.sol b/test/MockPriceFeed.sol new file mode 100644 index 0000000..ba76267 --- /dev/null +++ b/test/MockPriceFeed.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {AggregatorV3Interface} from '../src/BitSignal.sol'; + +contract MockPriceFeed is AggregatorV3Interface { + + int256 internal price; + uint8 decimalsNumber; + + constructor(uint8 _decimals) { + decimalsNumber = _decimals; + } + + function setAnswer(int256 _answer) external { + price = _answer; + } + + function decimals() external view returns (uint8) { + return decimalsNumber; + } + + function latestRoundData() + external + view + returns ( + uint80 roundId, + int256 answer, + uint256 startedAt, + uint256 updatedAt, + uint80 answeredInRound + ) { + return (0, price, 0, 0, 0); + } +}