Play & Earn Reward Share

If your game involves utility tokens that get earned or distributed to the players as rewards, you can include the Play&Earn Reward Share interface to make it easy distributing earnings if NFTs are used by scholars with reward share agreements.

Play & Earn Reward Share Mechanism

This protocol is introduced in Stash Rental Contracts and aims to enable revenue sharing with NFTs inside P2E games.

The way that it is achieved is pretty straight-forward: The parties involved in a revenue sharing case (Most commonly the lender and the renter) are stored inside the NFT contract along with their respective share rates.

You can then implement following interface on the NFT contract(s) to retrieve information about the parties/shares and to perform reward payouts. The functions inside the interface need to be implemented inside your NFT contract, if you want to make it compatible with PlayRewardShare721 or PlayRewardShare1155 protocol.

Interface

interface IPlayRewardShare721 {
  /**
   * @notice Emitted when play rewards are paid through this contract.
   * @param tokenId The tokenId of the NFT for which rewards were paid.
   * @param to The address to which the rewards were paid.
   * There may be multiple payments for a single payment transaction, one for each recipient.
   * @param operator The account which initiated and provided the funds for this payment.
   * @param role The role of the recipient in terms of why they are receiving a share of payments.
   * @param paymentToken The token used to pay the rewards, or address(0) if ETH was distributed.
   * @param tokenAmount The amount of `paymentToken` sent to the `to` address.
   */
  event PlayRewardPaid(
    uint256 indexed tokenId,
    address indexed to,
    address indexed operator,
    RecipientRole role,
    address paymentToken,
    uint256 tokenAmount
  );

  /**
   * @notice Emitted when additional recipients are provided for an NFT's play rewards.
   * @param tokenId The tokenId of the NFT for which reward recipients were set.
   * @param recipients The addresses to which rewards should be paid and their relative shares.
   */
  event PlayRewardRecipientsSet(uint256 indexed tokenId, Recipient[] recipients);

  /**
   * @notice Pays play rewards generated by this NFT to the expected recipients.
   * @param tokenId The tokenId of the NFT for which rewards were earned.
   * @param recipients The address and relative share each recipient should receive.
   * @param paymentToken The token to use to pay the rewards, or address(0) if ETH will be distributed.
   * @param tokenAmount The amount of `paymentToken` to distribute to the recipients.
   * @dev If an ERC-20 token is used for payment, the `msg.sender` should first grant approval to this contract.
   */
  function payPlayRewards(
    uint256 tokenId,
    Recipient[] calldata recipients,
    address paymentToken,
    uint256 tokenAmount
  ) external payable;

  /**
   * @notice Sets additional recipients for play rewards generated by this NFT.
   * @dev This is only callable while rented, by the operator which created the rental.
   * @param tokenId The tokenId of the NFT for which reward recipients should be set.
   * @param recipients Additional recipients and their share of play rewards to receive.
   * The user/player of the NFT will automatically be added as a recipient, receiving the remaining share - the sum
   * provided for the additional recipients must be less than 100%.
   */
  function setPlayRewardShareRecipients(uint256 tokenId, Recipient[] calldata recipients) external;

  /**
   * @notice Gets the expected recipients for play rewards generated by this NFT.
   * @param tokenId The tokenId of the NFT to get recipients for.s
   * @return recipients The addresses to which rewards should be paid and their relative shares.
   * @dev This will return 1 or more recipients, and the shares defined will sum to exactly 100% in basis points.
   */
  function getPlayRewardShares(uint256 tokenId) external view returns (Recipient[] memory recipients);
}
interface IPlayRewardShare1155 {
  /**
   * @notice Emitted when play rewards are paid through this contract.
   * @param tokenId The tokenId of the NFT for which rewards were paid.
   * @param to The address to which the rewards were paid.
   * There may be multiple payments for a single payment transaction, one for each recipient.
   * @param operator The account which initiated and provided the funds for this payment.
   * @param amount The amount of NFTs used to generate the rewards.
   * @param recordId The associated rental recordId, or 0 if n/a.
   * @param role The role of the recipient in terms of why they are receiving a share of payments.
   * @param paymentToken The token used to pay the rewards, or address(0) if ETH was distributed.
   * @param tokenAmount The amount of `paymentToken` sent to the `to` address.
   */
  event PlayRewardPaid(
    uint256 indexed tokenId,
    address indexed to,
    address indexed operator,
    uint256 amount,
    uint256 recordId,
    RecipientRole role,
    address paymentToken,
    uint256 tokenAmount
  );

  /**
   * @notice Emitted when additional recipients are provided for an NFT's play rewards.
   * @param recordId The recordId of the NFT rental for which reward recipients were set.
   * @param recipients The addresses to which rewards should be paid and their relative shares.
   */
  event PlayRewardRecipientsSet(uint256 indexed recordId, Recipient[] recipients);

