Edit 2024-10-14: DAO can allow additional contracts to create streams. This is in order to allow selling of treasury nouns outside the auction.
In this post we describe an updated spec to the stream based minority protection we introduced in the previous post.
Nouns DAO auctions an NFT every day. Part of the auction proceeds are sent immediately to the DAO treasury and the rest are streamed over time. For the purpose of this document, we will refer to the escrow contract as StreamEscrow.
For more context please see this Nouns Foundation post and our initial post about this design.
Alice wins the daily auction for 10 ETH.
Upon auction settlement:
An initial amount of 2 ETH (20%) is paid immediately to the DAO.
The remaining 8 ETH are streamed from Alice to the DAO over 1500 days.
All newly vested amounts from all streams are sent to the DAO at each auction settlement.
The stream is managed by a non-upgradeable contract, not in control of the DAO (or anyone else).
Alice cancels her stream after 375 days (25% of the stream length), by returning her Noun and withdrawing her unstreamed funds, which come out to 6 ETH.
All streams are managed via a single StreamEscrow contract. All the streams’ funds are deposited into this single contract. It maintains internal accounting in order to know how many funds belong to which stream, and how much has already been streamed to the DAO.
The reason for a single contract as opposed to a contract per stream, is because it is significantly more gas efficient.
In the example above, once the stream is created, 8 ETH are transferred to StreamEscrow. The Auction House contract notifies StreamEscrow every time an auction is settled and another chunk of the funds are streamed to the DAO.
To create a stream for a Noun, the account creating the stream must meet two conditions at the same time:
Be on the StreamEscrow contract allowed accounts list, and
Be the owner of the Noun in question, or approved to transfer the Noun in question by its owner.
The primary use case is the Auction House contract creating streams upon auction settlement. The Auction House will be allowed by the DAO starting at the proposal that upgrades Auction House to the new streams-supporting version. Auction House is also the owner of each new Noun just before its sent to the auction winner, so it will meet these two conditions consistently.
Additional use cases might include selling treasury-owned Nouns outside the auction mechanism. In such cases we might create a helper contract that will be allowed, and the DAO will approve such a helper contract to transfer some or all of its Nouns.
The right to cancel the stream is attached to the Noun NFT, i.e. if Alice transfers her Noun to Bob, then only Bob is allowed to cancel the stream and withdraw the unstreamed funds.
In order to cancel her stream, Alice calls a function on StreamEscrow which:
Transfers her Noun into the Nouns DAO treasury.
Cancels her stream so that it doesn’t stream any more funds to the DAO.
Sends unstreamed funds back to Alice.
At each tick of the StreamEscrow clock (every 24 hours or longer), all streams are advanced one step forward, and the newly vested amount is sent to the DAO immediately, as part of the same clock-forwarding transaction.
Upon every transfer of ETH to the DAO an event is emitted with the ETH amount; this is the primary means of tracking streams revenues.
The DAO can pass a proposal to change the address to which ETH should be sent; this flexibility can be used in future for example to run revenues through a tax withholding helper contract, or if the DAO chooses to move to a new treasury / executor contract.
Any Noun owner whose Noun has an active stream can choose to fast-forward their stream by some amount of ticks, all the way up to sending the full value of their stream to the DAO immediately. This is helpful when a Noun owner wants to accelerate their funding of Nouns DAO. The primary downside of fast-forwarding is the reduction in minority protection.
Nouns that were auctioned before this change was introduced don’t have any stream attached to them.
Nounder Nouns will not have any refund available, since none of them are paid for and therefore will not have an escrowed stream.
Auctions and streams are all conducted in ETH.
Upgradeable and under DUNA control: Yes.
Initial payment percentage
Value: 20%.
Can it be modified: Yes, via DAO proposal.
Stream duration:
Value: 1500 ticks.
Can it be modified: Yes, via DAO proposal.
Only affects new streams. Existing streams cannot be modified.
StreamEscrow contract address
Upgradeable and under DUNA control: No.
DAO executor address, i.e. who’s the admin of this contract.
Value: current treasury.
Can it be modified: Yes, via DAO proposal.
ETH recipient address, i.e. where vested funds are sent.
Value: current treasury.
Can it be modified: Yes, via DAO proposal.
Nouns recipient address, i.e. where Nouns are sent when streams are canceled.
Value: current treasury.
Can it be modified: Yes, via DAO proposal.
The list of accounts allowed to create streams
Value: starts with just Auction House.
Can it be modified: Yes, via DAO proposal.
Introducing refunds via escrowed streams requires the following:
Changing Auction House.
Deploying the new StreamEscrow contract.
Disabling the Fork.
The gist of the change is that auction proceeds are split, such that some are sent to the DAO, most remaining funds are streamed to the DAO over time. The StreamEscrow clock can be advanced every 24 hours or longer. For convenience, we are “piggybacking” on the Auction House daily settlement transaction, as Nouns’ reliable daily transaction, thus avoiding the need to otherwise automate these stream clock forwarding transactions.
The code can be found here.
The primary consideration is that when streams vest, the recipient (DAO) should recognize revenue, and tying vesting to the auction achieves two positive things for the DAO:
Legibility: instead of implicit revenue that’s hard to track, it occurs as part of a transaction, where we can emit events and index them and make them easily trackable.
External call piggyback: to have this legibility, someone needs to initiate a transaction; instead of funding bots or humans to do this for us, we can use Nouns’ existing recurring transaction.
The “implicit” alternative seems bad to us. Without explicit transactions, say just vesting every 24 hours or every block, accounting becomes more challenging. We would have to build new tooling that would check the state of streams at specific blocks and store that information in a dedicated database in order to keep track.
The Auction House change is straightforward: upon auction settlement, instead of sending 100% of the winning bid directly to the DAO’s treasury, the new code will:
Send 20% directly to the treasury.
Send 80% to StreamEscrow and register a new stream, with an end time articulated in the number of ticks from today.
Notify StreamEscrow that an auction has settled in order to advance all streams by one “tick” (i.e. one auction).
StreamEscrow (SE) performs its accounting using a few key variables:
minimumTickDuration
: the minimum amount of time that needs to pass between ticks; we intend to set it to 24 hours. This variable is immutable.
ethStreamPerTick
: the amount of ETH streamed to the DAO at each “tick” (i.e. every 24 or longer, when forwardAll
is called; default is at auction settlement).
currentTick
: how many ticks have occurred since this contract started handling streams.
lastForwardTimestamp
: the last time a tick occurred; used to enforce the minimum tick duration.
ethStreamEndingAtTick
: a mapping from a tick to the rate of ETH vesting that should stop vesting once we reach that tick.
streams
: a mapping from noun ID to its stream information, including:
ethPerTick
: the amount of ETH to stream at each tick for this specific stream.
lastTick
: the tick at which this stream will end.
canceled
: whether the Noun owner canceled the stream or not.
allowedToCreateStream
: a mapping from an address to whether or not it’s allowed to create streams.
forwardAllAndCreateStream(uint256 nounId, uint16 streamLengthInTicks)
Requires the following (reverts if not true):
msg.sender
must be an approved caller, i.e. its value in allowedToCreateStream
is true
.
msg.sender
must be the owner of the input Noun, or approved by its owner.
Calls forwardAll()
to advance all active streams by one tick to the DAO (more info below).
Calculates when this nounId’s stream should end, i.e. at what tick value, using the input of stream length it gets from Auction House.
Calculates the new stream’s ethPerTick
value by dividing the stream amount (received as ETH sent in this function call) by the stream length input.
Adds the new stream’s ethPerTick
to the overall ethStreamPerTick
rate of streaming per tick for all streams.
Adds this new stream to the streams
contract-level mapping.
forwardAll()
Gracefully requires the following (stops execution without reverting if true, to not halt auction house execution):
minimumTickDuration
(24 hours) have elapsed since the last time all streams were forwarded.Increments currentTick
by one.
Transfers ETH to the DAO at the amount of ethStreamPerTick.
Emits the event ETHStreamedToDAO
with the amount of ETH streamed at this tick.
Handles finished streams (streams whose lastTick
value equals the current tick value): 3. For each such stream, decrements ethStreamPerTick
by the stream’s ethPerTick
.
createStream(uint256 nounId, uint16 streamLengthInTicks)
Requires the following (reverts if not true):
msg.sender
must be enabled via setAllowedToCreateStream.
msg.sender
must own the given Noun, or be approved by its owner.
There is no active stream for nounId.
Creates a new stream, without forwarding existing streams.
cancelStream(uint256 nounId)
Requires the following (reverts if not true):
msg.sender
is the owner of nounId.
The nounId stream hasn’t been canceled already.
The nounId stream hasn’t ended yet.
Sends Noun with nounId
from the owner to the DAO treasury.
Decrements ethStreamPerTick
by the stream’s ethPerTick
.
Calculates and sends msg.sender
the refund amount (ethPerTick
times ticks left until the stream ends).
Emits the event StreamCanceled
with the amount of ETH sent back to the Noun owner.
setDAOExecutorAddress(address treasury)
Requires the following (reverts if not true):
msg.sender
is the DAO executor (treasury).Sets the executor address to the new value.
setAllowedToCreateStream(address contract, bool allow)
Requires msg.sender
to be the DAO executor address.
Enables/disables the contract to call createStream
setETHRecipient(address newAddress)
Requires msg.sender
to be the DAO executor address.
Sets ethRecipient
to the new address; future vested ETH gets sent to this new address.
setNounsRecipient(address newAddress)
Requires msg.sender
to be the DAO executor address.
Sets nounsRecipient
to the new address; future Nouns from canceled streams get sent to this new address.
To disable the Fork, the DAO will need to pass a proposal calling the DAO logic function _setForkThresholdBPS
with a new value of 10,000, such that it becomes impossible to fork.