2

I'm working on a Solidity token-vesting contract that allows users to claim tokens based on how long they met certain conditions.

To illustrate, let's assume holders of a specific NFT can claim 100 tokens if they held their NFT during a period of time. If they held it for 50% of the time and sold it, they can claim 50 tokens (50% of 100). If they held for 10% of the time, they can claim 10 tokens, and so on...

The question then becomes, is it possible to generate any proof (off-chain) that the user has not sold their NFT in that period of time?

Buying and Selling are represented on-chain via the Transfer event, and I understand that we can prove an event has happened using Merkle proof. However, is it possible to prove to the contract that the event has not happened since a specific block? And if the event did happen, we also need to calculate how many tokens the user is able to claim.

This approach suggested using the Event Merkle Tree, but is there a different approach using the Contract's Patricia Merkle Tree?

Last comment: The token-vesting contract is a different contract than the NFT. We can't customize the NFT contract for this application and need to use existing methods/events of ERC-721.

1 Answer 1

1

Off-chain listener

By setting up an off-chain event listener for your ERC-721 contract, you can write code that tracks the timestamp of the NFT being transferred as well as the address that transferred the NFT.

With these pieces of information, you should be able to calculate how much of the rewards the holder is eligible for.

For example, if the event starts at timestamp 0, 0x777 sells their NFT at timestamp 50, and the event ends at timestamp 100, you can calculate that 0x777 should receive 50% of the total rewards. (sellTimestamp - startTimestamp / endTimestamp - startTimestamp)

Here, here, and here are more resources on setting up an off-chain listener.

Merkle tree

This answer also isn't perfect, but to more appeal toward your goal of using cryptography to prove how much each user should receive as opposed to a centralized controller, consider this example I've created:

/**@title Claim
 *@author Rohan Nero
 *@notice this contract allows anyone claim rewards earned during a `reward period` */