  /**
   * @notice Pays play rewards generated by this NFT to the expected recipients.
   * @param tokenId The tokenId of the NFT for which rewards were earned.
   * @param amount The amount of NFTs used to generate the rewards.
   * @param recordId The associated rental recordId, or 0 if n/a.
   * @param recipients The address and relative share each recipient should receive.
   * @param paymentToken The token to use to pay the rewards, or address(0) if ETH will be distributed.
   * @param tokenAmount The amount of `paymentToken` to distribute to the recipients.
   * @dev If an ERC-20 token is used for payment, the `msg.sender` should first grant approval to this contract.
   */
  function payPlayRewards(
    uint256 tokenId,
    uint256 amount,
    uint256 recordId,
    Recipient[] calldata recipients,
    address paymentToken,
    uint256 tokenAmount
  ) external payable;

  /**
   * @notice Sets additional recipients for play rewards generated by this NFT.
   * @dev This is only callable while rented, by the operator which created the rental.
   * @param recordId The recordId of the NFT for which reward recipients should be set.
   * @param recipients Additional recipients and their share of play rewards to receive.
   * The user/player of the NFT will automatically be added as a recipient, receiving the remaining share - the sum
   * provided for the additional recipients must be less than 100%.
   */
  function setPlayRewardShareRecipients(uint256 recordId, Recipient[] calldata recipients) external;

  /**
   * @notice Gets the expected recipients for play rewards generated by this NFT.
   * @return recipients The addresses to which rewards should be paid and their relative shares.
   * @dev If the record is found, this will return 1 or more recipients, and the shares defined will sum to exactly 100%
   * in basis points. If the record is not found, this will revert instead.
   */
  function getPlayRewardShares(uint256 recordId) external view returns (Recipient[] memory recipients);
}

Reference Implementation

Constants

uint16 constant BASIS_POINTS = 10_000;

/**
 * @dev The gas limit to send ETH to multiple recipients, enough for a 5-way split.
 */
uint256 constant SEND_VALUE_GAS_LIMIT_MULTIPLE_RECIPIENTS = 210000;

/**
 * @dev The gas limit to send ETH to a single recipient, enough for a contract with a simple receiver.
 */
uint256 constant SEND_VALUE_GAS_LIMIT_SINGLE_RECIPIENT = 20000;

/**
 * @dev The percent of revenue the NFT owner should receive from play reward payments generated while this NFT is
 * rented, in basis points.
 */
uint16 constant DEFAULT_OWNER_REWARD_SHARE_IN_BASIS_POINTS = 1_000; // 10%

Helper Contracts

abstract contract PlayRewardShare {
  modifier requireRecipients(Recipient[] calldata recipients) {
    require(recipients.length > 0, "PlayRewardShare: recipients are required");
    _;
  }

  modifier validTokenAmount(address paymentToken, uint256 tokenAmount) {
    require(tokenAmount != 0, "PlayRewardShare: tokenAmount is required");
    require(
      (paymentToken != address(0) && msg.value == 0) || (paymentToken == address(0) && msg.value == tokenAmount),
      "PlayRewardShare: Incorrect funds provided"
    );
    _;
  }
}

abstract contract TokenTransfers {
  using SafeERC20Upgradeable for IERC20Upgradeable;
  using AddressUpgradeable for address payable;

  /**
   * @notice The WETH contract address on this network.
   */
  address payable public immutable weth;

  /**
   * @notice Assign immutable variables defined in this proxy's implementation.
   * @param _weth The address of the WETH contract for this network.
   */
  constructor(address payable _weth) {
    require(_weth.isContract(), "TokenTransfers: WETH is not a contract");
    weth = _weth;
  }

  /**
   * @notice Transfer funds from the msg.sender to the recipient specified.
   * @param to The address to which the funds should be sent.
   * @param paymentToken The ERC-20 token to be used for the transfer, or address(0) for ETH.
   * @param amount The amount of funds to be sent.
   * @dev When ETH is used, the caller is required to confirm that the total provided is as expected.
   */
  function _transferFunds(
    address to,
    address paymentToken,
    uint256 amount
  ) internal {
    if (amount == 0) {
      return;
    }
    require(to != address(0), "TokenTransfers: to is required");

    if (paymentToken == address(0)) {
      // ETH
      // Cap the gas to prevent consuming all available gas to block a tx from completing successfully
      (bool success, ) = to.call{ value: amount, gas: SEND_VALUE_GAS_LIMIT_SINGLE_RECIPIENT }("");
      if (!success) {
        // Store the funds that failed to send for the user in WETH
        IWeth(weth).deposit{ value: amount }();
        IWeth(weth).transfer(to, amount);
      }
    } else {
      // ERC20 Token
      require(msg.value == 0, "TokenTransfers: ETH cannot be sent with a token payment");
      IERC20Upgradeable(paymentToken).safeTransferFrom(msg.sender, to, amount);
    }
  }
}

