Skip to content

Commit

Permalink
account for founder tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
spengrah committed Jun 16, 2024
1 parent f5462b2 commit 524fc1e
Show file tree
Hide file tree
Showing 4 changed files with 155 additions and 27 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ An account is considered the owner of the most recently auctioned NFT (and there
2. The auction for their NFT has been settled
3. No subsequent auctions have been settled

If a subsequent auction has been settled without a winner (e.g. because there were no valid bids) then no account is considered eligible.
Additionally...

- If a subsequent auction has been settled without a winner (e.g. because there were no valid bids) then no account is considered eligible.
- If the subsequent auction(s) was skipped for founder tokens, then the account that owns that last actually-auctioned NFT is considered eligible.

## Development

Expand Down
41 changes: 36 additions & 5 deletions src/LatestNounsBuilderNFTEligibility.sol
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,15 @@ contract LatestNounsBuilderNFTEligibility is HatsEligibilityModule {
//////////////////////////////////////////////////////////////*/

/**
* @notice Get the id of the most recently auctioned token for `_token`.
* @notice Get the id of the most recently auctioned token for `_token`, skipping founder tokens.
* If the auction was settled without a winner, the returned token id will not have an owner.
*
* @dev Return the present auction's token id if it has been settled with a winner, otherwise it returns
* the id of the previous auction.
*
* @return The id of the most recently auctioned token.
* @return tokenId The id of the most recently auctioned token.
*/
function getLastAuctionedTokenId() public view returns (uint256) {
function getLastAuctionedTokenId() public view returns (uint256 tokenId) {
// get the auction contract
IAuction auctionContract = IAuction(TOKEN().auction());

Expand All @@ -101,10 +101,41 @@ contract LatestNounsBuilderNFTEligibility is HatsEligibilityModule {

// if the auction is settled with a winner, we want the current auction's token;
if (currentAuction.settled && currentAuction.highestBidder > address(0)) {
return currentAuction.tokenId;
tokenId = currentAuction.tokenId;
} else {
// otherwise, we want the previous auction's token
return currentAuction.tokenId - 1;
tokenId = currentAuction.tokenId - 1;
}

// We skip founder tokens. There can be multiple founder tokens in a row, so iterate backwards until we find a
// non-founder token.
while (isFounderToken(tokenId)) {
tokenId--;
}
}

/**
* @notice Checks if a given token was minted to a founder
* @dev Matches the logic here:
* https://github.com/ourzora/nouns-protocol/blob/98b65e2368c52085ff3844779afd45162eb1cc7d/src/token/Token.sol#L263
* @param _tokenId The ERC-721 token id
* @return bool True if the token was minted to a founder, false otherwise
*/
function isFounderToken(uint256 _tokenId) public view returns (bool) {
// Get the scheduled recipient for the token ID
IToken.Founder memory founder = TOKEN().getScheduledRecipient(_tokenId);

// If there is no scheduled recipient:
if (founder.wallet == address(0)) {
return false;
}

// Else if the founder is still vesting:
if (block.timestamp < founder.vestExpiry) {
return true;
}

// Else the founder has finished vesting:
return false;
}
}
19 changes: 19 additions & 0 deletions src/lib/IToken.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,17 @@ pragma solidity ^0.8.19;
/// @dev Excerpt sourced from
/// https://github.com/ourzora/nouns-protocol/blob/98b65e2368c52085ff3844779afd45162eb1cc7d/src/token/IToken.sol
interface IToken {
/// @notice The founder type
/// @param wallet The address where tokens are sent
/// @param ownershipPct The percentage of token ownership
/// @param vestExpiry The timestamp when vesting ends
struct Founder {
address wallet;
uint8 ownershipPct;
uint32 vestExpiry;
}
/// @notice The address of the auction house

function auction() external view returns (address);

/// @notice The total number of tokens that can be claimed from the reserve
Expand All @@ -22,4 +32,13 @@ interface IToken {

/// @notice Transfers a token from one address to another
function transferFrom(address from, address to, uint256 tokenId) external;

/// @notice The founder scheduled to receive the given token id
/// NOTE: If a founder is returned, there's no guarantee they'll receive the token as vesting expiration is not
/// considered
/// @param _tokenId The ERC-721 token id
function getScheduledRecipient(uint256 _tokenId) external view returns (Founder memory);

/// @notice The founders total percent ownership
function totalFounderOwnership() external view returns (uint256);
}
117 changes: 96 additions & 21 deletions test/LatestNounsBuilderNFTEligibility.t.sol
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import { Test, console2 } from "forge-std/Test.sol";
import { Test, console2, stdStorage, StdStorage } from "forge-std/Test.sol";
import { LatestNounsBuilderNFTEligibility } from "../src/LatestNounsBuilderNFTEligibility.sol";
import { IToken } from "../src/lib/IToken.sol";
import { IAuction } from "../src/lib/IAuction.sol";
Expand Down Expand Up @@ -34,7 +34,7 @@ contract LatestNounsBuilderNFTEligibilityTest is DeployImplementation, Test {
// Purple DAO's contracts on Base
// The token on auction at block 15794717 is #554, so we expect #553 to be the last auctioned token
IToken public token = IToken(0x8de71d80eE2C4700bC9D4F8031a2504Ca93f7088);
IAuction public auctionContract = IAuction(0x73Ab6d816FB9FE1714E477C5a70D94E803b56576);
IAuction public auctionContract;

function setUp() public virtual {
// create and activate a fork, at BLOCK_NUMBER
Expand All @@ -46,51 +46,44 @@ contract LatestNounsBuilderNFTEligibilityTest is DeployImplementation, Test {
}

function _getCurrentAuctionTokenId() internal view returns (uint256) {
IAuction auction = IAuction(token.auction());
return auction.auction().tokenId;
return auctionContract.auction().tokenId;
}

function _pauseAuction() internal returns (IAuction auction) {
// get the auction contract
auction = IAuction(token.auction());
function _pauseAuction() internal {
// get the auction owner
address auctionOwner = auction.owner();
address auctionOwner = auctionContract.owner();
// pause the auction
vm.prank(auctionOwner);
auction.pause();
auctionContract.pause();
}

function _settlePausedAuction() internal {
// get the auction contract
IAuction auction = IAuction(token.auction());
// get the auction status
IAuction.Auction memory currentAuction = auction.auction();
IAuction.Auction memory currentAuction = auctionContract.auction();
// advance time to the end of the auction
vm.warp(currentAuction.endTime + 1);
// settle the auction
auction.settleAuction();
auctionContract.settleAuction();
}

function _pauseAndSettleAuction() internal {
// pause the auction and get the auction contract
IAuction auction = _pauseAuction();
// pause the auction
_pauseAuction();
// get the current auction status
IAuction.Auction memory currentAuction = auction.auction();
IAuction.Auction memory currentAuction = auctionContract.auction();
// advance time to the end of the auction
vm.warp(currentAuction.endTime + 1);
// settle the auction
auction.settleAuction();
auctionContract.settleAuction();
}

function _settleCurrentAndCreateNewAuction() internal {
// get the current auction
IAuction auction = IAuction(token.auction());
// get the current auction status
IAuction.Auction memory currentAuction = auction.auction();
IAuction.Auction memory currentAuction = auctionContract.auction();
// advance time to the end of the auction
vm.warp(currentAuction.endTime + 1);
// settle the current auction
auction.settleCurrentAndCreateNewAuction();
auctionContract.settleCurrentAndCreateNewAuction();
}

function _createBidForAccount(address _bidder) internal {
Expand Down Expand Up @@ -126,6 +119,9 @@ contract WithInstanceTest is LatestNounsBuilderNFTEligibilityTest {
// run the script to deploy the module instance
deployInstance.prepare(false, address(implementation), hatId, address(token), saltNonce);
instance = deployInstance.run();

// set the auction contract
auctionContract = IAuction(token.auction());
}
}

Expand Down Expand Up @@ -195,6 +191,8 @@ contract GetLastAuctionedTokenId is WithInstanceTest {
}

contract GetWearerStatus is WithInstanceTest {
using stdStorage for StdStorage;

/// @dev Asserts that an account is eligible and in good standing the hat.
function assertEligible(address _account, uint256 _hatId) public view {
(bool eligible, bool standing) = instance.getWearerStatus(_account, _hatId);
Expand Down Expand Up @@ -309,4 +307,81 @@ contract GetWearerStatus is WithInstanceTest {
_settleCurrentAndCreateNewAuction(); // settled auction 555
assertIneligible(alice, hatId);
}

function test_founderToken() public {
// select a token id just prior to a future founder token
uint256 targetToken = 599;

// have some other account win the auctions between now and the target token
address otherAccount = address(11);
// HACK: this takes a while in tests
while (_getCurrentAuctionTokenId() < targetToken) {
_settleAuctionForWinner(otherAccount);
}

// have alice win the auction for the target token
_settleAuctionForWinner(alice);

// confirm that the next two tokens have been minted
assertNotEq(token.ownerOf(targetToken + 1), address(0));
assertNotEq(token.ownerOf(targetToken + 2), address(0));

// alice should be eligible
assertEligible(alice, hatId);
}

/**
* @dev Fast forwards to the auction for a given (assumed) future tokenId.
* Sets the following values directly in storage:
* - the current auction's token id => _tokenId
* - the owner of the target token => auctionContract
* - updates the settings.mintCount to reconcile with the above
*/
function _skipToAuctionForToken(uint256 _targetTokenId) internal {
// cache the current auction token id
uint256 ogTokenId = _getCurrentAuctionTokenId();

// set the current auction's token id to the target
stdstore.target(address(auctionContract)).sig("auction()").depth(0).checked_write(_targetTokenId);
// confirm that the current token id is the target
assertEq(_getCurrentAuctionTokenId(), _targetTokenId);

// set the target token's owner to the auction contract. We need to calculate the storage slot manually because
// stdstore doesn't work when the function reverts, which happens for ownerOf when the tokenId has not yet been
// minted
vm.store(address(token), _getOwnerSlot(_targetTokenId), bytes32(uint256(uint160(address(auctionContract)))));
// confirm that the owner of the target token is the auction contract
assertEq(token.ownerOf(_targetTokenId), address(auctionContract));

// increment the mintCount to be true for the target token
// FIXME: the "mintCoun()" function doesn't exist, so need to find another way
uint256 mintCount = stdstore.target(address(token)).sig("mintCount()").depth(4).read_uint();
uint256 increment = _targetTokenId - ogTokenId;
stdstore.target(address(token)).sig("mintCount()").depth(4).checked_write(mintCount + increment);
}

/// @dev Calculates the storage slot for the owner of a tokenId.
function _getOwnerSlot(uint256 tokenId) internal pure returns (bytes32) {
// The `owners` mapping begins at slot 10 in Token.sol
return keccak256(abi.encode(tokenId, uint256(10)));
}
}

contract IsFounderToken is WithInstanceTest {
// test a few know cases
function test_founderToken() public view {
// Purple DAO has two founders, each with 1% ownership. This means that the first two tokens of each set of 100 are
// founder tokens
assertTrue(instance.isFounderToken(0));
assertTrue(instance.isFounderToken(1));
assertFalse(instance.isFounderToken(2));
assertFalse(instance.isFounderToken(99));
assertTrue(instance.isFounderToken(100));
assertTrue(instance.isFounderToken(101));
assertFalse(instance.isFounderToken(102));
assertTrue(instance.isFounderToken(400));
assertFalse(instance.isFounderToken(250));
assertTrue(instance.isFounderToken(1000));
assertFalse(instance.isFounderToken(100_000_003));
}
}

0 comments on commit 524fc1e

Please sign in to comment.