Introduction
Ditto Protocol is a trustless and governance-free NFT Futures protocol.
Protocol Overview
Interested buyers of an NFT can mint or buy a Future, called a Clone.
Each clone encodes –
- an NFT collection,
- a specific token or the floor of that NFT collection,
- its current worth,
- minimum bid amount to purchase the clone decided by the smart contract.
If a bidder bids on a clone with an amount greater than the minimum bid amount, the clone is transferred to them from the current owner. On each Clone transfer, a fee is taken from the bid amount which is split between the previous holder of the Clone and a separate pool.
A clone represents a claim to receive the NFT when it’s sold via Ditto Protocol. A clone can be a floor Future, which means its worth is the minimum amount the NFT seller gets.
As that Clone gets traded, the pool size increases, incentivizing the NFT holder to sell their NFT via the Ditto Protocol.
Protocol features –
- Ditto protocol is fully trustless and governance free. All the parameters are hardcoded in the contract with no way to change it after deployment.
- There is no protocol fee, and any other fee is to incentivize protocol participation.
- We are looking at Verifiable Delay Functions (VDF) to combat front running.
- TWAP oracle for clone prices.
- On a clone transfer from a bidder, in addition to receiving a part of the fee from the higher bid, they’ll receive an on-chain NFT. We believe this will act as an additional incentive to participate in the protocol, and we want to work with an artist to make these NFTs something people will want to own.
The protocol is inspired by the SALSA concept from radicalxchange.
Gitcoin Grant
If you want to support us, we are accepting funding via our Gitcoin grant.
Reference
FAQs
General
What is Ditto?
Ditto a is a decentralized and permissionless protocol used to create tradable non-fungible futures of non-fungible tokens. Anyone may create a future, called a clone, for any NFT or purchase one that has already been created at any time.
How does Ditto work?
Ditto operates using a hybrid auction mechanism, innspired by SALSA and dutch auctions.
A clone of an underlying NFT may be created by anyone at anytime at a self-assessed price. Once the clone is created it is put up for auction similar to SALSA, however the Ditto smart contract calculates the minimum price one must pay to take ownership of the clone, starting at double the clone's assessed price and decreasing towards the assessed price over a set period of time. This auction resets whenever a clone is traded via this mechanism.
While a clone exists for an underlying NFT this effectively gives its owner an option to sell the NFT through Ditto. When the underlying NFT is sold through Ditto its clone is burned, the seller receives the funds represented by the clone, and the clone owner receives the underlying NFT.
What is a clone?
A clone is an ERC-721 non-fungible token representing the right receive a particular NFT when it is sold via the Ditto protocol. The clone may be traded freely and interface with any applications accepting ERC-721 tokens.
What fees are associated with Ditto?
When a clone is created a fee is set aside from the position's value, as well as any time the clone is subsequently traded via Ditto's auction mechanism. The fee can range between %0.04882887 and %12.451361868 depending on how frequently it is traded via Ditto's auctions.
Why sell an NFT through Ditto?
Whenever a clone is traded via Ditto's auction mechanism a fee is taken and pooled towards the underlying NFT. When the underlying NFT is sold via Ditto the pooled fees are transferred to the NFT seller along with the value of the clone.
What is SALSA?
Salsa is an auction mechanism from radicalxchange. You can read more about it here.
What is a dutch auction?
An auction where the selling price starts high and descends until a participant accepts the price.
Protocol Documentation
Ditto Auction Mechanism & Heat
Auctions
When a Ditto clone is created it is up for auction at all times via SALSA.
A clone has a self-assessed value called its worth
. When the clone is minted its auction price is calculated as 2 * worth
with its price descending until it is equal to worth
over a set dutch auction period. Once the dutch auction period has finished the minimum price to purchase the clone is its worth
until it is sold via the auction.
After the clone is sold through the auction mechanism, the dutch auction period is then reset for the process to start over.
Heat
heat
is a variable of each clone determining fee amounts, and dutch auction period lengths. The heat
of a clone is increased by 1
anytime a larger position is opened on a clone, and decreases relative to how much time has elapsed during its dutch auction.
heat
has a maximum value of 255
.
If a clone's heat is 200 and half it's dutch auction period has elapsed when a larger position is opened on the underlying then its heat
will become 100 + 1
, halved because of the elapsed time and + 1
because a larger positioned was opened on top of it.
Fee Calculation
Fees are calculated as heat * 32 / 2**16-1
. The minimum fee to be paid on an auction is %0.04882887
while the maximum fee is %12.451361868
as heat
cannot exceed 255
.
Dutch Auction Length Curve
Dutch auction length periods increase in time relative to heat according to this formula:
2^18 + ( (1 day) * 7 * log_2(cbrt(heat)) * sqrt(heat) )
Dutch auction lengths can range between ~3 days to ~297 days. These auction lengths give a clone owner a reasonable assurance that they will own the position on the underlying until another outbids them for a higher value position.
Token Ejector
If you are developing a smart contract that plans to hold Ditto clones, it is worth considering implementing the token ejector.
interface IERC721TokenEjector {
function onERC721Ejected(
address operator,
address to,
uint256 id,
bytes calldata data
) external returns (bytes4);
}
Because a clone can be force transferred to a new owner via the Ditto auction mechanism, the Ditto contract will try to call the onERC721Ejected
function on the previous owner.
if (from.code.length != 0) {
try IERC721TokenEjector(from).onERC721Ejected{gas: 30000}(address(this), to, id, "") {}
catch {}
}
This will allow any smart contract that implements the token ejector a chance to do their own accounting or enact any necessary actions when the clone is force transferred from them.
The function call is given up to a 30_000
gas stipend to accomplish its goals. This should be more than enough gas to get anything reasonable done.
If your contract does not implement onERC721Ejected
the forced transfer will proceed as intended in any case.
It is highly recommended when implementing onERC721Ejected
to check that msg.sender
and/or operator
are coming from the expected address. Not properly checking these could result in a potential exploit or unintended behaviour.
Multidimensional Clones
Ditto Protocol creates a queue of clones. Each clone in that queue represents an eventual claim on some unique NFT. The clone at the front of the queue has the immediate claim on the underlying NFT when sold through the protocol.
As soon as this trade happens, the NFT is transferred to that clone owner, and the clone is burned.
Here's a more technical description for accuracy -
A queue Q
is identified by a parameter protoId
which is a hash of
- the underlying
ERC721
/ERC1155
contract address, sayA
, tokenId
ofA
, sayid
,- the
ERC20
contract, sayB
, - and a boolean
floor
.
All together, this means that the clones part of this queue want a claim on the NFT represented by address A
with tokenId id
, and they are bidding using ERC20 token B
. If floor
is true
, the clones are not bidding for any specific token in the NFT collection. In this case, id
must have a constant value - FLOOR_ID
.
A (protoId, index)
tuple uniquely identifies a clone, where index
refers to the 0
-indexed position in the queue. In fact, cloneId
is defined as the hash of protoId
and index
.
A bidder is allowed to bid on an existing clone, or a new clone which is pushed at the end of the queue. If no clone is dissolved, the index of the clones in the queue will be 0,1,2,...
and so on. But since, a clone can be dissolved, at any moment the queue will be missing the indexes corresponding to the dissolved clones.
Next, we go into detail on the actions you can take through Ditto protocol:
Duplicate
Open or buy out a future on a particular NFT or floor Future of an ERC721 or ERC1155 collection. The ownership of this Future is represented via an ERC1155 token called a clone
. The caller specifies:
_ERC721Contract
: NFT contract, also referred to as the "Underlying NFT"._tokenId
: the specific token ID of the NFT contract. For floors,_tokenId
has to beFLOOR_ID
._ERC20Contract
: ERC20 token which is used to buy the clone,_amount
: amount of ERC20 token to buy the clone. Has to be at least the amount returned bygetMinAmountForCloneTransfer(cloneId)
,floor
: a boolean value determining if the purchase if for a floor Future.index
: position in the queue of the claim on the underlying NFT.
If the bid is successful, this call returns two parameters:
cloneId
: an ERC1155 token ID owned by the buyer.protoId
: identifier of the queue in which the clone was minted.
function duplicate(
address _ERC721Contract,
uint _tokenId,
address _ERC20Contract,
uint128 _amount,
bool floor,
uint index
) external returns (uint cloneId, uint protoId);
Dissolve
A clone owner can choose to burn the clone, which in effect, removes their claim on the underlying NFT.
- Their bid is returned after subtracting the subsidy and fee.
- The subsidy is passed to the clone next in the queue.
- The corresponding index is removed from the queue.
function dissolve(uint protoId, uint cloneId);
Underlying NFT Trade
If an underlying NFT owner decides to sell the NFT via Ditto, they just have to transfer it DittoMachine
contract. This triggers a trade where the NFT to the clone owner at the front of the queue, and the subsidy amount plus clone's worth is transferred to the seller. It also burns the clone and moves the head of the queue to the next clone.
Vouchers
The Ditto smart contract keeps track of vouchers
, a kind of receipt for having owned a clone with a position on an underlying NFT.
vouchers
are recorded any time a larger position on a clone's underlying NFT replaces the current position.
The important information about the position is hashed into a unique 32 byte identifier and stored in the contract. Anyone may read the smart contract to see if a voucher's identifier has been recorded. This allows for any party to construct a reward system or incentivize trading on top of Ditto.
Encoded Information
cloneId
The clone ID itself contains a lot of useful information.- The clone's
protoId
containing the NFT contract address, token ID, ERC20 address, and afloor
boolean. - The clone's Index.
- The clone's
- The recipient of the
voucher
. heat
property at the time.- The smaller position's value.
- The larger position's value.
- The time at which the smaller position was opened.
- The time at which the voucher was issued.
- If the clone was at the head of the auction que at time of voucher issue.
These variables should be helpful for any party interested in constructing incentives on top of Ditto.
Oracle
To track a clone's worth on-chain, Ditto protocol has a oracle built into its smart contract. The oracle design is the same as the Uniswap V3 oracle. Any time a clone's worth changes (duplicate, dissolve, trade), the current accumulated worth is stored with the current timestamp.
Note: Not all clones are tracked by the oracle. Only the clones at the front of a
protoId
queue are tracked.
Accumulated worth
Let's say due to some action, a clone's worth changes from \(w_{n}\) to \(w_{n+1}\) at timestamp \(t_{n}\). The current accumulated worth is \(W_{n}\) and was last updated at time \(T_{n}\). After the action is done, these are the new values: \[W_{n+1} = W_{n} + (w_n \times (t_{n}-T_{n}))\] \[T_{n+1} = t_{n}\]
The first time a clone is duplicated, the accumulated worth and current worth of a clone is 0
, so cumulative worth remains \(W_1\) remains 0
, and \(T_1\) is set to the current timestamp.
Effectively, accumulated worth is the sum of all the worths of a clone in the past weighted by the duration for which that worth remained unchanged.
Note: From the above equations, the following conclusions can be derived:
- the oracle update happens before the price change.
- If multiple updates happen in the same block, writing each of them to oracle is a no-op. So only the first update in a block is written to oracle.
Time weighted average worth
TWAP (time weighted average price) oracles are widely used to be secure from price manipulation attacks. Historical observations for a clone are persisted by oracle and only overwritten when the number of observations written exceeds a threshold (defined by cardinality). Initially, only 1 observation per clone is persisted, so a new update overwrites the previous observation. However, anyone can call grow(protoId, cardinality)
function to increase the cardinality for a clone up to a maximum of 65535. This means Ditto can store up to a maximum of 65535 observations for a clone.
Even though the technically correct term is Time weighted average worth (TWAW), we'll continue to use the term TWAP to refer to it.
Reading observations
To read from oracle, call observe(protoId, secondsAgos)
. secondsAgos
is an array of length n
. This call returns an array cumulativeWorth
of the same length n
.
cumulativeWorth[i]
represents the accumulated worth at time t-secondsAgos[i]
. Effectively, you can look back in the past and read the cumulative worth of a clone. We now dive into how different possible values of secondsAgos
are handled:
-
secondsAgos[i]
is0
: This returns the latest observation. -
secondsAgos[i]
points to a timestamp of an existing observation: This returns that specific observation. -
secondsAgos[i]
points to a time older than the timestamp of the oldest existing observation: This reverts. -
secondsAgos[i]
points to a time between 2 consecutive observations: This returns a time weighted cumulative worth of 2 consecutive observations.Let's say the two consecutive observations are: \(w_1, t_1\) and \(w_2, t_2\), and
secondsAgos[i]
refers to time \(t\). Then the time weight cumulative worth is \(w_1 + (w_2 \times (t-t_1))\). Effectively, this refers to the observation if it would have been written at time \(t\). -
secondsAgos[i]
points to a time newer than the timestamp of the newest observation: This returns a time weighted cumulative worth of the latest observation and current worth.Let's say the latest observation is: \(w_1, t_1\) and the current worth is \(w_2\), and
secondsAgos[i]
refers to time \(t\). Then the time weight cumulative worth is \(w_1 + (w_2 \times (t-t_1))\). Effectively, this refers to the observation if it would have been written at time \(t\). It's the same concept noted in the previous point.
Calculating TWAP
Now what callers would generally need is time weighted average worth between 2 timestamps \(t_0\) and \(t_1\); \(t_1\) being the current time. To get a TWAP:
- call
cw = observe(protoId, [t_0, t_1])
. (cw
refers to cumulative worth). - then TWAP is given by
(cw[1]-cw[0])/(t_1-t_0)
.
Effects of increasing time interval for TWAP
TWAP model is suggested for safety from any recent volatility or price manipulation attacks on Ditto clones. Increase in the time interval makes it less sensitive towards these kinds of attacks. However, this also makes the TWAP deviate from the market price. So the callers should determine a suitable time interval depending on the use case.