diff --git a/packages/nouns-contracts/contracts/NounsAuctionHouseV2.sol b/packages/nouns-contracts/contracts/NounsAuctionHouseV2.sol index 1ceacec99..d316873b0 100644 --- a/packages/nouns-contracts/contracts/NounsAuctionHouseV2.sol +++ b/packages/nouns-contracts/contracts/NounsAuctionHouseV2.sol @@ -29,6 +29,7 @@ import { ReentrancyGuardUpgradeable } from '@openzeppelin/contracts-upgradeable/ import { OwnableUpgradeable } from '@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol'; import { IERC20 } from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; import { INounsAuctionHouseV2 } from './interfaces/INounsAuctionHouseV2.sol'; +import { IStreamEscrow } from './interfaces/IStreamEscrow.sol'; import { INounsToken } from './interfaces/INounsToken.sol'; import { IWETH } from './interfaces/IWETH.sol'; @@ -69,6 +70,12 @@ contract NounsAuctionHouseV2 is /// @notice The Nouns price feed state mapping(uint256 => SettlementState) settlementHistory; + uint16 public immediateTreasuryBps; + + uint16 public streamLengthInAuctions; + + IStreamEscrow public streamEscrow; + constructor(INounsToken _nouns, address _weth, uint256 _duration) initializer { nouns = _nouns; weth = _weth; @@ -284,8 +291,18 @@ contract NounsAuctionHouseV2 is nouns.transferFrom(address(this), _auction.bidder, _auction.nounId); } - if (_auction.amount > 0) { - _safeTransferETHWithFallback(owner(), _auction.amount); + uint256 amountToSendTreasury = (_auction.amount * immediateTreasuryBps) / 10_000; + uint256 amountToStream = _auction.amount - amountToSendTreasury; + + if (amountToSendTreasury > 0) { + _safeTransferETHWithFallback(owner(), amountToSendTreasury); + } + + // TODO maybe separate in case there's no winner and no auction.amount? + if (amountToStream > 0) { + streamEscrow.createStreamAndForwardAll{ value: amountToStream }(_auction.nounId, streamLengthInAuctions); + } else { + streamEscrow.forwardAll(); } SettlementState storage settlementState = settlementHistory[_auction.nounId]; diff --git a/packages/nouns-contracts/contracts/StreamEscrow.sol b/packages/nouns-contracts/contracts/StreamEscrow.sol new file mode 100644 index 000000000..ff0d61be4 --- /dev/null +++ b/packages/nouns-contracts/contracts/StreamEscrow.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0 + +/// @title Stream Escrow + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +pragma solidity ^0.8.19; + +import { IStreamEscrow } from './interfaces/IStreamEscrow.sol'; +import { INounsToken } from './interfaces/INounsToken.sol'; + +contract StreamEscrow is IStreamEscrow { + struct Stream { + uint256 ethPerAuction; + bool canceled; + uint256 streamEndId; + } + + address public daoTreasury; + address public auctionHouse; + INounsToken public nounsToken; // TODO immutable? + + uint256 public ethStreamedPerAuction; + uint256 public ethStreamedToDAO; + uint256 public ethWithdrawn; + mapping(uint256 streamEndId => uint256[] streamIds) public streamEndIds; + mapping(uint256 streamId => Stream) streams; + uint256 public auctionsCounter; + + constructor(address daoTreasury_, address auctionHouse_, address nounsToken_) { + daoTreasury = daoTreasury_; + auctionHouse = auctionHouse_; + nounsToken = INounsToken(nounsToken_); + } + + function createStreamAndForwardAll(uint256 nounId, uint16 streamLengthInAuctions) external payable { + require(msg.sender == auctionHouse, 'only auction house'); + + // register new stream + uint256 streamEndId = auctionsCounter + streamLengthInAuctions + 1; + streamEndIds[streamEndId].push(nounId); + + // TODO: check for rounding issues. probably best to immediately vest the rounded down amount + uint256 ethPerAuction = msg.value / streamLengthInAuctions; + ethStreamedPerAuction += ethPerAuction; + streams[nounId] = Stream({ ethPerAuction: ethPerAuction, canceled: false, streamEndId: streamEndId }); + + forwardAll(); + } + + // used for example when there were no bids on a noun + function forwardAll() public { + require(msg.sender == auctionHouse, 'only auction house'); + + auctionsCounter++; + finishStreams(); + ethStreamedToDAO += ethStreamedPerAuction; + } + + // TODO add versions with uint256[] nounIds? + function cancelStream(uint256 nounId) external { + // transfer noun to treasury + nounsToken.transferFrom(msg.sender, daoTreasury, nounId); + + // cancel stream + require(!streams[nounId].canceled, 'already canceled'); + streams[nounId].canceled = true; + ethStreamedPerAuction -= streams[nounId].ethPerAuction; + + // calculate how much needs to be refunded + // TODO: assuming lastSeenNounId < streamEndId, but need to handle the other case + uint256 auctionsLeft = streams[nounId].streamEndId - auctionsCounter - 1; + uint256 amountToRefund = streams[nounId].ethPerAuction * auctionsLeft; + (bool sent, ) = msg.sender.call{ value: amountToRefund }(''); + require(sent, 'failed to send eth'); + } + + function withdrawToTreasury(uint256 amount) external { + require(msg.sender == daoTreasury); + require(amount >= (ethStreamedToDAO - ethWithdrawn), 'not enough to withdraw'); + ethWithdrawn += amount; + (bool sent, ) = daoTreasury.call{ value: amount }(''); + require(sent, 'failed to send eth'); + } + + function setDAOTreasuryAddress(address newAddress) external { + require(msg.sender == daoTreasury); + daoTreasury = newAddress; + } + + function finishStreams() internal { + uint256[] storage endingStreams = streamEndIds[auctionsCounter]; + for (uint256 i; i < endingStreams.length; i++) { + uint256 streamId = endingStreams[i]; + if (!streams[streamId].canceled) { + ethStreamedPerAuction -= streams[streamId].ethPerAuction; + } + } + } +} diff --git a/packages/nouns-contracts/contracts/interfaces/IStreamEscrow.sol b/packages/nouns-contracts/contracts/interfaces/IStreamEscrow.sol new file mode 100644 index 000000000..af8f9a216 --- /dev/null +++ b/packages/nouns-contracts/contracts/interfaces/IStreamEscrow.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0 + +/// @title Interface for Stream Escrow + +/********************************* + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░██░░░████░░██░░░████░░░ * + * ░░██████░░░████████░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░██░░██░░░████░░██░░░████░░░ * + * ░░░░░░█████████░░█████████░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ * + *********************************/ + +pragma solidity ^0.8.19; + +interface IStreamEscrow { + function createStreamAndForwardAll(uint256 nounId, uint16 streamLengthInAuctions) external payable; + + function forwardAll() external; +} diff --git a/packages/nouns-contracts/test/foundry/StreamEscrow.t.sol b/packages/nouns-contracts/test/foundry/StreamEscrow.t.sol new file mode 100644 index 000000000..ef29d53d1 --- /dev/null +++ b/packages/nouns-contracts/test/foundry/StreamEscrow.t.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: GPL-3.0 +pragma solidity ^0.8.19; + +import { Test } from 'forge-std/Test.sol'; +import { StreamEscrow } from '../../contracts/StreamEscrow.sol'; +import { ERC721Mock } from './helpers/ERC721Mock.sol'; + +contract StreamEscrowTest is Test { + StreamEscrow escrow; + address treasury = makeAddr('treasury'); + address auctionHouse = makeAddr('auctionHouse'); + ERC721Mock nounsToken = new ERC721Mock(); + address user = makeAddr('user'); + + function setUp() public { + escrow = new StreamEscrow(treasury, auctionHouse, address(nounsToken)); + + vm.deal(auctionHouse, 1000 ether); + } + + function testSingleStream() public { + vm.prank(auctionHouse); + escrow.createStreamAndForwardAll{ value: 10 ether }({ nounId: 1, streamLengthInAuctions: 20 }); + + // check that one 'tick' has been streamed + assertEq(escrow.ethStreamedToDAO(), 0.5 ether); + + for (uint i; i < 3; i++) { + vm.prank(auctionHouse); + escrow.forwardAll(); + } + + assertEq(escrow.ethStreamedToDAO(), 2 ether); + + vm.prank(treasury); + escrow.withdrawToTreasury(2 ether); + + // forward past the point of stream ending + for (uint i; i < 20; i++) { + vm.prank(auctionHouse); + escrow.forwardAll(); + } + + assertEq(escrow.ethStreamedToDAO(), 10 ether); + } + + function testCancelStream() public { + nounsToken.mint(user, 1); + + vm.prank(auctionHouse); + escrow.createStreamAndForwardAll{ value: 10 ether }({ nounId: 1, streamLengthInAuctions: 20 }); + + for (uint i; i < 3; i++) { + vm.prank(auctionHouse); + escrow.forwardAll(); + } + + assertEq(escrow.ethStreamedToDAO(), 2 ether); + + vm.prank(user); + nounsToken.approve(address(escrow), 1); + vm.prank(user); + escrow.cancelStream(1); + + // make sure moving forward works with canceled streams + for (uint i; i < 20; i++) { + vm.prank(auctionHouse); + escrow.forwardAll(); + } + } +}