Top NFTs can cost tens of thousands or even millions of dollars. They are out of reach for most people and different teams are tackling this issue by fractionalizing a NFT. Fractionalization is the process of locking a NFT into a vault and then mints a number of fungible tokens as ERC-20 tokens. Fractionalization benefits both NFT owners and token buyers. Token buyers can now afford a fraction of the NFTs. NFT owners can understand how their NFTs are valued by selling parts of them and see some liquidity without selling their entire NFTs.
There are many teams tackling this problem and Fractional is one of the top projects in this space.
In the Fractional protocol, NFT owners can fractionalize their NFTs by creating a vault. Each vault holds a NFT basket (represented as a NFT token), which in itself holds a number of NFTs. NFT owners are given all the fractional ownership tokens and they can sell them to the token buyers.
Vault creators are entitled to receive an annual curator fee. The vault mints new fractional ownership tokens as curator fees. The protocol prevents the fee percentage to be too high by having a governance controlled maximum fee percentage parameter.
If one day someone wants to own the whole NFT basket, he can start an auction by sending an equal or greater amount of ETH than the reserve price to the vault. When the auction is complete, the auction winner receives the NFTs and fractional ownership token owners can claim the ETH paid.
Creating a vault and setting its parameters
In order to create a vault, the ERC721VaultFactory#mint
needs the following information:
name - vault name
symbol - vault symbol
token - NFT (or NFT basket) address
id - NFT id
listPrice - the initial NFT price
During the vault factory’s initialization, a ERC721TokenVault
contract is created and its logic is reused in each vault’s initialization in order to save gas and storage. Each vault is its own contract, but the logic is in one place only. The function encodes the initialization function signature with its arguments and then passes the calldata to a proxy, which does delegatecall
to the pre-created token vault contract. ERC721TokenVault#initialize
initializes the fractionalized ERC-20 tokens, mints the total supply to the curator and sets the total reserve price (list price x supply). After the vault is initialized, it transfers the NFT from the owner to the vault.
// calldata to pass onto the logic contract
bytes memory _initializationCalldata =
abi.encodeWithSignature(
"initialize(address,address,uint256,uint256,uint256,uint256,string,string)",
msg.sender,
_token,
_id,
_supply,
_listPrice,
_fee,
_name,
_symbol
);
// Delegatecall into the logic contract
(bool _ok, bytes memory returnData) =
_logic.delegatecall(_initializationCalldata);
// Transfer the NFT to the vault
IERC721(_token).safeTransferFrom(msg.sender, vault, _id);
Each token vault can be bought out through an auction and its default length is 7 days. The curator can update the vault’s auction length by calling ERC721TokenVault#updateAuctionLength
, but it has to be within the protocol setting’s allowed range.
function updateAuctionLength(uint256 _length) external {
require(msg.sender == curator, "update:not curator");
require(_length >= ISettings(settings).minAuctionLength() && _length <= ISettings(settings).maxAuctionLength(), "update:invalid auction length");
auctionLength = _length;
}
Curators can also update the vault’s fee by calling ERC721TokenVault#updateFee
. It has to be less than the protocol setting’s max curator fee percentage. As the curator fee is being updated, the curator also claims any fees in the vault.
function updateFee(uint256 _fee) external {
require(msg.sender == curator, "update:not curator");
require(_fee <= ISettings(settings).maxCuratorFee(), "update:cannot increase fee this high");
_claimFees();
fee = _fee;
}
Creating a NFT basket and depositing/withdrawing NFTs
In the token vault smart contract, each vault can hold one NFT only. Fractional allows a vault to hold multiple NFTs through the concept of NFT basket. A NFT basket is a NFT itself and it can own multiple NFTs due to the fact that it has an Ethereum address. To create a NFT basket, IndexERC721Factory#createBasket
is called to create a new IndexERC721
basket. It mints token 0 (the basket) to belong to the factory. The basket ownership is then transferred to the msg.sender
.
// In IndexERC721's constructor
_mint(msg.sender, 0);
// In IndexERC721Factory
basket.transferFrom(address(this), msg.sender, 0);
After the basket is created, NFTs can be deposited into the basket by calling IndexERC721#depositERC721
.
IERC721(_token).safeTransferFrom(msg.sender, address(this), _tokenId);
The basket owner or any approved users can also withdraw NFTs from the basket by calling IndexERC721#withdrawERC721
.
require(_isApprovedOrOwner(msg.sender, 0), "withdraw:not allowed");
IERC721(_token).safeTransferFrom(address(this), msg.sender, _tokenId);
Transferring vault tokens
Vault tokens are ERC-20 tokens and they can be freely traded between addresses. When a trade happens, the vault tokens are transferred from the liquidity pool to the buyer. The hook ERC721TokenVault#_beforeTokenTransfer
is defined which is run before each transfer. It does three things.
First, if the sender is transferring all his vault tokens, the vault burns his non-transferrable vault NFT, which represents his vault ownership.
if (balanceOf(_from) == _amount) {
nft.burn(_from);
}
Second, if the receiver does not own a vault NFT, the vault mints one for him.
if (balanceOf(_to) == 0) {
nft.mint(_to);
}
Finally, if there currently isn’t an auction and the sender’s desired sale price and the receiver’s desired sale price are different, it adjusts the reserve price based on the sender and receiver’s transfer amount weighted desired sale price.
if (toPrice != fromPrice) {
// new owner is not a voter
if (toPrice == 0) {
// get the average reserve price ignoring the senders amount
votingTokens -= _amount;
reserveTotal -= _amount * fromPrice;
}
// old owner is not a voter
else if (fromPrice == 0) {
votingTokens += _amount;
reserveTotal += _amount * toPrice;
}
// both owners are voters
else {
reserveTotal = reserveTotal + (_amount * toPrice) - (_amount * fromPrice);
}
}
Updating desired sale price
Vault token owners can update their desired sale price by calling ERC721TokenVault#updateUserPrice
. The reserve price is a weighted average of every vault token owner’s desired sale price based on their tokens. Users can add/remove/update their desired sale prices.
If there are no voting tokens or if the user is the only token owner, it sets the reserve price to the user’s weight times the desired price.
if (votingTokens == 0) {
votingTokens = weight;
reserveTotal = weight * _new;
} else if (weight == votingTokens && old != 0) {
reserveTotal = weight * _new;
}
If the user no longer wants to vote, the function removes the user’s weighted price from the reserve price as well as the number of voting tokens.
else if (_new == 0) {
votingTokens -= weight;
reserveTotal -= weight * old;
}
If the user is voting for the first time and the reserve price is already established by other token owners, the function checks that the user’s desired sale price is within the vault average reserve price’s minimum/maximum reserve factors before adding the weighted price to the reserve price.
else if (old == 0) {
uint256 averageReserve = reserveTotal / votingTokens;
uint256 reservePriceMin = averageReserve * ISettings(settings).minReserveFactor() / 1000;
require(_new >= reservePriceMin, "update:reserve price too low");
uint256 reservePriceMax = averageReserve * ISettings(settings).maxReserveFactor() / 1000;
require(_new <= reservePriceMax, "update:reserve price too high");
votingTokens += weight;
reserveTotal += weight * _new;
}
Similarly, if the user is updating desired sale price, the function does the same check, except it removes the old weighted price in its average reserve price calculation. The new weighted price is added to the reserve price and the old weighted price is removed from it.
else {
uint256 averageReserve = (reserveTotal - (old * weight)) / (votingTokens - weight);
uint256 reservePriceMin = averageReserve * ISettings(settings).minReserveFactor() / 1000;
require(_new >= reservePriceMin, "update:reserve price too low");
uint256 reservePriceMax = averageReserve * ISettings(settings).maxReserveFactor() / 1000;
require(_new <= reservePriceMax, "update:reserve price too high");
reserveTotal = reserveTotal + (weight * _new) - (weight * old);
}
Finally, it sets the user’s desired sale price in the userPrices
mapping.
userPrices[msg.sender] = _new;
Initiating an auction
An auction can be kicked off by calling ERC721TokenVault#start
. It is a payable
function as the function caller has to send ETH valued at least equal to the vault’s reserve price as the starting bid. There also has to be a consensus on the reserve price in order for the auction to start. It is checked by comparing the number of voting tokens and protocol’s minimum vote percentage.
require(auctionState == State.inactive, "start:no auction starts");
require(msg.value >= reservePrice(), "start:too low bid");
require(votingTokens * 1000 >= ISettings(settings).minVotePercentage() * totalSupply(), "start:not enough voters");
The auction’s ending timestamp is the current block timestamp plus the auction length. Its state is transitioned from inactive to live. The current price is set as msg.value
and the winning address is the msg.sender
.
auctionEnd = block.timestamp + auctionLength;
auctionState = State.live;
livePrice = msg.value;
winning = payable(msg.sender);
Bidding in an auction
Other bidders can submit their bids by calling ERC721TokenVault#bid
. Each bid has to increase the price by at least the protocol’s predefined minimum bid increase.
uint256 increase = ISettings(settings).minBidIncrease() + 1000;
require(msg.value * 1000 >= livePrice * increase, "bid:too low bid");
The auction is extended by 15 minutes if there is less than 15 minutes towards the end of the auction.
if (auctionEnd - block.timestamp <= 15 minutes) {
auctionEnd += 15 minutes;
}
The function then returns the current highest bidder his deposited ETH as WETH. Based on how the contract defines _sendETHOrWETH
, I believe WETH is sent instead of ETH to prevent malicious contracts from attacking the vault by defining a malicious fallback
function.
Ending an auction
A live auction that has passed its ending timestamp can be ended by calling ERC721TokenVault#end
. It transitions the auction from live
to ended
and transfers the vault’s NFT (or NFT basket) to the winning address.
require(auctionState == State.live, "end:vault has already closed");
require(block.timestamp >= auctionEnd, "end:auction live");
IERC721(token).transferFrom(address(this), winning, id);
auctionState = State.ended;
After an auction is over, vault token owners can call ERC721TokenVault#cash
to redeem their proportion of ETH collected from the auction. WETH is sent instead if the token owner is a smart contract. Vault tokens are then burnt.
require(auctionState == State.ended, "cash:vault not closed yet");
uint256 bal = balanceOf(msg.sender);
require(bal > 0, "cash:no tokens to cash out");
uint256 claimable = bal * address(this).balance / totalSupply();
_burn(msg.sender, bal);
_sendETHOrWETH(payable(msg.sender), claimable);
Redeeming vault NFTs
If a buyer doesn’t want to go through an auction, he also has the option to acquire full NFT ownership by controlling all of the vault’s token supply and then calls ERC721TokenVault#redeem
. The function burns all tokens and transfers the vault NFT to the user. Auction state is transitioned to redeemed
.
require(auctionState == State.inactive, "redeem:no redeeming");
_burn(msg.sender, totalSupply());
IERC721(token).transferFrom(address(this), msg.sender, id);
auctionState = State.redeemed;
Dealing with malicious curators
If there is a malicious curator, governance can vote to kick a curator out by assigning another address to be the curator by calling ERC721TokenVault#kickCurator
.
require(msg.sender == Ownable(settings).owner(), "kick:not gov");
curator = _curator;
Claiming fees
A curator receives a fee for curating the vault. ERC721TokenVault#claimFees
can be called to mint ERC-20 vault tokens to the curator and governance. The annual fee is a percentage of the total vault token supply and it is dripped to the curator and governance every second. Fees are claimable as long as time has elapsed between the current block timestamp and the last claimed timestamp. Fees cannot be claimed if the vault has been bought out through an auction.
function claimFees() external {
require(auctionState != State.ended, "claim:cannot claim after auction ends");
// get how much in fees the curator would make in a year
uint256 currentAnnualFee = fee * totalSupply() / 1000;
// get how much that is per second;
uint256 feePerSecond = currentAnnualFee / 31536000;
// get how many seconds they are eligible to claim
uint256 sinceLastClaim = block.timestamp - lastClaimed;
// get the amount of tokens to mint
uint256 curatorMint = sinceLastClaim * feePerSecond;
// now lets do the same for governance
address govAddress = ISettings(settings).feeReceiver();
uint256 govFee = ISettings(settings).governanceFee();
currentAnnualFee = govFee * totalSupply() / 1000;
feePerSecond = currentAnnualFee / 31536000;
uint256 govMint = sinceLastClaim * feePerSecond;
lastClaimed = block.timestamp;
_mint(curator, curatorMint);
_mint(govAddress, govMint);
}
How it’s doing so far (by Andrew Hong)
Let's start with some high-level statistics about the platform:
Below we can see the result of all the NFTs that were successfully fractionalized and then auctioned (currently 9 of them), comparing the list price and final auction price for a "profit".
And the more complex query, getting to the implied valuation of a fractionalized NFT. We start by taking the most recent trade from the DEX pool that the fractionalized NFT's ERC20 token is in, and then we take the USD value of the exchange rate and multiply it by the total supply of that token.
With that, we get 59 different NFT pools with the following implied valuation distribution:
Lastly, we can look at total volume traded over time:
You can find the link to the dashboard here.
Closing Thoughts
Fractional allows NFT owners to see liquidity for their NFTs without selling the whole pieces while also enables price discovery. Buyers can now partially own expensive NFTs they could not afford previously through Fractional’s vault tokens. Different protocols use different ways to perform NFT fractionalization/buyout and Fractional chooses AMM + auction as its main mechanisms. I am also looking at another protocol called Spectre, which does not believe in low liquidity high slippage AMM for fractionalized tokens and on-chain auctions for buyouts. It uses a combination of mint and trade for acquisition depending on pool liquidity, and it uses flash buyouts over auctions for de-fractionalization. I am curious to find out which mechanism will be more widely adopted in the end.
Hi, Do you have any github link or something for this? Please share if you do Thanks.
Good post