NFT Contracts

abstract contract PlayRewardShare721 is
  IERC4907,
  IPlayRewardShare721,
  ERC165,
  TokenTransfers,
  ERC4907,
  PlayRewardShare
{
  /**
   * @notice Stores additional payees for play rewards.
   */
  mapping(uint256 => Recipient[]) private _tokenIdToRecipients;

  function payPlayRewards(
    uint256 tokenId,
    Recipient[] calldata recipients,
    address paymentToken,
    uint256 tokenAmount
  ) external payable validTokenAmount(paymentToken, tokenAmount) requireRecipients(recipients) {
    uint256 totalDistributed;
    for (uint256 i = 1; i < recipients.length; ) {
      uint256 toDistribute = (tokenAmount * recipients[i].shareInBasisPoints) / BASIS_POINTS;
      totalDistributed += toDistribute;
      _payReward(tokenId, recipients[i], paymentToken, toDistribute);

      unchecked {
        ++i;
      }
    }

    // Round in favor of the first recipient
    _payReward(tokenId, recipients[0], paymentToken, tokenAmount - totalDistributed);
  }

  function setPlayRewardShareRecipients(uint256 tokenId, Recipient[] calldata recipients)
    external
    requireRecipients(recipients)
  {
    require(userOperatorOf(tokenId) == msg.sender, "PlayRewardShare721: Only the operator can set recipients");
    require(_tokenIdToRecipients[tokenId].length == 0, "PlayRewardShare721: Recipients already set");

    uint16 totalShares;
    for (uint256 i = 0; i < recipients.length; ) {
      _tokenIdToRecipients[tokenId].push(recipients[i]);
      totalShares += recipients[i].shareInBasisPoints;
      unchecked {
        ++i;
      }
    }
    require(totalShares < BASIS_POINTS, "PlayRewardShare721: Total shares must be less than 100%");

    emit PlayRewardRecipientsSet(tokenId, recipients);
  }

  /**
   * @dev Anytime the rental terms change, clear recorded play reward recipients. They can be reset by the operator
   * when applicable.
   */
  function setUser(
    uint256 tokenId,
    address user,
    uint64 expires
  ) public virtual override(IERC4907, ERC4907) {
    delete _tokenIdToRecipients[tokenId];
    // Emit is not required, this can be inferred from the `UpdateUser` event.

    super.setUser(tokenId, user, expires);
  }

  /**
   * @notice Distributes play reward payments to the given recipient, if the amount is greater than 0.
   */
  function _payReward(
    uint256 tokenId,
    Recipient calldata recipient,
    address paymentToken,
    uint256 tokenAmount
  ) private {
    if (tokenAmount != 0) {
      _transferFunds(recipient.to, paymentToken, tokenAmount);
      emit PlayRewardPaid({
        tokenId: tokenId,
        to: recipient.to,
        operator: msg.sender,
        role: recipient.role,
        paymentToken: paymentToken,
        tokenAmount: tokenAmount
      });
    }
  }

  function getPlayRewardShares(uint256 tokenId) external view returns (Recipient[] memory recipients) {
    if (userExpires(tokenId) != 0) {
      Recipient[] storage savedRecipients = _tokenIdToRecipients[tokenId];
      recipients = new Recipient[](savedRecipients.length + 1);

      uint256 totalShares;
      for (uint256 i = 0; i < savedRecipients.length; ) {
        recipients[i + 1] = savedRecipients[i];
        totalShares += savedRecipients[i].shareInBasisPoints;

        unchecked {
          ++i;
        }
      }

      // Dynamically add the player, reduces storage requirements and allows for the user changing during a rental.
      recipients[0] = Recipient({
        to: payable(userOf(tokenId)),
        role: RecipientRole.Player,
        shareInBasisPoints: uint16(BASIS_POINTS - totalShares)
      });
    } else {
      recipients = new Recipient[](1);
      recipients[0] = Recipient({
        to: payable(ownerOf(tokenId)),
        role: RecipientRole.Owner,
        shareInBasisPoints: BASIS_POINTS
      });
    }
  }

  /**
   * @notice Checks if this contract implements the given ERC-165 interface.
   * @param interfaceId The interface to check for.
   * @return supported True if this contract implements the given interface.
   * @dev This instance checks the IPlayRewardShare721 interface.
   */
  function supportsInterface(bytes4 interfaceId)
    public
    view
    virtual
    override(ERC165, ERC4907)
    returns (bool supported)
  {
    supported = interfaceId == type(IPlayRewardShare721).interfaceId || super.supportsInterface(interfaceId);
  }
}
abstract contract PlayRewardShare1155 is
  IERC5006,
  IPlayRewardShare1155,
  ERC165,
  TokenTransfers,
  ERC5006,
  PlayRewardShare
{
  /**
   * @notice Stores additional payees for play rewards.
   */
  mapping(uint256 => Recipient[]) private _recordIdToRecipients;

  function payPlayRewards(
    uint256 tokenId,
    uint256 amount,
    uint256 recordId,
    Recipient[] calldata recipients,
    address paymentToken,
    uint256 tokenAmount
  ) external payable validTokenAmount(paymentToken, tokenAmount) requireRecipients(recipients) {
    uint256 totalDistributed;
    for (uint256 i = 1; i < recipients.length; ) {
      uint256 toDistribute = (tokenAmount * recipients[i].shareInBasisPoints) / BASIS_POINTS;
      totalDistributed += toDistribute;
      _payReward(tokenId, amount, recordId, recipients[i], paymentToken, toDistribute);

      unchecked {
        ++i;
      }
    }

    // Round in favor of the first recipient
    _payReward(tokenId, amount, recordId, recipients[0], paymentToken, tokenAmount - totalDistributed);
  }

  function setPlayRewardShareRecipients(uint256 recordId, Recipient[] calldata recipients)
    external
    requireRecipients(recipients)
  {
    require(recordOperatorOf(recordId) == msg.sender, "PlayRewardShare1155: Only the operator can set recipients");
    require(_recordIdToRecipients[recordId].length == 0, "PlayRewardShare1155: Recipients already set");

    uint16 totalShares;
    for (uint256 i = 0; i < recipients.length; ) {
      _recordIdToRecipients[recordId].push(recipients[i]);
      totalShares += recipients[i].shareInBasisPoints;
      unchecked {
        ++i;
      }
    }
    require(totalShares < BASIS_POINTS, "PlayRewardShare1155: Total shares must be less than 100%");

    emit PlayRewardRecipientsSet(recordId, recipients);
  }

  /**
   * @dev Anytime the rental terms are cleared, also clear recorded play reward recipients. They can be reset by the
   * operator when applicable.
   */
  function _deleteUserRecord(uint32 recordId) internal virtual override {
    delete _recordIdToRecipients[recordId];
    // Emit is not required, this can be inferred from expiry or the `DeleteUserRecord` event.

    super._deleteUserRecord(recordId);
  }

  /**
   * @notice Distributes play reward payments to the given recipient, if the amount is greater than 0.
   */
  function _payReward(
    uint256 tokenId,
    uint256 amount,
    uint256 recordId,
    Recipient calldata recipient,
    address paymentToken,
    uint256 tokenAmount
  ) private {
    if (tokenAmount != 0) {
      _transferFunds(recipient.to, paymentToken, tokenAmount);
      emit PlayRewardPaid({
        tokenId: tokenId,
        to: recipient.to,
        operator: msg.sender,
        amount: amount,
        recordId: recordId,
        role: recipient.role,
        paymentToken: paymentToken,
        tokenAmount: tokenAmount
      });
    }
  }

  function getPlayRewardShares(uint256 recordId) external view returns (Recipient[] memory recipients) {
    UserRecord memory record = userRecordOf(recordId);
    require(record.expiry != 0, "PlayRewardShare1155: Record does not exist");
    Recipient[] storage savedRecipients = _recordIdToRecipients[recordId];
    // TODO: if none stored use default owner split?
    recipients = new Recipient[](savedRecipients.length + 1);

    uint256 totalShares;
    for (uint256 i = 0; i < savedRecipients.length; ) {
      recipients[i + 1] = savedRecipients[i];
      totalShares += savedRecipients[i].shareInBasisPoints;

      unchecked {
        ++i;
      }
    }

    // Dynamically add the player, reduces storage requirements and allows for the user changing during a rental.
    recipients[0] = Recipient({
      to: payable(record.user),
      role: RecipientRole.Player,
      shareInBasisPoints: uint16(BASIS_POINTS - totalShares)
    });
  }

  /**
   * @notice Checks if this contract implements the given ERC-165 interface.
   * @param interfaceId The interface to check for.
   * @return supported True if this contract implements the given interface.
   * @dev This instance checks the IPlayRewardShare1155 interface.
   */
  function supportsInterface(bytes4 interfaceId)
    public
    view
    virtual
    override(ERC165, ERC5006)
    returns (bool supported)
  {
    supported = interfaceId == type(IPlayRewardShare1155).interfaceId || super.supportsInterface(interfaceId);
  }
}