diff --git a/src/abaci.sol b/src/abaci.sol index 8aa7fb96..e38e209f 100644 --- a/src/abaci.sol +++ b/src/abaci.sol @@ -258,3 +258,90 @@ contract ExponentialDecrease is Abacus { return rmul(top, rpow(cut, dur, RAY)); } } + +contract StairstepExponentialIncrease is Abacus { + + // --- Auth --- + mapping (address => uint256) public wards; + function rely(address usr) external auth { wards[usr] = 1; emit Rely(usr); } + function deny(address usr) external auth { wards[usr] = 0; emit Deny(usr); } + modifier auth { + require(wards[msg.sender] == 1, "StairstepExponentialIncrease/not-authorized"); + _; + } + + // --- Data --- + uint256 public step; // Length of time between price drops [seconds] + uint256 public gain; // Per-step multiplicative factor [ray] + + // --- Events --- + event Rely(address indexed usr); + event Deny(address indexed usr); + + event File(bytes32 indexed what, uint256 data); + + // --- Init --- + // @notice: `gain` and `step` values must be correctly set for + // this contract to return a valid price + constructor() public { + wards[msg.sender] = 1; + emit Rely(msg.sender); + } + + // --- Administration --- + function file(bytes32 what, uint256 data) external auth { + if (what == "gain") require((gain = data) >= RAY, "StairstepExponentialIncrease/gain-lt-RAY"); + else if (what == "step") step = data; + else revert("StairstepExponentialIncrease/file-unrecognized-param"); + emit File(what, data); + } + + // --- Math --- + uint256 constant RAY = 10 ** 27; + function rmul(uint256 x, uint256 y) internal pure returns (uint256 z) { + z = x * y; + require(y == 0 || z / y == x); + z = z / RAY; + } + // optimized version from dss PR #78 + function rpow(uint256 x, uint256 n, uint256 b) internal pure returns (uint256 z) { + assembly { + switch n case 0 { z := b } + default { + switch x case 0 { z := 0 } + default { + switch mod(n, 2) case 0 { z := b } default { z := x } + let half := div(b, 2) // for rounding. + for { n := div(n, 2) } n { n := div(n,2) } { + let xx := mul(x, x) + if shr(128, x) { revert(0,0) } + let xxRound := add(xx, half) + if lt(xxRound, xx) { revert(0,0) } + x := div(xxRound, b) + if mod(n,2) { + let zx := mul(z, x) + if and(iszero(iszero(x)), iszero(eq(div(zx, x), z))) { revert(0,0) } + let zxRound := add(zx, half) + if lt(zxRound, zx) { revert(0,0) } + z := div(zxRound, b) + } + } + } + } + } + } + + // dip: initial price + // dur: seconds since the auction has started + // step: seconds between a price increment + // gain: gain encodes the percentage to increase per step. + // The values is set as (1 + (% value / 100)) * RAY + // So, for a 1% increment per step, gain would be (1 + 0.01) * RAY + // + // returns: dip * (gain ^ dur) + // + // + function price(uint256 dip, uint256 dur) override external view returns (uint256) { + return rmul(dip, rpow(gain, dur / step, RAY)); + } +} diff --git a/src/clap.sol b/src/clap.sol new file mode 100644 index 00000000..0ce6366a --- /dev/null +++ b/src/clap.sol @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/// clap.sol -- Surplus auction + +// Copyright (C) 2021 Dai Foundation +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +pragma solidity >=0.5.12; + +// FIXME: This contract was altered compared to the production version. +// It doesn't use LibNote anymore. +// New deployments of this contract will need to include custom events (TO DO). + +interface VatLike { + function move(address,address,uint256) external; +} + +interface GemLike { + function burn(address,uint256) external; +} + +interface PipLike { + function peek() external returns (bytes32, bool); +} + +interface SpotterLike { + function par() external returns (uint256); +} + +interface ClapperCallee { + function clapperCall(address, uint256, uint256, bytes calldata) external; +} + +interface AbacusLike { + function price(uint256, uint256) external view returns (uint256); +} + +/* + This thing lets you sell some dai in return for gems. + + - `lot` dai in return for bid + - `bid` gems paid + - `ttl` single bid lifetime + - `beg` minimum bid increase + - `end` max auction duration +*/ + +contract Clapper { + // --- Auth --- + mapping (address => uint256) public wards; + function rely(address usr) external auth { wards[usr] = 1; emit Rely(usr); } + function deny(address usr) external auth { wards[usr] = 0; emit Deny(usr); } + modifier auth { + require(wards[msg.sender] == 1, "Clapper/not-authorized"); + _; + } + + // --- Data --- + struct Sale { + uint256 lot; // dai to sell [rad] + uint256 dip; // Starting price [ray] + uint256 tic; // Auction start time [timestamp] + } + + mapping (uint256 => Sale) public sales; + + VatLike public immutable vat; // CDP Engine + GemLike public immutable gem; + address public spotter; + address public pip; + AbacusLike public calc; // Current price calculator + + uint256 public buf; // Multiplicative factor to decrease starting price [ray] + uint256 public tail; // Time elapsed before auction reset [seconds] + uint256 public cusp; // Percentage increment before auction reset [ray] + uint256 public dust; // Min amount of DAI in lot to be left on a partial purchase [rad] + + uint256 public kicks; + uint256 public live; // Active Flag + + uint256 internal locked; + + // Levels for circuit breaker + // 0: no breaker + // 1: no new kick() + // 2: no new kick() or redo() + // 3: no new kick(), redo(), or take() + uint256 public stopped = 0; + + // --- Events --- + event Rely(address indexed usr); + event Deny(address indexed usr); + + event File(bytes32 indexed what, uint256 data); + event File(bytes32 indexed what, address data); + + event Kick( + uint256 indexed id, + uint256 lot, + uint256 dip + ); + event Take( + uint256 indexed id, + uint256 min, + uint256 price, + uint256 lot, + uint256 slice + ); + event Redo( + uint256 indexed id, + uint256 lot, + uint256 dip + ); + + // event Yank(uint256 id); + + // --- Init --- + constructor(address vat_, address gem_) public { + wards[msg.sender] = 1; + vat = VatLike(vat_); + gem = GemLike(gem_); + live = 1; + } + + modifier lock { + require(locked == 0, "Clapper/system-locked"); + locked = 1; + _; + locked = 0; + } + + modifier isStopped(uint256 level) { + require(stopped < level, "Clapper/stopped-incorrect"); + _; + } + + // --- Math --- + uint256 constant BLN = 10 ** 9; + uint256 constant WAD = 10 ** 18; + uint256 constant RAY = 10 ** 27; + + function sub(uint256 x, uint256 y) internal pure returns (uint256 z) { + require((z = x - y) <= x); + } + function mul(uint256 x, uint256 y) internal pure returns (uint256 z) { + require(y == 0 || (z = x * y) / y == x); + } + function rmul(uint256 x, uint256 y) internal pure returns (uint256 z) { + z = mul(x, y) / RAY; + } + function rdiv(uint256 x, uint256 y) internal pure returns (uint256 z) { + z = mul(x, RAY) / y; + } + + // --- Admin --- + function file(bytes32 what, uint256 data) external auth { + if (what == "buf") buf = data; + else if (what == "tail") tail = data; + else if (what == "cusp") cusp = data; + else if (what == "dust") dust = data; + else revert("Clapper/file-unrecognized-param"); + emit File(what, data); + } + + function file(bytes32 what, address data) external auth lock { + if (what == "spotter") spotter = data; + else if (what == "pip") pip = data; + else if (what == "calc") calc = AbacusLike(data); + else revert("Clapper/file-unrecognized-param"); + emit File(what, data); + } + + // --- Auction --- + function kick( + uint256 lot, + uint256 // For compatibility with actual vow + ) external auth lock isStopped(1) returns (uint256 id) { + require(live == 1, "Clapper/not-live"); + require(kicks < uint256(-1), "Clapper/overflow"); + id = ++kicks; + + sales[id].lot = lot; + sales[id].tic = block.timestamp; + uint256 dip = rmul(getFeedPrice(), buf); + require(dip > 0, "Clapper/zero-dip-price"); + sales[id].dip = dip; + + vat.move(msg.sender, address(this), lot); + + emit Kick(id, lot, dip); + } + + // We might want to remove this function and the values tail and cusp. + // Probably also the functions status and getStatus would be a bit useless. + // The reasoning behind this is in this case the system is not in a hurry to + // auction the lot, so it can wait until the price catches the market one. + // It would only require a yank function that allows governance to reset a + // specific auction just in case it is necessary. + function redo( + uint256 id // id of the auction to reset + ) external lock isStopped(2) { + uint256 lot = sales[id].lot; + require(lot > 0, "Clapper/not-running-auction"); + + (bool done,) = status(sales[id].tic, sales[id].dip); + require(done, "Clapper/cannot-reset"); + + sales[id].tic = block.timestamp; + + uint256 dip = rmul(getFeedPrice(), buf); + require(dip > 0, "Clapper/zero-dip-price"); + sales[id].dip = dip; + + emit Redo(id, lot, dip); + } + + function take( + uint256 id, + uint256 lot, + uint256 min, + address who, + bytes calldata data + ) external lock isStopped(3) { + require(live == 1, "Clapper/not-live"); + require(sales[id].lot > 0, "Clapper/not-running-auction"); + + uint256 tic = sales[id].tic; + (bool done, uint256 price) = status(tic, sales[id].dip); + require(!done, "Clapper/needs-reset"); + + require(lot <= sales[id].lot, "Clapper/lot-not-matching"); + require(min <= price, "Clapper/bid-not-higher"); + uint256 rLot = sales[id].lot - lot; + require(rLot >= dust, "Clapper/dusty-lot-left"); + + sales[id].lot = rLot; + vat.move(address(this), who, lot); + + uint256 slice = lot / price; + + if (data.length > 0 && who != address(vat) && who != address(this)) { + ClapperCallee(who).clapperCall(msg.sender, lot, slice, data); + } + + gem.burn(msg.sender, slice); + + emit Take(id, min, price, lot, slice); + } + + function getFeedPrice() internal returns (uint256 feedPrice) { + (bytes32 val, bool has) = PipLike(pip).peek(); + require(has, "Clapper/invalid-price"); + feedPrice = rdiv(mul(uint256(val), BLN), SpotterLike(spotter).par()); + } + + // Internally returns boolean for if an auction needs a redo + function status(uint256 tic, uint256 dip) internal view returns (bool done, uint256 price) { + price = calc.price(dip, sub(block.timestamp, tic)); + done = (sub(block.timestamp, tic) > tail || rdiv(price, dip) > cusp); + } + + // Externally returns boolean for if an auction needs a redo and also the current price + function getStatus(uint256 id) external view returns (bool needsRedo, uint256 price, uint256 lot) { + bool done; + (done, price) = status(sales[id].tic, sales[id].dip); + lot = sales[id].lot; + needsRedo = lot > 0 && done; + } + + function cage( + uint256 rad + ) external auth lock { + live = 0; + vat.move(address(this), msg.sender, rad); + } + + // Only worth it if the redo function is removed + // function yank( + // uint id + // ) external auth lock { + // require(live == 1, "Clapper/already-caged"); + // uint256 lot = sales[id].lot; + // require(lot > 0, "Clapper/not-running-auction"); + // vat.move(address(this), msg.sender, lot); + // delete sales[id]; + // } +} diff --git a/src/test/clap.t.sol b/src/test/clap.t.sol new file mode 100644 index 00000000..c8941c95 --- /dev/null +++ b/src/test/clap.t.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +pragma solidity >=0.5.12; + +import "ds-test/test.sol"; +import {DSValue} from "ds-value/value.sol"; +import {DSToken} from "ds-token/token.sol"; +import {Clapper} from "../clap.sol"; +import {Vat} from "../vat.sol"; +import {Spotter} from "../spot.sol"; +import {StairstepExponentialIncrease} from "../abaci.sol"; +import {Spotter} from "../spot.sol"; + + +interface Hevm { + function warp(uint256) external; +} + +contract Guy { + Clapper clap; + constructor(Clapper clap_) public { + clap = clap_; + Vat(address(clap.vat())).hope(address(clap)); + DSToken(address(clap.gem())).approve(address(clap)); + } + function take(uint256 id, uint256 lot, uint256 min, address who, bytes calldata data) public { + clap.take(id, lot, min, who, data); + } + function try_take(uint256 id, uint256 lot, uint256 min, address who, bytes calldata data) + public returns (bool ok) + { + string memory sig = "take(uint256,uint256,uint256,address,bytes)"; + (ok,) = address(clap).call(abi.encodeWithSignature(sig, id, lot, min, who, data)); + } +} + +contract ClapperTest is DSTest { + Hevm hevm; + + Clapper clap; + Vat vat; + Spotter spotter; + DSValue pip; + DSToken gem; + + address ali; + address bob; + + uint256 constant mkrPrice = 5000 ether; + + function ray(uint256 wad) internal pure returns (uint256) { + return wad * 10 ** 9; + } + + function rad(uint256 wad) internal pure returns (uint256) { + return wad * 10 ** 27; + } + + function setUp() public { + hevm = Hevm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + hevm.warp(604411200); + + vat = new Vat(); + spotter = new Spotter(address(vat)); + vat.rely(address(spotter)); + gem = new DSToken(''); + + pip = new DSValue(); + pip.poke(bytes32(mkrPrice)); + + clap = new Clapper(address(vat), address(gem)); + + StairstepExponentialIncrease calc = new StairstepExponentialIncrease(); + calc.file("gain", ray(1.01 ether)); // 1% increment + calc.file("step", 90); // Increment every 90 second + + clap.file("spotter", address(spotter)); + clap.file("pip", address(pip)); + clap.file("calc", address(calc)); + + clap.file("buf", ray(0.80 ether)); // 80% of current price as Initial + clap.file("cusp", ray(1.3 ether)); // 30% increment before reset + clap.file("tail", 1 hours); // 1 hour before reset + + ali = address(new Guy(clap)); + bob = address(new Guy(clap)); + + vat.hope(address(clap)); + gem.approve(address(clap)); + + vat.suck(address(this), address(this), rad(100_000 ether)); + + gem.mint(1000 ether); + gem.setOwner(address(clap)); + + gem.push(ali, 200 ether); + gem.push(bob, 200 ether); + } + + function test_kick() public { + assertEq(vat.dai(address(this)), rad(100_000 ether)); + assertEq(vat.dai(address(clap)), 0 ether); + uint256 id = clap.kick(rad(10_000 ether), 0); + assertEq(vat.dai(address(this)), rad(90_000 ether)); + assertEq(vat.dai(address(clap)), rad(10_000 ether)); + (uint256 lot, uint256 dip, uint256 tic) = clap.sales(id); + assertEq(lot, rad(10_000 ether)); + assertEq(dip, ray(4_000 ether)); // 5,000 (mkrPrice) * 0.8 (buf) + assertEq(tic, block.timestamp); + } + + function test_take() public { + uint256 id = clap.kick(rad(10_000 ether), 0); + + Guy(ali).take({ + id: id, + lot: rad(6_000 ether), + min: ray(4_000 ether), // starting price + who: address(ali), + data: "" + }); + + // bid taken from bidder + assertEq(gem.balanceOf(ali), 200 ether - 6_000 ether / 4_000); + assertEq(vat.dai(address(ali)), rad(6_000 ether)); + + hevm.warp(block.timestamp + 90); + + (uint256 lot, uint256 dip, uint256 tic) = clap.sales(id); + assertEq(lot, 4_000 * 10**45); // 10_000 - 6_000 + + (bool needsRedo, uint256 price, uint256 lot2) = clap.getStatus(id); + assertEq(lot2, lot); + assertTrue(!needsRedo); + assertEq(price, 4_040 * 10**27); // 4,000 * 1%^(90/90) + + Guy(bob).take({ + id: id, + lot: rad(4_000 ether), + min: price, + who: address(bob), + data: "" + }); + assertEq(gem.balanceOf(bob), 200 ether - 4_000 ether * 10 ** 27 / price); + assertEq(vat.dai(address(bob)), rad(4_000 ether)); + + (lot, dip, tic) = clap.sales(id); + assertEq(lot, 0); + } + + function test_redo_tail() public { + clap.file("cusp", ray(2 ether)); // High limit so it triggers due tail and not cusp + uint256 id = clap.kick(rad(10_000 ether), 0); + + (bool needsRedo, uint256 price,) = clap.getStatus(id); + assertTrue(!needsRedo); + assertEq(price, ray(mkrPrice * clap.buf() / 10**27)); + + hevm.warp(block.timestamp + clap.tail()); + (needsRedo,,) = clap.getStatus(id); + assertTrue(!needsRedo); + + hevm.warp(block.timestamp + 1); + (needsRedo, price,) = clap.getStatus(id); + assertTrue(needsRedo); + (, uint256 dip,) = clap.sales(id); + assertTrue(price * 10**27 / dip <= clap.cusp()); // Not failing due price/cusp + + pip.poke(bytes32(uint256(5_200 ether))); // 200 higher at the redo moment + + clap.redo(id); + (needsRedo, price,) = clap.getStatus(id); + assertTrue(!needsRedo); + assertEq(price, ray(5_200 ether * clap.buf() / 10**27)); + } + + function test_redo_price() public { + uint256 id = clap.kick(rad(10_000 ether), 0); + + (bool needsRedo, uint256 price,) = clap.getStatus(id); + assertTrue(!needsRedo); + assertEq(price, ray(mkrPrice * clap.buf() / 10**27)); + + hevm.warp(block.timestamp + 30 minutes); + (needsRedo,,) = clap.getStatus(id); + assertTrue(!needsRedo); + + hevm.warp(block.timestamp + 20 minutes); + (needsRedo, price,) = clap.getStatus(id); + assertTrue(needsRedo); + (, uint256 dip,) = clap.sales(id); + assertTrue(price * 10**27 / dip > clap.cusp()); // Fails due price/cusp (time passed lower than tail) + + pip.poke(bytes32(uint256(5_200 ether))); // 200 higher at the redo moment + + clap.redo(id); + (needsRedo, price,) = clap.getStatus(id); + assertTrue(!needsRedo); + assertEq(price, ray(5_200 ether * clap.buf() / 10**27)); + } + + function testFail_redo() public { + uint256 id = clap.kick(rad(10_000 ether), 0); + hevm.warp(block.timestamp + 30 minutes); + clap.redo(id); + } +}