contract Claim {
    /**@notice each struct outlines the details of a reward event
     *@dev nftContract - the ERC721 contract
     *@dev rewardToken - the current that reward will be in, set to 0 address to use ETH
     *@dev blockStart - the block number that the reward event starts at
     *@dev blockEnd - the block number that the reward event ends at
     *@dev rewardAmount - the amount of ETH
     *@dev nfts - total amount of different NFTs
     *@dev
     */
    struct RewardEvent {
        address nftContract;
        address rewardToken;
        address organizer;
        bytes32 merkleRoot;
        uint startBlock;
        uint endBlock;
        uint rewardAmount;
        uint nfts;
        uint eventId;
    }

    /**@notice used to avoid stack-too-deep error
     *@param holder is the address that held the NFT
     *@param to is the address that will be sent the rewards
     *@param tokenId is the NFT's tokenId
     *@param eventId is the `RewardEvent` index in the `eventMap`
     *@param heldUntil is the block number the NFT was held until*/
    struct ClaimInfo {
        address holder;
        address to;
        uint tokenId;
        uint eventId;
        uint heldUntil;
    }

    /**@notice array from eventId to RewardEvent */
    RewardEvent[] public eventMap;

    /**@notice tracks whether or not rewards have been claimed for an NFT or not
     *@dev set to true inside `claim()` */
    mapping(uint eventId => mapping(uint tokenId => bool hasClaimed))
        public claimMap;

    /**@notice anyone would be able to claim after the endTime
     *@dev this needs to use `MerkleProof` lib to verify that the caller can receieve funds
     *@param proof is an array of proofs that can be used to verifiy the user's claim
     *@param signature contains hash of holder, tokenId, eventId, heldUntil
     *@param info is the ClaimInfo struct containing information about the claim
     */
    function claimWithSignature(
        bytes32[] memory proof,
        bytes memory signature,
        ClaimInfo memory info
    ) public returns (uint) {
        // ensure the event has ended
        if (block.number < eventMap[info.eventId].endBlock) {
            revert Claim__RewardsPeriodHasntEnded(
                block.number,
                eventMap[info.eventId].endBlock
            );
        }
        // ensure the user hasn't already claimed for this event and tokenId
        if (claimMap[info.eventId][info.tokenId]) {
            revert Claim__AlreadyClaimed();
        }
        // Recover the signer's address

        // create the message hash
        bytes32 messageHash = keccak256(abi.encodePacked(info.to));
        // sign the message hash
        bytes32 signedMessageHash = keccak256(
            abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
        );
        // recover the signer
        address signer = recoverSigner(signedMessageHash, signature);

        // revert if signer address isn't the holder
        if (signer != info.holder) {
            revert Claim__InvalidSigner();
        }

        // Calculate the user's `leaf` hash
        bytes32 leaf = keccak256(
            bytes.concat(
                keccak256(
                    abi.encode(
                        info.holder,
                        info.tokenId,
                        info.eventId,
                        info.heldUntil
                    )
                )
            )
        );
        // Use MerkleProof lib to verify proof with root and leaf
        bool verified = MerkleProof.verify(
            proof,
            eventMap[info.eventId].merkleRoot,
            leaf
        );
        // Revert if the verification returns false
        if (!verified) {
            revert Claim__InvalidProof();
        }
        // Reward calculation
        // calculate maxPortion that an NFT could earn its holder
        // 1 Ether / 10 nfts = 0.1 ether per NFT
        // maxPortion uses 6 decimals
        uint maxPortion = (eventMap[info.eventId].rewardAmount * 10 ** 6) /
            eventMap[info.eventId].nfts;
        // calculate what percent of the maxPortion they should get
        // total = 100, blocksHeld = 77
        // percent = blocksHeld / total
        uint totalBlocks = eventMap[info.eventId].endBlock -
            eventMap[info.eventId].startBlock;
        uint blocksHeld = info.heldUntil - eventMap[info.eventId].startBlock;
        uint percent = (blocksHeld * 10 ** 6) / totalBlocks;
        // now that we know the maxPortion they could get, lets see how much they actually earned
        uint portion = (maxPortion * percent) / 1e12;
        if (eventMap[info.eventId].rewardToken == address(0)) {
            console2.log("transfer eth");
            (bool success, ) = info.to.call{value: portion}("");
            // ensure call went through
            if (!success) {
                revert Claim__RewardTransferFailed();
            }
        }
        else {
            bool success = IERC20(eventMap[info.eventId].rewardToken).transfer(
                info.to,
                portion
            );
            if (!success) {
                revert Claim__RewardTransferFailed();
            }
        }
        claimMap[info.eventId][info.tokenId] == true;
        return portion;
    }

    /**@notice anyone can call this function to create a reward event
     *@dev _root may be set to 0 if waiting until reward period to add
     *@param _nftContract is the ERC-721 contract associated with the Reward Event
     *@param _rewardToken is the token to be sent as rewards
     *@param _organizer is the address that created the Reward Event
     *@param _root is the Merkle Root of the Reward Event
     *@param _blockStart is the block number that the Reward Event started at
     *@param _blockEnd is the block number that the Reward Event ended at
     *@param _rewardAmount is the amount of reward token to be sent to the holders
     *@param _nfts is the total amount of NFTs eligible for rewards
     */
    function createRewardEvent(
        address _nftContract,
        address _rewardToken,
        address _organizer,
        bytes32 _root,
        uint _blockStart,
        uint _blockEnd,
        uint _rewardAmount,
        uint _nfts
    ) public payable {
        // if event period is over, root has to be set
        if (block.number > _blockEnd && _root == 0) {
            revert Claim__MustProvideRootIfContestIsOver();
        }

        // add the struct to the eventMap
        eventMap.push(
            RewardEvent(
                _nftContract,
                _rewardToken,
                _organizer,
                _root,
                _blockStart,
                _blockEnd,
                _rewardAmount,
                _nfts,
                eventMap.length
            )
        );
    }

    /** View / pure functions */

    /**@notice returns the duration of the reward period in seconds */
    function viewRewardPeriodDuration(uint eventId) public view returns (uint) {
        return eventMap[eventId].endBlock - eventMap[eventId].startBlock;
    }

    function viewEthBalance() public view returns (uint) {
        return address(this).balance;
    }

    /**@notice returns length of `eventMap` */
    function viewEventMapLength() public view returns (uint) {
        return eventMap.length;
    }

    /**@notice recovers the signer
     *@dev from SMP's signature recovery */
    function recoverSigner(
        bytes32 _signedMessageHash,
        bytes memory _signature
    ) internal pure returns (address) {
        (bytes32 r, bytes32 s, uint8 v) = splitSignature(_signature);

        return ecrecover(_signedMessageHash, v, r, s);
    }

    /**@notice splits the signature
     *@dev from SMP's signature recovery */
    function splitSignature(
        bytes memory sig
    ) internal pure returns (bytes32 r, bytes32 s, uint8 v) {
        require(sig.length == 65, "invalid signature length");

        assembly {
            /*
            First 32 bytes stores the length of the signature

            add(sig, 32) = pointer of sig + 32
            effectively, skips first 32 bytes of signature

            mload(p) loads next 32 bytes starting at the memory address p into memory
            */

            // first 32 bytes, after the length prefix
            r := mload(add(sig, 32))
            // second 32 bytes
            s := mload(add(sig, 64))
            // final byte (first byte of the next 32 bytes)
            v := byte(0, mload(add(sig, 96)))
        }

        // implicitly return (r, s, v)
    }
}
  1. Anyone creates a Merkle tree with each leaf containing data relevant to an NFT contract such as the holder, the NFT's tokenId, the eventId, and heldUntil block number. These variables can all be viewed and recorded in a script that takes a block number to start and block number to end at, and an NFT contract address. Record the first 3 variables at the beginning of the duration and update the fourth as Transfer events are emitted, if the Transfer event is never emitted for a specific tokenId, you can assume the NFT was held the entire duration of the reward period (Ideally, you can run this script once after a reward duration is over so that all the variables can be stored at once as opposed to constantly listening for the NFT events over time).

  2. The creator of the Merkle Tree can then call createRewardEvent() as the organizer to fund the contract with rewardAmount and make the merkleRoot publicly known. At this point, the creator could also describe how the Merkle Tree was created so that anyone can prove the root is authentic.

  3. Anyone who held the NFTs that were eligible for rewards can create a proof using the Merkle Tree. Also, only because my example uses it, if they sold/transferred one of the NFTs during the reward period, they should record what block number their transaction was executed at.

  4. After obtaining the proof they can call claimWithSignature() from any address as long as they've signed a message that contains the address they'd like to receive the rewards (Signature stuff isn't necessary, added it for fun).

function claimWithSignature(
        bytes32[] memory proof,
        bytes memory signature,
        ClaimInfo memory info
    ) public returns (uint) {
        if (block.number < eventMap[info.eventId].endBlock) {
            revert Claim__RewardsPeriodHasntEnded(
                block.number,
                eventMap[info.eventId].endBlock
            );
        }
        if (claimMap[info.eventId][info.tokenId]) {
            revert Claim__AlreadyClaimed();
        }
        bytes32 messageHash = keccak256(abi.encodePacked(info.to));
        // sign the message hash
        bytes32 signedMessageHash = keccak256(
            abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)
        );
        // recover the signer
        address signer = recoverSigner(signedMessageHash, signature);
        if (signer != info.holder) {
            revert Claim__InvalidSigner();
        }
        bytes32 leaf = keccak256(
            bytes.concat(
                keccak256(
                    abi.encode(
                        info.holder,
                        info.tokenId,
                        info.eventId,
                        info.heldUntil
                    )
                )
            )
        );
        // Use MerkleProof lib to verify proof with root and leaf
        bool verified = MerkleProof.verify(
            proof,
            eventMap[info.eventId].merkleRoot,
            leaf
        );
        // Revert if the verification returns false
        if (!verified) {
            revert Claim__InvalidProof();
        }
        // Reward calculation
        uint maxPortion = (eventMap[info.eventId].rewardAmount * 10 ** 6) /
            eventMap[info.eventId].nfts;
        uint totalBlocks = eventMap[info.eventId].endBlock -
            eventMap[info.eventId].startBlock;
        uint blocksHeld = info.heldUntil - eventMap[info.eventId].startBlock;
        uint percent = (blocksHeld * 10 ** 6) / totalBlocks;
        uint portion = (maxPortion * percent) / 1e12;
        if (eventMap[info.eventId].rewardToken == address(0)) {
            console2.log("transfer eth");
            (bool success, ) = info.to.call{value: portion}("");
            // ensure call went through
            if (!success) {
                revert Claim__RewardTransferFailed();
            }
        }
        else {
            bool success = IERC20(eventMap[info.eventId].rewardToken).transfer(
                info.to,
                portion
            );
            if (!success) {
                revert Claim__RewardTransferFailed();
            }
        }
        claimMap[info.eventId][info.tokenId] == true;
        return portion;
    }

For more information here is Open Zeppelin's Javascript Merkle tree library, and here is their solidity MerkleProof.sol library

3
  • 2
    That approach will have centralized permissioning though. I think they're looking trusting cryptography such that the controller of the listener could not manipulate it, aka proof.
    – Bruce
    Sep 7 at 3:34
  • 1
    Thanks for your response, Rohan, but the challenge is to cryptographically prove to the smart contract that the event has occurred. We can't have a centralized entity with special permissions defining how much each user should take
    – Ernani
    Sep 7 at 13:09
  • @Ernani I'm not sure if it's possible to do without some sort of central authority setting values/thresholds that are used to measure how many rewards a user should earn, however, I tried to add another solution that closer fits your needs.
    – Rohan Nero
    Sep 11 at 5:50

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.