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)
}
}
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).
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.
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.
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