├── remappings.txt ├── foundry.toml ├── .gitmodules ├── test ├── mock │ ├── ERC20M.sol │ └── Parameters.sol └── RDA.t.sol ├── LICENSE ├── README.md ├── src ├── interfaces │ └── IRDA.sol └── RDA.sol └── AUDIT.md /remappings.txt: -------------------------------------------------------------------------------- 1 | @openzeppelin/=lib/openzeppelin-contracts/contracts/ 2 | @root/=src/ 3 | 4 | forge-std/=lib/forge-std/src/ 5 | 6 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = 'src' 3 | out = 'out' 4 | libs = ['lib'] 5 | 6 | # See more config options https://github.com/foundry-rs/foundry/tree/master/config -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | branch = v1.4.0 5 | [submodule "lib/openzeppelin-contracts"] 6 | path = lib/openzeppelin-contracts 7 | url = https://github.com/OpenZeppelin/openzeppelin-contracts 8 | branch = v4.8.2 9 | -------------------------------------------------------------------------------- /test/mock/ERC20M.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.8.1; 2 | 3 | import "@openzeppelin/token/ERC20/ERC20.sol"; 4 | 5 | contract ERC20M is ERC20 { 6 | 7 | constructor(string memory name, string memory symbol) 8 | ERC20(name, symbol) 9 | public { } 10 | 11 | function mint(uint256 amount) public { 12 | _mint(msg.sender, amount); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /test/mock/Parameters.sol: -------------------------------------------------------------------------------- 1 | pragma solidity ^0.8.1; 2 | 3 | contract Parameters { 4 | uint256 constant TEST_PRIVATE_KEY_ONE = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; 5 | uint256 constant TEST_PRIVATE_KEY_TWO = 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d; 6 | address constant TEST_ADDRESS_ONE = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; 7 | address constant TEST_ADDRESS_TWO = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; 8 | 9 | uint256 constant AUCTION_DURATION = 7 days; 10 | uint256 constant AUCTION_WINDOW_DURATION = 2 hours; 11 | uint256 constant AUCTION_MINIMUM_PURCHASE = 1 ether; 12 | uint256 constant AUCTION_ORIGIN_PRICE = 4717280 gwei; 13 | uint256 constant AUCTION_RESERVES = 1000000000 ether; 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rolling Dutch Auction 2 | 3 | > Audited by [@pashovkrum]() | commit hash [cb27022597db95c4fd734356491bc0304e1e0721]() | [Report](./AUDIT.md) 4 | 5 | A Dutch auction derivative with composite decay. 6 | 7 | ## Overview 8 | 9 | Composite decay resets a point on the curve to it's current coordinates normalising the slope, making price decay inversely proportional to bid activity. This occurs whenever an auction "window" or "tranche" is initialised, which is when a participant submits a bid; which must be equal or greater than the current scalar price. 10 | 11 | ![image](https://i.imgur.com/uo1YECe.png) 12 | 13 | The Rolling Dutch auction introduces composite decay, meaning the price decay of an auction is reset to compliment a slower rate of decay proportional to bid activity. This occurs whenever an auction "window" or "tranche" is initialised, which is when a participant submits a bid; which must be equal or greater than the current scalar price. 14 | 15 | ## How It Works 16 | 17 | 1. **Composite Decay**: 18 | - Price decay resets on bids 19 | - Slower decay rate over time 20 | - Proportional to bid activity 21 | - Window-based pricing 22 | 23 | 2. **Bidding Windows**: 24 | - Activated by valid bids 25 | - Fixed duration per window 26 | - Reset on counter bids 27 | - Creates perpetual state 28 | 29 | 3. **Bid Requirements**: 30 | - Must meet/exceed scalar price 31 | - Must exceed previous bid price 32 | - Must meet/exceed previous volume 33 | 34 | ## Contributing 35 | 36 | @TODO 37 | 38 | ## License 39 | 40 | See [License](./LICENSE) 41 | -------------------------------------------------------------------------------- /src/interfaces/IRDA.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.13; 2 | 3 | interface IRDA { 4 | 5 | struct Auction { 6 | uint256 windowDuration; /* @dev Unix time window duration */ 7 | uint256 windowTimestamp; /* @dev Unix timestamp for window start */ 8 | uint256 startTimestamp; /* @dev Unix auction start timestamp */ 9 | uint256 endTimestamp; /* @dev Unix auction end timestamp */ 10 | uint256 duration; /* @dev Unix time auction duration */ 11 | uint256 proceeds; /* @dev Auction proceeds balance */ 12 | uint256 reserves; /* @dev Auction reserves balance */ 13 | uint256 price; /* @dev Auction origin price */ 14 | } 15 | 16 | struct Window { 17 | bytes bidId; /* @dev Bid identifier */ 18 | uint256 expiry; /* @dev Unix timestamp window exipration */ 19 | uint256 price; /* @dev Window price */ 20 | uint256 volume; /* @dev Window volume */ 21 | bool processed; /* @dev Window fuflfillment state */ 22 | } 23 | 24 | error InvalidPurchaseVolume(); 25 | 26 | error InvalidReserveVolume(); 27 | 28 | error InvalidWindowVolume(); 29 | 30 | error InvalidWindowPrice(); 31 | 32 | error InsufficientReserves(); 33 | 34 | error InvalidTokenDecimals(); 35 | 36 | error InvalidAuctionDurations(); 37 | 38 | error InvalidAuctionPrice(); 39 | 40 | error InvalidAuctionTimestamp(); 41 | 42 | error InvalidScalarPrice(); 43 | 44 | error WindowUnexpired(); 45 | 46 | error WindowFulfilled(); 47 | 48 | error AuctionExists(); 49 | 50 | error AuctionActive(); 51 | 52 | error AuctionInactive(); 53 | 54 | function createAuction( 55 | address operatorAddress, 56 | address reserveToken, 57 | address purchaseToken, 58 | uint256 reserveAmount, 59 | uint256 minimumPurchaseAmount, 60 | uint256 startingOriginPrice, 61 | uint256 startTimestamp, 62 | uint256 endTimestamp, 63 | uint256 windowDuration 64 | ) external returns (bytes memory); 65 | 66 | function commitBid(bytes calldata auctionId, uint256 price, uint256 volume) external returns (bytes memory); 67 | 68 | function fulfillWindow(bytes calldata auctionId, uint256 windowId) external; 69 | 70 | function withdraw(bytes calldata auctionId) external; 71 | 72 | function redeem(address bidder, bytes calldata auctionId) external; 73 | 74 | event NewAuction( 75 | bytes indexed auctionId, address reserveToken, uint256 reserves, uint256 price, uint256 endTimestamp 76 | ); 77 | 78 | event Offer(bytes indexed auctionId, address indexed owner, bytes indexed bidId, uint256 expiry); 79 | 80 | event Fulfillment(bytes indexed auctionId, bytes indexed bidId, uint256 windowId); 81 | 82 | event Expiration(bytes indexed auctionId, bytes indexed bidId, uint256 windowId); 83 | 84 | event Claim(bytes indexed auctionId, bytes indexed bidId); 85 | 86 | event Withdraw(bytes indexed auctionId); 87 | 88 | } 89 | -------------------------------------------------------------------------------- /test/RDA.t.sol: -------------------------------------------------------------------------------- 1 | pragma solidity 0.8.13; 2 | 3 | import "@root/RDA.sol"; 4 | import "forge-std/Test.sol"; 5 | 6 | import "./mock/Parameters.sol"; 7 | import "./mock/ERC20M.sol"; 8 | 9 | contract RDATest is Test, Parameters { 10 | address _auctionAddress; 11 | address _purchaseToken; 12 | address _reserveToken; 13 | 14 | bytes _auctionId; 15 | 16 | function setUp() public { 17 | vm.deal(TEST_ADDRESS_ONE, 1 ether); 18 | vm.deal(TEST_ADDRESS_TWO, 1 ether); 19 | 20 | _auctionAddress = address(new RDA()); 21 | _reserveToken = address(new ERC20M("COIN", "COIN")); 22 | _purchaseToken = address(new ERC20M("WETH", "WETH")); 23 | 24 | /* -------------OPERATOR------------ */ 25 | vm.startPrank(TEST_ADDRESS_ONE); 26 | 27 | ERC20M(_purchaseToken).mint(1 ether); 28 | ERC20M(_reserveToken).mint(AUCTION_RESERVES); 29 | _auctionId = createAuction(); 30 | 31 | vm.stopPrank(); 32 | /* --------------------------------- * 33 | 34 | /* -------------BIDDER-------------- */ 35 | vm.startPrank(TEST_ADDRESS_TWO); 36 | ERC20M(_purchaseToken).mint(100 ether); 37 | vm.stopPrank(); 38 | /* --------------------------------- */ 39 | } 40 | 41 | function testAuctionIdDecoding() public { 42 | require(RDA(_auctionAddress).operatorAddress(_auctionId) == TEST_ADDRESS_ONE); 43 | require(RDA(_auctionAddress).purchaseToken(_auctionId) == IERC20(_purchaseToken)); 44 | require(RDA(_auctionAddress).reserveToken(_auctionId) == IERC20(_reserveToken)); 45 | require(RDA(_auctionAddress).minimumPurchase(_auctionId) == AUCTION_MINIMUM_PURCHASE); 46 | } 47 | 48 | function testBidIdDecoding() public { 49 | /* -------------BIDDER------------ */ 50 | vm.startPrank(TEST_ADDRESS_TWO); 51 | 52 | uint256 scalarPrice = RDA(_auctionAddress).scalarPrice(_auctionId); 53 | 54 | bytes memory bidId = createBid(scalarPrice); 55 | 56 | (bytes memory auctionId, address biddingAddress, uint256 price, uint256 volume) = abi.decode(bidId, (bytes, address, uint256, uint256)); 57 | 58 | uint256 dustRemainder = 1 ether % scalarPrice; 59 | 60 | require(keccak256(auctionId) == keccak256(_auctionId)); 61 | require(biddingAddress == TEST_ADDRESS_TWO); 62 | require(volume == 1 ether - dustRemainder); 63 | require(price == scalarPrice); 64 | 65 | vm.stopPrank(); 66 | /* --------------------------------- */ 67 | } 68 | 69 | function testScalarPrice() public { 70 | vm.warp(block.timestamp + 6 days + 23 hours + 30 minutes); 71 | 72 | uint256 scalarPrice = RDA(_auctionAddress).scalarPrice(_auctionId); 73 | 74 | require(scalarPrice == 14039523809524); 75 | 76 | /* -------------BIDDER-------------- */ 77 | vm.startPrank(TEST_ADDRESS_TWO); 78 | bytes memory bidId = createBid(scalarPrice); 79 | vm.stopPrank(); 80 | /* --------------------------------- */ 81 | 82 | vm.warp(block.timestamp + AUCTION_WINDOW_DURATION + 20 minutes); 83 | 84 | uint256 windowScalarPrice = RDA(_auctionAddress).scalarPrice(_auctionId); 85 | 86 | require(windowScalarPrice == 4679841269842); 87 | } 88 | 89 | function testFuzz_scalarPrice(uint256 amount) public { 90 | vm.assume(amount < AUCTION_DURATION && amount > 1); 91 | vm.warp(block.timestamp + amount); 92 | 93 | uint256 scalarPrice = RDA(_auctionAddress).scalarPrice(_auctionId); 94 | 95 | /* -------------BIDDER-------------- */ 96 | vm.startPrank(TEST_ADDRESS_TWO); 97 | bytes memory bidId = createBid(scalarPrice); 98 | vm.stopPrank(); 99 | /* --------------------------------- */ 100 | 101 | vm.warp(block.timestamp + AUCTION_WINDOW_DURATION + 1); 102 | 103 | uint256 newScalarPrice = RDA(_auctionAddress).scalarPrice(_auctionId); 104 | 105 | /* -------------BIDDER-------------- */ 106 | vm.startPrank(TEST_ADDRESS_TWO); 107 | bidId = createBid(newScalarPrice); 108 | vm.stopPrank(); 109 | /* --------------------------------- */ 110 | 111 | uint256 remainingTime = RDA(_auctionAddress).remainingTime(_auctionId); 112 | 113 | vm.warp(block.timestamp + AUCTION_WINDOW_DURATION + remainingTime - 1); 114 | 115 | uint256 nextScalarPrice = RDA(_auctionAddress).scalarPrice(_auctionId); 116 | 117 | /* -------------BIDDER-------------- */ 118 | vm.startPrank(TEST_ADDRESS_TWO); 119 | bidId = createBid(nextScalarPrice); 120 | vm.stopPrank(); 121 | /* --------------------------------- */ 122 | } 123 | 124 | function testElapsedTime() public { 125 | vm.warp(block.timestamp + 1 days); 126 | 127 | uint256 scalarPrice = RDA(_auctionAddress).scalarPrice(_auctionId); 128 | 129 | /* -------------BIDDER-------------- */ 130 | vm.startPrank(TEST_ADDRESS_TWO); 131 | bytes memory bidId = createBid(scalarPrice); 132 | vm.stopPrank(); 133 | /* --------------------------------- */ 134 | 135 | vm.warp(block.timestamp + AUCTION_WINDOW_DURATION); 136 | 137 | uint256 remainingTime = RDA(_auctionAddress).elapsedTime(_auctionId); 138 | 139 | require(remainingTime == 1 days); 140 | } 141 | 142 | function testRemainingTime() public { 143 | uint256 remainingTime = RDA(_auctionAddress).remainingTime(_auctionId); 144 | uint256 elapsedTime = 3 hours + 14 minutes + 45 seconds; 145 | 146 | require(remainingTime == 7 days); 147 | 148 | vm.warp(block.timestamp + elapsedTime); 149 | 150 | uint256 scalarPrice = RDA(_auctionAddress).scalarPrice(_auctionId); 151 | 152 | /* -------------BIDDER-------------- */ 153 | vm.startPrank(TEST_ADDRESS_TWO); 154 | bytes memory bidId = createBid(scalarPrice); 155 | vm.stopPrank(); 156 | /* --------------------------------- */ 157 | 158 | uint256 elapsedWindowTime = 39 minutes + 7 seconds; 159 | 160 | vm.warp(block.timestamp + elapsedWindowTime); 161 | 162 | uint256 remainingWindowTime = RDA(_auctionAddress).remainingWindowTime(_auctionId); 163 | 164 | require(remainingWindowTime == AUCTION_WINDOW_DURATION - elapsedWindowTime); 165 | 166 | vm.warp(block.timestamp + remainingWindowTime); 167 | 168 | uint256 finalWindowTime = RDA(_auctionAddress).remainingWindowTime(_auctionId); 169 | 170 | require(finalWindowTime == 0); 171 | 172 | uint256 newScalarPrice = RDA(_auctionAddress).scalarPrice(_auctionId); 173 | 174 | /* -------------BIDDER-------------- */ 175 | vm.startPrank(TEST_ADDRESS_TWO); 176 | bidId = createBid(newScalarPrice); 177 | vm.stopPrank(); 178 | /* --------------------------------- */ 179 | 180 | uint256 finalRemainingTime = RDA(_auctionAddress).remainingTime(_auctionId); 181 | 182 | emit log_uint(AUCTION_DURATION - elapsedTime); 183 | require(finalRemainingTime == AUCTION_DURATION - elapsedTime); 184 | } 185 | 186 | 187 | function testWindowExpiry() public { 188 | vm.warp(block.timestamp + 33 minutes); 189 | 190 | uint256 scalarPrice = RDA(_auctionAddress).scalarPrice(_auctionId); 191 | uint256 initialRemainingTime = RDA(_auctionAddress).remainingTime(_auctionId); 192 | 193 | /* -------------BIDDER------------ */ 194 | vm.startPrank(TEST_ADDRESS_TWO); 195 | createBid(scalarPrice); 196 | vm.stopPrank(); 197 | /* --------------------------------- */ 198 | 199 | require(RDA(_auctionAddress).remainingWindowTime(_auctionId) == AUCTION_WINDOW_DURATION); 200 | 201 | vm.warp(block.timestamp + 1 hours); 202 | 203 | require(RDA(_auctionAddress).remainingWindowTime(_auctionId) == AUCTION_WINDOW_DURATION - 1 hours); 204 | 205 | /* -------------OPERATOR------------ */ 206 | vm.startPrank(TEST_ADDRESS_ONE); 207 | createBid(scalarPrice + 1); 208 | vm.stopPrank(); 209 | /* --------------------------------- */ 210 | 211 | vm.warp(block.timestamp + AUCTION_WINDOW_DURATION); 212 | 213 | require(RDA(_auctionAddress).remainingWindowTime(_auctionId) == 0); 214 | 215 | vm.warp(block.timestamp + 1 minutes); 216 | 217 | uint256 nextScalarPrice = RDA(_auctionAddress).scalarPrice(_auctionId); 218 | 219 | (, uint256 expiryTimestamp,,,) = RDA(_auctionAddress)._window(_auctionId, 0); 220 | (, uint256 windowTimestamp,,,,,,) = RDA(_auctionAddress)._auctions(_auctionId); 221 | uint256 elapsedWindowTimestamp = expiryTimestamp - windowTimestamp; 222 | uint256 elapsedExpiryTimestamp = block.timestamp - expiryTimestamp; 223 | 224 | /* -------------OPERATOR------------ */ 225 | vm.startPrank(TEST_ADDRESS_TWO); 226 | createBid(nextScalarPrice); 227 | vm.stopPrank(); 228 | /* --------------------------------- */ 229 | 230 | vm.warp(block.timestamp + AUCTION_WINDOW_DURATION); 231 | 232 | uint256 newRemainingTime = RDA(_auctionAddress).remainingTime(_auctionId); 233 | uint256 remainingMinusElapsedTime = initialRemainingTime - elapsedExpiryTimestamp - 1 hours; 234 | 235 | require(remainingMinusElapsedTime == newRemainingTime); 236 | } 237 | 238 | function testCommitBid() public { 239 | vm.warp(block.timestamp + 10 minutes); 240 | 241 | uint256 scalarPrice = RDA(_auctionAddress).scalarPrice(_auctionId); 242 | 243 | /* -------------BIDDER------------ */ 244 | vm.startPrank(TEST_ADDRESS_TWO); 245 | createBid(scalarPrice); 246 | vm.stopPrank(); 247 | /* --------------------------------- */ 248 | } 249 | 250 | function testClaimAndWithdraw() public { 251 | vm.warp(block.timestamp + 1 minutes); 252 | 253 | uint256 scalarPrice = RDA(_auctionAddress).scalarPrice(_auctionId); 254 | 255 | /* -------------BIDDER------------ */ 256 | vm.startPrank(TEST_ADDRESS_TWO); 257 | createBid(scalarPrice); 258 | vm.stopPrank(); 259 | /* --------------------------------- */ 260 | 261 | /* -------------OPERATOR------------ */ 262 | vm.startPrank(TEST_ADDRESS_ONE); 263 | createBid(scalarPrice + 1); 264 | vm.stopPrank(); 265 | /* --------------------------------- */ 266 | 267 | /* -------------BIDDER------------ */ 268 | vm.startPrank(TEST_ADDRESS_TWO); 269 | createBid(scalarPrice + 2); 270 | vm.stopPrank(); 271 | /* --------------------------------- */ 272 | 273 | vm.warp(block.timestamp + AUCTION_DURATION - 1 minutes + AUCTION_WINDOW_DURATION); 274 | 275 | RDA(_auctionAddress).fulfillWindow(_auctionId, 0); 276 | 277 | /* -------------BIDDER------------ */ 278 | vm.startPrank(TEST_ADDRESS_TWO); 279 | RDA(_auctionAddress).redeem(TEST_ADDRESS_TWO, _auctionId); 280 | vm.stopPrank(); 281 | /* --------------------------------- */ 282 | 283 | /* -------------OPERATOR------------ */ 284 | vm.startPrank(TEST_ADDRESS_ONE); 285 | RDA(_auctionAddress).redeem(TEST_ADDRESS_ONE, _auctionId); 286 | RDA(_auctionAddress).withdraw(_auctionId); 287 | vm.stopPrank(); 288 | /* --------------------------------- */ 289 | 290 | uint256 operatorPTokenBalance = ERC20(_purchaseToken).balanceOf(TEST_ADDRESS_ONE); 291 | uint256 operatorRTokenBalance = ERC20(_reserveToken).balanceOf(TEST_ADDRESS_ONE); 292 | uint256 bidderPTokenBalance = ERC20(_purchaseToken).balanceOf(TEST_ADDRESS_TWO); 293 | uint256 bidderRTokenBalance = ERC20(_reserveToken).balanceOf(TEST_ADDRESS_TWO); 294 | 295 | uint256 dustRemainder = 1 ether % (scalarPrice + 2); 296 | uint256 orderAmount = (1 ether - dustRemainder) * 1e18 / (scalarPrice + 2); 297 | uint256 remainingReserves = AUCTION_RESERVES - orderAmount; 298 | 299 | require(bidderRTokenBalance == orderAmount); 300 | require(operatorRTokenBalance == remainingReserves); 301 | require(operatorPTokenBalance == 2 ether - dustRemainder); 302 | require(bidderPTokenBalance == 99 ether + dustRemainder); 303 | } 304 | 305 | function createAuction() public returns (bytes memory) { 306 | ERC20(_reserveToken).approve(_auctionAddress, AUCTION_RESERVES); 307 | 308 | return RDA(_auctionAddress).createAuction( 309 | TEST_ADDRESS_ONE, 310 | _reserveToken, 311 | _purchaseToken, 312 | AUCTION_RESERVES, 313 | AUCTION_MINIMUM_PURCHASE, 314 | AUCTION_ORIGIN_PRICE, 315 | block.timestamp, 316 | block.timestamp + AUCTION_DURATION, 317 | AUCTION_WINDOW_DURATION 318 | ); 319 | } 320 | 321 | function createBid(uint256 price) public returns (bytes memory) { 322 | ERC20(_purchaseToken).approve(_auctionAddress, 1 ether); 323 | 324 | return RDA(_auctionAddress).commitBid(_auctionId, price, 1 ether); 325 | } 326 | 327 | } -------------------------------------------------------------------------------- /src/RDA.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity 0.8.13; 3 | 4 | import { IRDA } from "@root/interfaces/IRDA.sol"; 5 | import { IERC20 } from "@openzeppelin/token/ERC20/IERC20.sol"; 6 | import { IERC20Metadata } from "@openzeppelin/token/ERC20/extensions/IERC20Metadata.sol"; 7 | 8 | import { SafeERC20 } from "@openzeppelin/token/ERC20/utils/SafeERC20.sol"; 9 | import { ReentrancyGuard } from "@openzeppelin/security/ReentrancyGuard.sol"; 10 | 11 | /* 12 | * @title Rolling Dutch Auction (RDA) 13 | * @author Samuel JJ Gosling (@deomaius) 14 | * @description A dutch auction derivative with composite decay 15 | */ 16 | 17 | contract RDA is IRDA, ReentrancyGuard { 18 | 19 | using SafeERC20 for IERC20; 20 | 21 | /* @dev Address mapping for an auction's redeemable balances */ 22 | mapping(address => mapping(bytes => bytes)) public _claims; 23 | 24 | /* @dev Auction mapping translating to an indexed window */ 25 | mapping(bytes => mapping(uint256 => Window)) public _window; 26 | 27 | /* @dev Auction mapping for associated parameters */ 28 | mapping(bytes => Auction) public _auctions; 29 | 30 | /* @dev Auction mapping for the window index */ 31 | mapping(bytes => uint256) public _windows; 32 | 33 | /* 34 | * @dev Condition to ensure an auction is active 35 | * @param auctionId Encoded auction parameter identifier 36 | */ 37 | modifier activeAuction(bytes calldata auctionId) { 38 | if (remainingWindowTime(auctionId) == 0 && remainingTime(auctionId) == 0) { 39 | revert AuctionInactive(); 40 | } 41 | _; 42 | } 43 | 44 | /* 45 | * @dev Condition to ensure an auction is inactive 46 | * @param auctionId Encoded auction parameter identifier 47 | */ 48 | modifier inactiveAuction(bytes calldata auctionId) { 49 | if (remainingWindowTime(auctionId) > 0 || remainingTime(auctionId) > 0) { 50 | revert AuctionActive(); 51 | } 52 | _; 53 | } 54 | 55 | /* 56 | * @dev Helper to view an auction's operator address 57 | * @param auctionId Encoded auction parameter identifier 58 | * @return operator Auction operator address 59 | */ 60 | function operatorAddress(bytes calldata auctionId) public pure returns (address operator) { 61 | (operator,,,,) = abi.decode(auctionId, (address, address, address, uint256, bytes)); 62 | } 63 | 64 | /* 65 | * @dev Helper to view an auction's purchase token address 66 | * @param auctionId Encoded auction parameter identifier 67 | * @return Token interface 68 | */ 69 | function purchaseToken(bytes calldata auctionId) public pure returns (IERC20) { 70 | (,, address tokenAddress,,) = abi.decode(auctionId, (address, address, address, uint256, bytes)); 71 | 72 | return IERC20(tokenAddress); 73 | } 74 | 75 | /* 76 | * @dev Helper to view an auction's reserve token address 77 | * @param auctionId Encoded auction parameter identifier 78 | * @return Token interface 79 | */ 80 | function reserveToken(bytes calldata auctionId) public pure returns (IERC20) { 81 | (, address tokenAddress,,,) = abi.decode(auctionId, (address, address, address, uint256, bytes)); 82 | 83 | return IERC20(tokenAddress); 84 | } 85 | 86 | /* 87 | * @dev Helper to decode claim hash balances 88 | * @param claimHash Encoded claim parameter identifer 89 | * @return refund Account refund balance 90 | * @return claim Account claim balance 91 | */ 92 | function balancesOf(bytes memory claimHash) public pure returns (uint256 refund, uint256 claim) { 93 | if (keccak256(claimHash) != keccak256(bytes(""))) { 94 | (refund, claim) = abi.decode(claimHash, (uint256, uint256)); 95 | } 96 | } 97 | 98 | /* 99 | * @dev Helper to decode bid identifer parameters 100 | * @param bidId Encoded bid parameter indentifier 101 | * @return auctionId Encoded auction parameter identifier 102 | * @return bidder Order recipient address 103 | * @return price Order price 104 | * @return volume Order volume 105 | */ 106 | function bidParameters(bytes memory bidId) public pure returns ( 107 | bytes memory auctionId, 108 | address bidder, 109 | uint256 price, 110 | uint256 volume 111 | ) { 112 | (auctionId, bidder, price, volume) = abi.decode(bidId, (bytes, address, uint256, uint256)); 113 | } 114 | 115 | /* 116 | * @dev Helper to query whether the current window is initialised 117 | * @param auctionId Encoded auction parameter identifier 118 | * @return Window state condition 119 | */ 120 | function isWindowInit(bytes calldata auctionId) public view returns (bool) { 121 | return _window[auctionId][_windows[auctionId]].expiry != 0; 122 | } 123 | 124 | /* 125 | * @dev Helper to query whether the current window is active 126 | * @param Encoded auction parameter identifier 127 | * @return Window state condition 128 | */ 129 | function isWindowActive(bytes calldata auctionId) public view returns (bool) { 130 | Window storage window = _window[auctionId][_windows[auctionId]]; 131 | 132 | return isWindowInit(auctionId) && window.expiry > block.timestamp; 133 | } 134 | 135 | /* 136 | * @dev Helper to query whether the current window is expired 137 | * @param auctionId Encoded auction parameter identifier 138 | * @return Window state condition 139 | */ 140 | function isWindowExpired(bytes calldata auctionId) public view returns (bool) { 141 | Window storage window = _window[auctionId][_windows[auctionId]]; 142 | 143 | return isWindowInit(auctionId) && window.expiry < block.timestamp; 144 | } 145 | 146 | /* 147 | * @dev Auction deployment 148 | * @param operatorAddress Auction management address 149 | * @param reserveToken Auctioning token address 150 | * @param purchaseToken Currency token address 151 | * @param reserveAmount Auctioning token amount 152 | * @param minimumPurchaseAmount Minimum currency purchase amount 153 | * @param startingOriginPrice Auction starting price 154 | * @param startTimestamp Unix timestamp auction initiation 155 | * @param endTimestamp Unix timestamp auction expiration 156 | * @param windowDuration Unix time window duration 157 | * @return Encoded auction parameter identifier 158 | */ 159 | function createAuction( 160 | address operatorAddress, 161 | address reserveToken, 162 | address purchaseToken, 163 | uint256 reserveAmount, 164 | uint256 minimumPurchaseAmount, 165 | uint256 startingOriginPrice, 166 | uint256 startTimestamp, 167 | uint256 endTimestamp, 168 | uint256 windowDuration 169 | ) override external returns (bytes memory) { 170 | bytes memory auctionId = abi.encode( 171 | operatorAddress, 172 | reserveToken, 173 | purchaseToken, 174 | minimumPurchaseAmount, 175 | abi.encodePacked(reserveAmount, startingOriginPrice, startTimestamp, endTimestamp, windowDuration) 176 | ); 177 | 178 | Auction storage state = _auctions[auctionId]; 179 | 180 | if (state.price != 0) { 181 | revert AuctionExists(); 182 | } 183 | if (startingOriginPrice == 0) { 184 | revert InvalidAuctionPrice(); 185 | } 186 | if (startTimestamp < block.timestamp) { 187 | revert InvalidAuctionTimestamp(); 188 | } 189 | if (endTimestamp - startTimestamp < 1 days || windowDuration < 2 hours) { 190 | revert InvalidAuctionDurations(); 191 | } 192 | if (IERC20Metadata(reserveToken).decimals() != IERC20Metadata(purchaseToken).decimals()){ 193 | revert InvalidTokenDecimals(); 194 | } 195 | 196 | state.duration = endTimestamp - startTimestamp; 197 | state.windowDuration = windowDuration; 198 | state.windowTimestamp = startTimestamp; 199 | state.startTimestamp = startTimestamp; 200 | state.endTimestamp = endTimestamp; 201 | state.price = startingOriginPrice; 202 | state.reserves = reserveAmount; 203 | 204 | emit NewAuction(auctionId, reserveToken, reserveAmount, startingOriginPrice, endTimestamp); 205 | 206 | IERC20(reserveToken).safeTransferFrom(msg.sender, address(this), reserveAmount); 207 | 208 | return auctionId; 209 | } 210 | 211 | /* 212 | * @dev Helper to view an auction's minimum purchase amount 213 | * @param auctionId Encoded auction parameter identifier 214 | * @return minimumAmount Minimum purchaseToken amount 215 | */ 216 | function minimumPurchase(bytes calldata auctionId) public pure returns (uint256 minimumAmount) { 217 | (,,, minimumAmount,) = abi.decode(auctionId, (address, address, address, uint256, bytes)); 218 | } 219 | 220 | /* 221 | * @param auctionId Encoded auction parameter identifier 222 | * ----------------------------------------------------------------------------- 223 | * @dev Active price decay proportional to time delta (t) between the current 224 | * timestamp and the window's start timestamp or if the window is expired; 225 | * the window's expiration. Time remaining (t_r) since the predefined 226 | * timestamp until the auctions conclusion, is subtracted from t and 227 | * applied as modulo to t subject to addition of itself. The resultant is 228 | * divided by t_r to compute elapsed progress (x) from the last timestamp, 229 | * x is multiplied by the origin price (y) and subtracted by y to result 230 | * the decayed price. 231 | * ----------------------------------------------------------------------------- 232 | * @return Curve price 233 | */ 234 | function scalarPrice(bytes calldata auctionId) 235 | activeAuction(auctionId) 236 | public view returns (uint256) { 237 | Auction storage state = _auctions[auctionId]; 238 | Window storage window = _window[auctionId][_windows[auctionId]]; 239 | 240 | uint256 ts = isWindowExpired(auctionId) ? window.expiry : state.windowTimestamp; 241 | uint256 y = !isWindowInit(auctionId) ? state.price : window.price; 242 | 243 | uint256 t = block.timestamp - ts; 244 | uint256 t_r = state.duration - elapsedTimeFromWindow(auctionId); 245 | 246 | uint256 t_mod = t % (t_r - t); 247 | uint256 x = (t + t_mod) * 1e18; 248 | uint256 y_x = y * x / t_r; 249 | 250 | return y - y_x / 1e18; 251 | } 252 | 253 | /* 254 | * @dev Bid submission 255 | * @param auctionID Encoded auction parameter identifier 256 | * @param price Bid order price 257 | * @param volume Bid order volume 258 | * @return bidId Encoded bid parameter indentifier 259 | */ 260 | function commitBid(bytes calldata auctionId, uint256 price, uint256 volume) 261 | activeAuction(auctionId) 262 | nonReentrant 263 | override external returns (bytes memory bidId) { 264 | Auction storage state = _auctions[auctionId]; 265 | Window storage window = _window[auctionId][_windows[auctionId]]; 266 | 267 | if (volume < minimumPurchase(auctionId)) { 268 | revert InvalidPurchaseVolume(); 269 | } 270 | 271 | bool hasExpired; 272 | 273 | if (isWindowInit(auctionId)) { 274 | if (isWindowActive(auctionId)) { 275 | if (window.price < price) { 276 | if (volume < window.volume) { 277 | revert InvalidWindowVolume(); 278 | } 279 | } else { 280 | revert InvalidWindowPrice(); 281 | } 282 | } else { 283 | hasExpired = true; 284 | } 285 | } 286 | 287 | if (window.price == 0 || hasExpired) { 288 | if (price < scalarPrice(auctionId)) { 289 | revert InvalidScalarPrice(); 290 | } 291 | } 292 | 293 | uint256 orderVolume = volume - (volume % price); 294 | 295 | if (state.reserves < orderVolume * 1e18 / price) { 296 | revert InsufficientReserves(); 297 | } 298 | if (volume < price) { 299 | revert InvalidReserveVolume(); 300 | } 301 | 302 | bidId = abi.encode(auctionId, msg.sender, price, orderVolume); 303 | 304 | { 305 | (uint256 refund, uint256 claim) = balancesOf(_claims[msg.sender][auctionId]); 306 | 307 | delete _claims[msg.sender][auctionId]; 308 | 309 | _claims[msg.sender][auctionId] = abi.encode(refund + orderVolume, claim); 310 | } 311 | 312 | if (hasExpired) { 313 | window = _window[auctionId][windowExpiration(auctionId)]; 314 | } 315 | 316 | window.expiry = block.timestamp + state.windowDuration; 317 | window.volume = orderVolume; 318 | window.price = price; 319 | window.bidId = bidId; 320 | 321 | state.windowTimestamp = block.timestamp; 322 | 323 | emit Offer(auctionId, msg.sender, bidId, window.expiry); 324 | 325 | purchaseToken(auctionId).safeTransferFrom(msg.sender, address(this), orderVolume); 326 | } 327 | 328 | /* 329 | * @dev Expire and fulfill an auction's active window 330 | * @param auctionId Encoded auction parameter identifier 331 | * @return Next window index 332 | */ 333 | function windowExpiration(bytes calldata auctionId) internal returns (uint256) { 334 | uint256 windowIndex = _windows[auctionId]; 335 | 336 | Auction storage state = _auctions[auctionId]; 337 | Window storage window = _window[auctionId][windowIndex]; 338 | 339 | state.endTimestamp = block.timestamp + remainingTime(auctionId); 340 | state.price = window.price; 341 | 342 | _windows[auctionId] = windowIndex + 1; 343 | 344 | _fulfillWindow(auctionId, windowIndex); 345 | 346 | emit Expiration(auctionId, window.bidId, windowIndex); 347 | 348 | return windowIndex + 1; 349 | } 350 | 351 | /* 352 | * @dev Fulfill a window index for an inactive auction 353 | * @param auctionId Encoded auction parameter identifier 354 | */ 355 | function fulfillWindow(bytes calldata auctionId, uint256 windowId) 356 | inactiveAuction(auctionId) 357 | override public { 358 | _fulfillWindow(auctionId, windowId); 359 | } 360 | 361 | /* 362 | * @dev Fulfill a window index 363 | * @param auctionId Encoded auction parameter identifier 364 | */ 365 | function _fulfillWindow(bytes calldata auctionId, uint256 windowId) internal { 366 | Auction storage state = _auctions[auctionId]; 367 | Window storage window = _window[auctionId][windowId]; 368 | 369 | if (isWindowActive(auctionId)) { 370 | revert WindowUnexpired(); 371 | } 372 | if (window.processed) { 373 | revert WindowFulfilled(); 374 | } 375 | 376 | (, address bidder, uint256 price, uint256 volume) = bidParameters(window.bidId); 377 | (uint256 refund, uint256 claim) = balancesOf(_claims[bidder][auctionId]); 378 | 379 | delete _claims[bidder][auctionId]; 380 | 381 | window.processed = true; 382 | 383 | uint256 orderAmount = volume * 1e18 / price; 384 | 385 | state.reserves -= orderAmount; 386 | state.proceeds += volume; 387 | 388 | _claims[bidder][auctionId] = abi.encode(refund - volume, claim + orderAmount); 389 | 390 | emit Fulfillment(auctionId, window.bidId, windowId); 391 | } 392 | 393 | /* 394 | * @dev Helper to view an auction's remaining duration 395 | * @param auctionId Encoded auction parameter identifier 396 | * @return Remaining unix time 397 | */ 398 | function remainingTime(bytes calldata auctionId) public view returns (uint256) { 399 | return _auctions[auctionId].duration - elapsedTime(auctionId); 400 | } 401 | 402 | /* 403 | * @dev Helper to view an auction's active remaining window duration 404 | * @param auctionId Encoded auction parameter identifier 405 | * @return Remaining window unix time 406 | */ 407 | function remainingWindowTime(bytes calldata auctionId) public view returns (uint256) { 408 | if (!isWindowActive(auctionId)) { 409 | return 0; 410 | } 411 | 412 | return _window[auctionId][_windows[auctionId]].expiry - block.timestamp; 413 | } 414 | 415 | /* 416 | * @dev Helper to view an auction's progress in unix time 417 | * @param auctionId Encoded auction parameter identifier 418 | * @return Completed unix time 419 | */ 420 | function elapsedTime(bytes calldata auctionId) public view returns (uint256) { 421 | return block.timestamp - windowElapsedTime(auctionId) - _auctions[auctionId].startTimestamp; 422 | } 423 | 424 | /* 425 | * @dev Helper to view an auction's total window progress in unix time 426 | * @param auctionId Encoded auction parameter identifier 427 | * @return Completed unix time 428 | */ 429 | function windowElapsedTime(bytes calldata auctionId) public view returns (uint256) { 430 | if (!isWindowInit(auctionId)) { 431 | return 0; 432 | } 433 | 434 | uint256 windowIndex = _windows[auctionId]; 435 | uint256 elapsedWindowsTime = _auctions[auctionId].windowDuration * (windowIndex + 1); 436 | 437 | return elapsedWindowsTime - remainingWindowTime(auctionId); 438 | } 439 | 440 | /* 441 | * @dev Helper to view an auction's progress from a window expiration or start in unix time 442 | * @param auctionId Encoded auction parameter identifier 443 | * @return Completed unix time 444 | */ 445 | function elapsedTimeFromWindow(bytes calldata auctionId) public view returns (uint256) { 446 | Auction storage state = _auctions[auctionId]; 447 | 448 | uint256 endTimestamp = state.windowTimestamp; 449 | 450 | if (isWindowExpired(auctionId)) { 451 | endTimestamp = _window[auctionId][_windows[auctionId]].expiry; 452 | } 453 | 454 | return endTimestamp - windowElapsedTime(auctionId) - state.startTimestamp; 455 | } 456 | 457 | /* 458 | * @dev Auction management redemption 459 | * @param auctionId Encoded auction parameter identifier 460 | */ 461 | function withdraw(bytes calldata auctionId) 462 | inactiveAuction(auctionId) 463 | override external { 464 | uint256 proceeds = _auctions[auctionId].proceeds; 465 | uint256 reserves = _auctions[auctionId].reserves; 466 | 467 | delete _auctions[auctionId].proceeds; 468 | delete _auctions[auctionId].reserves; 469 | 470 | if (proceeds > 0) { 471 | purchaseToken(auctionId).safeTransfer(operatorAddress(auctionId), proceeds); 472 | } 473 | if (reserves > 0) { 474 | reserveToken(auctionId).safeTransfer(operatorAddress(auctionId), reserves); 475 | } 476 | 477 | emit Withdraw(auctionId); 478 | } 479 | 480 | /* 481 | * @dev Auction order and refund redemption 482 | * @param auctionId Encoded auction parameter identifier 483 | */ 484 | function redeem(address bidder, bytes calldata auctionId) 485 | inactiveAuction(auctionId) 486 | override external { 487 | bytes memory claimHash = _claims[bidder][auctionId]; 488 | 489 | (uint256 refund, uint256 claim) = balancesOf(claimHash); 490 | 491 | delete _claims[bidder][auctionId]; 492 | 493 | if (refund > 0) { 494 | purchaseToken(auctionId).safeTransfer(bidder, refund); 495 | } 496 | if (claim > 0) { 497 | reserveToken(auctionId).safeTransfer(bidder, claim); 498 | } 499 | 500 | emit Claim(auctionId, claimHash); 501 | } 502 | 503 | } 504 | -------------------------------------------------------------------------------- /AUDIT.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | A time-boxed security review of the **Rolling Dutch Auction** protocol was done by **pashov**, with a focus on the security aspects of the application's implementation. 4 | 5 | # Disclaimer 6 | 7 | A smart contract security review can never verify the complete absence of vulnerabilities. This is a time, resource and expertise bound effort where I try to find as many vulnerabilities as possible. I can not guarantee 100% security after the review or even if the review will find any problems with your smart contracts. 8 | 9 | # About **pashov** 10 | 11 | Krum Pashov, or **pashov**, is an independent smart contract security researcher. Having found numerous security vulnerabilities in various protocols, he does his best to contribute to the blockchain ecosystem and its protocols by putting time and effort into security research & reviews. Reach out on Twitter [@pashovkrum](https://twitter.com/pashovkrum) 12 | 13 | # About **Rolling Dutch Auction** 14 | 15 | The Rolling Dutch Auction protocol is a dutch auction derivative with composite decay. This means that the auction will start off with a high asking price and it will gradually be decreased with time. The protocol works by auctioning ERC20 tokens that can be bid on with other ERC20 tokens themselves. Both the auction and bid implementations are custodial, meaning the protocol is transferring and holding ERC20 funds in itself. 16 | 17 | The way the auction works is by having windows, which are periods where the current price is frozen. Anytime a bid is submitted while a window is active, the duration of the window is restarted, meaning it implements a perpetual duration mechanism. When a window expires the current selling price is referenced by calculating the time elapsed from the last window instead of the auction's initiation. 18 | 19 | ## Unexpected/Interesting Design choices 20 | 21 | The `withdraw` functionality is callable by anyone, it will transfer the proceeds and the remaining reserves to the `operatorAddress`. Same for `redeem`, anyone can call it and it will transfer the refund and claim amount to the `bidder` argument. 22 | 23 | To overbid another bidder, you have to place a bid that has both a higher `price` and higher `volume` than the preceding highest bid. 24 | 25 | Both the `scalarPrice` method and the `price` parameter in `commitBid` are valued in terms of the `reserve` token. 26 | 27 | # Threat Model 28 | 29 | ## Roles & Actors 30 | 31 | - Auction creator - an account that calls `createAuction` and loads the initial reserve into the `RDA` contract 32 | - Auction operator - the account that will receive the proceeds & remaining reserves when `withdraw` is called 33 | - Bidder - an account that submits a bid via the `commitBid` functionality, moving his `purchaseToken` funds into the `RDA` contract 34 | - Unauthorized user - anyone can call `fulfillWindow`, `withdraw` and `redeem` by just paying gas 35 | 36 | ## Security Interview 37 | 38 | **Q:** What in the protocol has value in the market? 39 | 40 | **A:** The protocol is custodial, so the purchase and reserve tokens amounts it holds are valuable. 41 | 42 | **Q:** What is the worst thing that can happen to the protocol? 43 | 44 | **A:** The funds it holds get stuck in it forever or a user steals them, making other users' redeems/withdraws revert. 45 | 46 | **Q:** In what case can the protocol/users lose money? 47 | 48 | **A:** If users receive less (or none at all) reserve tokens than what they bid for. Same for the protocol operator - if he receives less (or none at all) purchase tokens than what he should have. 49 | 50 | ## Potential attacker's goals 51 | 52 | - Place any method in the protocol into a state of DoS 53 | - Steal another user's claimable reserve tokens 54 | - Exploit bugs in price calculations 55 | 56 | ## Potential ways for the attacker to achieve his goals 57 | 58 | - Making calls to `createAuction`, `commitBid`, `redeem` and `withdraw` revert by force-failing a `require` statement or an external call 59 | - Exploit errors or rounding downs in divisions in price calculations for personal benefit 60 | - Force the auction to never complete so no one will receive tokens 61 | 62 | # Severity classification 63 | 64 | | Severity | Impact: High | Impact: Medium | Impact: Low | 65 | | ---------------------- | ------------ | -------------- | ----------- | 66 | | **Likelihood: High** | Critical | High | Medium | 67 | | **Likelihood: Medium** | High | Medium | Low | 68 | | **Likelihood: Low** | Medium | Low | Low | 69 | 70 | **Impact** - the technical, economic and reputation damage of a successful attack 71 | 72 | **Likelihood** - the chance that a particular vulnerability gets discovered and exploited 73 | 74 | **Severity** - the overall criticality of the risk 75 | 76 | # Security Assessment Summary 77 | 78 | **_review commit hash_ - [cb27022597db95c4fd734356491bc0304e1e0721](https://github.com/deomaius/rolling-dutch-auction/tree/cb27022597db95c4fd734356491bc0304e1e0721)** 79 | 80 | ### Scope 81 | 82 | The following smart contracts were in scope of the audit: 83 | 84 | - `RDA` 85 | - `interfaces/IRDA` 86 | 87 | The following number of issues were found, categorized by their severity: 88 | 89 | - Critical & High: 4 issues 90 | - Medium: 4 issues 91 | - Low: 3 issues 92 | - Informational: 6 issues 93 | 94 | --- 95 | 96 | # Findings Summary 97 | 98 | | ID | Title | Severity | 99 | | ------ | ----------------------------------------------------------------------------------------- | ------------- | 100 | | [C-01] | Anyone can make new bids always revert after a window expires | Critical | 101 | | [C-02] | Successful bidders can lose significant value due to division rounding | Critical | 102 | | [C-03] | The logic in `elapsedTime` is flawed | Critical | 103 | | [H-01] | Users are likely to lose their bid if `purchaseToken` is a low-decimals token | High | 104 | | [M-01] | Missing input validation on `createAuction` function parameters can lead to loss of value | Medium | 105 | | [M-02] | Loss of precision in `scalarPrice` function | Medium | 106 | | [M-03] | Protocol won't work correctly with tokens that do not revert on failed `transfer` | Medium | 107 | | [M-04] | Auction won't work correctly with fee-on-transfer & rebasing tokens | Medium | 108 | | [L-01] | Auction with `price == 0` can be re-created | Low | 109 | | [L-02] | The `commitBid` method does not follow Checks-Effects-Interactions pattern | Low | 110 | | [L-03] | The `scalarPrice` method should have an `activeAuction` modifier | Low | 111 | | [I-01] | Using `require` statements without error strings | Informational | 112 | | [I-02] | Typos in code | Informational | 113 | | [I-03] | Missing License identifier | Informational | 114 | | [I-04] | Function state mutability can be restricted to view | Informational | 115 | | [I-05] | Incomplete NatSpec docs | Informational | 116 | | [I-06] | Missing `override` keyword | Informational | 117 | 118 | # Detailed Findings 119 | 120 | # [C-01][x] Anyone can make new bids always revert after a window expires 121 | 122 | ## Severity 123 | 124 | **Impact:** 125 | High, as all new bidding will revert until auction ends 126 | 127 | **Likelihood:** 128 | High, as anyone can execute the attack without rare preconditions 129 | 130 | ## Description 131 | 132 | The `fulfillWindow` method is a `public` method that is also called internally. It sets `window.processed` to `true`, which makes it callable only once for a single `windowId`. The problem is that the `commitBid` function has the following logic: 133 | 134 | ```solidity 135 | if (hasExpired) { 136 | window = _window[auctionId][windowExpiration(auctionId)]; 137 | } 138 | ``` 139 | 140 | Where `windowExpiration` calls `fulfillWindow` with the latest `windowId` in itself. If any user manages to call `fulfilWindow` externally first, then the `window.processed` will be set to `true`, making the following check in `fulfillWindow` 141 | 142 | ```solidity 143 | if (window.processed) { 144 | revert WindowFulfilled(); 145 | } 146 | ``` 147 | 148 | revert on every `commitBid` call from now on. This will result in inability for anyone to place more bids, so the auction will not sell anything more until the end of the auction period. 149 | 150 | ## Recommendations 151 | 152 | Make `fulfillWindow` to be `internal` and then add a new public method that calls it internally but also has the `inactiveAuction` modifier as well - this way anyone will be able to complete a window when an auction is finished even though no one can call `commitBid`. 153 | 154 | # [C-02][x] Successful bidders can lose significant value due to division rounding 155 | 156 | ## Severity 157 | 158 | **Impact:** 159 | High, as possibly significant value will be lost 160 | 161 | **Likelihood:** 162 | High, as it will happen with most bids 163 | 164 | ## Description 165 | 166 | The `fulfillWindow` method calculates the auction reserves and proceeds after a successful bid in a window. Here is how it accounts it in both the `auctions` and `claims` storage mappings: 167 | 168 | ```solidity 169 | _auctions[auctionId].reserves -= volume / price; 170 | _auctions[auctionId].proceeds += volume; 171 | 172 | _claims[bidder][auctionId] = abi.encode(refund - volume, claim + (volume / price)); 173 | ``` 174 | 175 | The problem is in the `volume / price` division and the way Solidity works - since it only has integers, in division the result is always rounded down. This would mean the bidder will have less `claim` tokens than expected, while the `_auctions[auctionId].reserves` will keep more tokens than it should have. Let's look at the following scenario: 176 | 177 | 1. The `reserve` token is `WETH` (18 decimals) and the purchase token is `DAI` - 18 decimals as well 178 | 2. Highest bidder in the window bid 2 \* 1e18 - 1 `DAI` with a price of 1e18 `WETH` 179 | 3. While the bid should have resulted in 1.99999 `WETH` bought, the user will receive only 1 `WETH` but not get a refund here 180 | 4. The user got the same amount of `WETH` as if he bid 1e18 but he bid twice as much, minus one 181 | 182 | Every remainder of the `volume / price` division will result in a loss for the bidder. 183 | 184 | ## Recommendations 185 | 186 | Design the code so that the remainder of the `volume / price` division gets refunded to the bidder, for example adding it to the `refund` value. 187 | 188 | # [C-03][x] The logic in `elapsedTime` is flawed 189 | 190 | ## Severity 191 | 192 | **Impact:** 193 | High, as the method is used to calculate the price of the auction but will give out wrong results 194 | 195 | **Likelihood:** 196 | High, as the problems are present almost all of the time during an auction 197 | 198 | ## Description 199 | 200 | There are multiple flaws with the `elapsedTime` method: 201 | 202 | 1. If there are 0 windows, the `windowIndex` variable (which is used for the windows count) will be 1, which is wrong and will lead to a big value for `windowElapsedTime` when it should be 0 203 | 2. If `auctionElapsedTime == windowElapsedTime` we will get `auctionElapsedTime` as a result, but if there was just 1 more second in `auctionElapsedTime` we would get `auctionElapsedTime - windowElapsedTime` which would be 1 as a result, so totally different result 204 | 3. When a window is active, the `timestamp` argument will have the same value as the `auction.startTimestamp` so `auctionElapsedTime` will always be 0 in this case 205 | 206 | The method has multiple flaws and works only in the happy-case scenario. 207 | 208 | ## Recommendations 209 | 210 | Remove the method altogether or extract two methods out of it, removing the `timestamp` parameter to simplify the logic. Also think about the edge case scenarios. 211 | 212 | # [H-01][x] Users are likely to lose their bid if `purchaseToken` is a low-decimals token 213 | 214 | ## Severity 215 | 216 | **Impact:** 217 | High, because users will lose their entire bid amount 218 | 219 | **Likelihood:** 220 | Medium, because it happens when `purchaseToken` is a low-decimals token, but those are commonly used 221 | 222 | ## Description 223 | 224 | When a user calls `commitBid` he provides a `volume` parameter, which is the amount of `purchaseToken` he will bid, and a `price` parameter, which is the price in `reserveToken`. His bid is then cached and when window expires the `fulfillWindow` method is called, where we have this logic: 225 | 226 | ```solidity 227 | _auctions[auctionId].reserves -= volume / price; 228 | _auctions[auctionId].proceeds += volume; 229 | 230 | _claims[bidder][auctionId] = abi.encode(refund - volume, claim + (volume / price)); 231 | ``` 232 | 233 | The problem lies in the `volume / price` calculation. In the case that the `reserveToken` is a 18 decimal token (most common ones) but the `purchaseToken` has a low decimals count - `USDC`, `USDT` and `WBTC` have 6 to 8 decimals, then it's very likely that the `volume / price` calculation will result in rounding down to 0. This means that the auction owner would still get the whole bid amount, but the bidder will get 0 `reserveToken`s to claim, resulting in a total loss of his bid. 234 | 235 | The issue is also present when you are using same decimals tokens for both `reserve` and `purchase` tokens but the `volume` in a bid is less than the `price`. Again, the division will round down to zero, resulting in a 100% loss for the bidder. 236 | 237 | ## Recommendations 238 | 239 | In `commitBid` enforce that `volume >= price` and in `createAuction` enforce that the `reserveToken` decimals are equal to the `purchaseToken` decimals. 240 | 241 | # [M-01][x] Missing input validation on `createAuction` function parameters can lead to loss of value 242 | 243 | ## Severity 244 | 245 | **Impact:** 246 | High, as it can lead to stuck funds 247 | 248 | **Likelihood:** 249 | Low, as it requires user error/misconfiguration 250 | 251 | ## Description 252 | 253 | There are some problems with the input validation in `createAuction`, more specifically related to the timestamp values. 254 | 255 | 1. `endTimestamp` can be equal to `startTimestamp`, so `duration` will be 0 256 | 2. `endTimestamp` can be much further in the future than `startTimestamp`, so `duration` will be a huge number and the auction may never end 257 | 3. Both `startTimestamp` and `endTimestamp` can be much further in the future, so auction might never start 258 | 259 | Those possibilities should all be mitigated, as they can lead to the initial reserves and/or the bids being stuck in the protocol forever. 260 | 261 | ## Recommendations 262 | 263 | Use a minimal `duration` value, for example 1 day, as well as a max value, for example 20 days. Make sure auction does not start more than X days after it has been created as well. 264 | 265 | # [M-02][x] Loss of precision in `scalarPrice` function 266 | 267 | ## Severity 268 | 269 | **Impact:** 270 | Medium, as the price will not be very far from the expected one 271 | 272 | **Likelihood:** 273 | Medium, as it will not always result in big loss of precision 274 | 275 | ## Description 276 | 277 | In `scalarPrice` there is this code: 278 | 279 | ```solidity 280 | uint256 b_18 = 1e18; 281 | uint256 t_mod = t % (t_r - t); 282 | uint256 x = (t + t_mod) * b_18 / t_r; 283 | uint256 y = !isInitialised ? state.price : window.price; 284 | 285 | return y - (y * x) / b_18; 286 | ``` 287 | 288 | Here, when you calculate `x` you divide by `t_r` even though later you multiply `x` by `y`. To minimize loss of precision you should always do multiplications before divisions, since solidity just rounds down when there is a remainder in the division operation. 289 | 290 | ## Recommendations 291 | 292 | Always do multiplications before divisions in Solidity, make sure to follow this throughout the whole `scalarPrice` method. 293 | 294 | # [M-03] Protocol won't work correctly with tokens that do not revert on failed `transfer` 295 | 296 | ## Severity 297 | 298 | **Impact:** 299 | High, as it can lead to a loss of value 300 | 301 | **Likelihood:** 302 | Low, as such tokens are not so common 303 | 304 | ## Description 305 | 306 | Some tokens do not revert on failure in `transfer` or `transferFrom` but instead return `false` (example is [ZRX](https://etherscan.io/address/0xe41d2489571d322189246dafa5ebde1f4699f498#code)). While such tokens are technically compliant with the standard it is a common issue to forget to check the return value of the `transfer`/`transferFrom` calls. With the current code, if such a call fails but does not revert it will result in inaccurate calculations or funds stuck in the protocol. 307 | 308 | ## Recommendations 309 | 310 | Use OpenZeppelin's `SafeERC20` library and its `safe` methods for ERC20 transfers. 311 | 312 | # [M-04] Auction won't work correctly with fee-on-transfer & rebasing tokens 313 | 314 | ## Severity 315 | 316 | **Impact:** 317 | High, as it can lead to a loss of value 318 | 319 | **Likelihood:** 320 | Low, as such tokens are not so common 321 | 322 | ## Description 323 | 324 | The code in `createAuction` does the following: 325 | 326 | ```solidity 327 | IERC20(reserveToken).transferFrom(msg.sender, address(this), reserveAmount); 328 | ... 329 | ... 330 | state.reserves = reserveAmount; 331 | ``` 332 | 333 | so it basically caches the expected transferred amount. This will not work if the `reserveToken` has a fee-on-transfer mechanism, since the actual received amount will be less because of the fee. It is also a problem if the token used had a rebasing mechanism, as this can mean that the contract will hold less balance than what it cached in `state.reserves` for the auction, or it will hold more, which will be stuck in the protocol. 334 | 335 | ## Recommendations 336 | 337 | You can either explicitly document that you do not support tokens with a fee-on-transfer or rebasing mechanisms or you can do the following: 338 | 339 | 1. For fee-on-transfer tokens, check the balance before and after the transfer and use the difference as the actual amount received 340 | 2. For rebasing tokens, when they go down in value, you should have a method to update the cached `reserves` accordingly, based on the balance held. This is a complex solution 341 | 3. For rebasing tokens, when they go up in value, you should add a method to actually transfer the excess tokens out of the protocol. 342 | 343 | # [L-01] Auction with `price == 0` can be re-created 344 | 345 | The `createAuction` method checks if auction exists with this code 346 | 347 | ```solidity 348 | Auction storage state = _auctions[auctionId]; 349 | 350 | if (state.price != 0) { 351 | revert AuctionExists(); 352 | } 353 | ``` 354 | 355 | But the method does not check if the `startingOriginPrice` argument had a value of 0 - if it did, then `state.price` would be 0 in the next `createAuction` call. Even though this is not expected to happen, if it does it can lead to this line of code being executed twice: 356 | 357 | ```solidity 358 | IERC20(reserveToken).transferFrom(msg.sender, address(this), reserveAmount); 359 | ``` 360 | 361 | which will result in a loss for the caller. Make sure to require that the value of `startingOriginPrice` is not 0. 362 | 363 | # [L-02][x] The `commitBid` method does not follow Checks-Effects-Interactions pattern 364 | 365 | It's a best practice to follow the CEI pattern in methods that do value transfers. Still, it is not always the best solution, as ERC777 tokens can still reenter while the contract is in a strange state even if you follow CEI. I would recommend adding a `nonReentrant` modifier to `commitBid` and also moving the `transferFrom` call to the end of the method. 366 | 367 | # [L-03][x] The `scalarPrice` method should have an `activeAuction` modifier 368 | 369 | If an auction is inactive then the `scalarPrice` method will still be returning a price, even though it should not, since auction is over. Add the `activeAuction` modifier to it. 370 | 371 | # [I-01] Using `require` statements without error strings 372 | 373 | The `activeAuction` and `inactiveAuction` modifiers use `require` statements without error strings. Use `if` statements with custom errors instead for a better error case UX. 374 | 375 | # [I-02] Typos in code 376 | 377 | Fix all typos in the code: 378 | 379 | `Ancoded` -> `Encoded` 380 | 381 | `exipration` -> `expiration` 382 | 383 | `fuflfillment` -> `fulfillment` 384 | 385 | `operatorAdress` -> `operatorAddress` 386 | 387 | `Uinx` -> `Unix` 388 | 389 | `multipled` -> `multiplied` 390 | 391 | the `expiryTimestamp > 0` check in `remainingWindowTime` is redundant 392 | 393 | # [I-03] Missing License identifier 394 | 395 | The `RDA.sol` file is missing License Identifier as the first line of the file, which is a compiler warning. Add the `No License` License at least to remove the compiler warning. 396 | 397 | # [I-04] Function state mutability can be restricted to view 398 | 399 | The `scalarPriceUint` method does not mutate state but is not marked as `view` - add the `view` keyword to the function's signature. 400 | 401 | # [I-05] Incomplete NatSpec docs 402 | 403 | Methods have incomplete NatSpec docs, for example the `elapsedTime` method is missing the `@param timestamp` in its NatSpec, and also most methods are missing the `@return` param - for example `balancesOf` and `createAuction`. Make sure to write complete and detailed NatSpec docs for each public method. 404 | 405 | # [I-06] Missing `override` keyword 406 | 407 | The `createAuction`, `withdraw` and `redeem` methods are missing the `override` keyword even though the override methods from the `IRDA` interface. Add it to the mentioned methods. 408 | --------------------------------------------------------------------------------