NEW

CCIP is now available on testnet for all developers. Get started today.

VRF Subscription Monitor

Automatically top-up your VRF subscription balances using Chainlink Automation to ensure there is sufficient funding for requests.

View the template here.

Below is the main contract VRFSubscriptionBalanceMonitor.sol:

// SPDX-License-Identifier: MIT

pragma solidity 0.8.6;

import "../ConfirmedOwner.sol";
import "../interfaces/automation/KeeperCompatibleInterface.sol";
import "../interfaces/VRFCoordinatorV2Interface.sol";
import "../interfaces/LinkTokenInterface.sol";
import "@openzeppelin/contracts/security/Pausable.sol";

/**
 * @title The VRFSubscriptionBalanceMonitor contract.
 * @notice A keeper-compatible contract that monitors and funds VRF subscriptions.
 */
contract VRFSubscriptionBalanceMonitor is ConfirmedOwner, Pausable, KeeperCompatibleInterface {
  VRFCoordinatorV2Interface public COORDINATOR;
  LinkTokenInterface public LINKTOKEN;

  uint256 private constant MIN_GAS_FOR_TRANSFER = 55_000;

  event FundsAdded(uint256 amountAdded, uint256 newBalance, address sender);
  event FundsWithdrawn(uint256 amountWithdrawn, address payee);
  event TopUpSucceeded(uint64 indexed subscriptionId);
  event TopUpFailed(uint64 indexed subscriptionId);
  event KeeperRegistryAddressUpdated(address oldAddress, address newAddress);
  event VRFCoordinatorV2AddressUpdated(address oldAddress, address newAddress);
  event LinkTokenAddressUpdated(address oldAddress, address newAddress);
  event MinWaitPeriodUpdated(uint256 oldMinWaitPeriod, uint256 newMinWaitPeriod);
  event OutOfGas(uint256 lastId);

  error InvalidWatchList();
  error OnlyKeeperRegistry();
  error DuplicateSubcriptionId(uint64 duplicate);

  struct Target {
    bool isActive;
    uint96 minBalanceJuels;
    uint96 topUpAmountJuels;
    uint56 lastTopUpTimestamp;
  }

  address public s_keeperRegistryAddress; // the address of the keeper registry
  uint256 public s_minWaitPeriodSeconds; // minimum time to wait between top-ups
  uint64[] public s_watchList; // the watchlist on which subscriptions are stored
  mapping(uint64 => Target) internal s_targets;

  /**
   * @param linkTokenAddress the Link token address
   * @param coordinatorAddress the address of the vrf coordinator contract
   * @param keeperRegistryAddress the address of the keeper registry contract
   * @param minWaitPeriodSeconds the minimum wait period for addresses between funding
   */
  constructor(
    address linkTokenAddress,
    address coordinatorAddress,
    address keeperRegistryAddress,
    uint256 minWaitPeriodSeconds
  ) ConfirmedOwner(msg.sender) {
    setLinkTokenAddress(linkTokenAddress);
    setVRFCoordinatorV2Address(coordinatorAddress);
    setKeeperRegistryAddress(keeperRegistryAddress);
    setMinWaitPeriodSeconds(minWaitPeriodSeconds);
  }

  /**
   * @notice Sets the list of subscriptions to watch and their funding parameters.
   * @param subscriptionIds the list of subscription ids to watch
   * @param minBalancesJuels the minimum balances for each subscription
   * @param topUpAmountsJuels the amount to top up each subscription
   */
  function setWatchList(
    uint64[] calldata subscriptionIds,
    uint96[] calldata minBalancesJuels,
    uint96[] calldata topUpAmountsJuels
  ) external onlyOwner {
    if (subscriptionIds.length != minBalancesJuels.length || subscriptionIds.length != topUpAmountsJuels.length) {
      revert InvalidWatchList();
    }
    uint64[] memory oldWatchList = s_watchList;
    for (uint256 idx = 0; idx < oldWatchList.length; idx++) {
      s_targets[oldWatchList[idx]].isActive = false;
    }
    for (uint256 idx = 0; idx < subscriptionIds.length; idx++) {
      if (s_targets[subscriptionIds[idx]].isActive) {
        revert DuplicateSubcriptionId(subscriptionIds[idx]);
      }
      if (subscriptionIds[idx] == 0) {
        revert InvalidWatchList();
      }
      if (topUpAmountsJuels[idx] <= minBalancesJuels[idx]) {
        revert InvalidWatchList();
      }
      s_targets[subscriptionIds[idx]] = Target({
        isActive: true,
        minBalanceJuels: minBalancesJuels[idx],
        topUpAmountJuels: topUpAmountsJuels[idx],
        lastTopUpTimestamp: 0
      });
    }
    s_watchList = subscriptionIds;
  }

  /**
   * @notice Gets a list of subscriptions that are underfunded.
   * @return list of subscriptions that are underfunded
   */
  function getUnderfundedSubscriptions() public view returns (uint64[] memory) {
    uint64[] memory watchList = s_watchList;
    uint64[] memory needsFunding = new uint64[](watchList.length);
    uint256 count = 0;
    uint256 minWaitPeriod = s_minWaitPeriodSeconds;
    uint256 contractBalance = LINKTOKEN.balanceOf(address(this));
    Target memory target;
    for (uint256 idx = 0; idx < watchList.length; idx++) {
      target = s_targets[watchList[idx]];
      (uint96 subscriptionBalance, , , ) = COORDINATOR.getSubscription(watchList[idx]);
      if (
        target.lastTopUpTimestamp + minWaitPeriod <= block.timestamp &&
        contractBalance >= target.topUpAmountJuels &&
        subscriptionBalance < target.minBalanceJuels
      ) {
        needsFunding[count] = watchList[idx];
        count++;
        contractBalance -= target.topUpAmountJuels;
      }
    }
    if (count < watchList.length) {
      assembly {
        mstore(needsFunding, count)
      }
    }
    return needsFunding;
  }

  /**
   * @notice Send funds to the subscriptions provided.
   * @param needsFunding the list of subscriptions to fund
   */
  function topUp(uint64[] memory needsFunding) public whenNotPaused {
    uint256 minWaitPeriodSeconds = s_minWaitPeriodSeconds;
    uint256 contractBalance = LINKTOKEN.balanceOf(address(this));
    Target memory target;
    for (uint256 idx = 0; idx < needsFunding.length; idx++) {
      target = s_targets[needsFunding[idx]];
      (uint96 subscriptionBalance, , , ) = COORDINATOR.getSubscription(needsFunding[idx]);
      if (
        target.isActive &&
        target.lastTopUpTimestamp + minWaitPeriodSeconds <= block.timestamp &&
        subscriptionBalance < target.minBalanceJuels &&
        contractBalance >= target.topUpAmountJuels
      ) {
        bool success = LINKTOKEN.transferAndCall(
          address(COORDINATOR),
          target.topUpAmountJuels,
          abi.encode(needsFunding[idx])
        );
        if (success) {
          s_targets[needsFunding[idx]].lastTopUpTimestamp = uint56(block.timestamp);
          contractBalance -= target.topUpAmountJuels;
          emit TopUpSucceeded(needsFunding[idx]);
        } else {
          emit TopUpFailed(needsFunding[idx]);
        }
      }
      if (gasleft() < MIN_GAS_FOR_TRANSFER) {
        emit OutOfGas(idx);
        return;
      }
    }
  }

  /**
   * @notice Gets list of subscription ids that are underfunded and returns a keeper-compatible payload.
   * @return upkeepNeeded signals if upkeep is needed, performData is an abi encoded list of subscription ids that need funds
   */
  function checkUpkeep(
    bytes calldata
  ) external view override whenNotPaused returns (bool upkeepNeeded, bytes memory performData) {
    uint64[] memory needsFunding = getUnderfundedSubscriptions();
    upkeepNeeded = needsFunding.length > 0;
    performData = abi.encode(needsFunding);
    return (upkeepNeeded, performData);
  }

  /**
   * @notice Called by the keeper to send funds to underfunded addresses.
   * @param performData the abi encoded list of addresses to fund
   */
  function performUpkeep(bytes calldata performData) external override onlyKeeperRegistry whenNotPaused {
    uint64[] memory needsFunding = abi.decode(performData, (uint64[]));
    topUp(needsFunding);
  }

  /**
   * @notice Withdraws the contract balance in LINK.
   * @param amount the amount of LINK (in juels) to withdraw
   * @param payee the address to pay
   */
  function withdraw(uint256 amount, address payable payee) external onlyOwner {
    require(payee != address(0));
    emit FundsWithdrawn(amount, payee);
    LINKTOKEN.transfer(payee, amount);
  }

  /**
   * @notice Sets the LINK token address.
   */
  function setLinkTokenAddress(address linkTokenAddress) public onlyOwner {
    require(linkTokenAddress != address(0));
    emit LinkTokenAddressUpdated(address(LINKTOKEN), linkTokenAddress);
    LINKTOKEN = LinkTokenInterface(linkTokenAddress);
  }

  /**
   * @notice Sets the VRF coordinator address.
   */
  function setVRFCoordinatorV2Address(address coordinatorAddress) public onlyOwner {
    require(coordinatorAddress != address(0));
    emit VRFCoordinatorV2AddressUpdated(address(COORDINATOR), coordinatorAddress);
    COORDINATOR = VRFCoordinatorV2Interface(coordinatorAddress);
  }

  /**
   * @notice Sets the keeper registry address.
   */
  function setKeeperRegistryAddress(address keeperRegistryAddress) public onlyOwner {
    require(keeperRegistryAddress != address(0));
    emit KeeperRegistryAddressUpdated(s_keeperRegistryAddress, keeperRegistryAddress);
    s_keeperRegistryAddress = keeperRegistryAddress;
  }

  /**
   * @notice Sets the minimum wait period (in seconds) for subscription ids between funding.
   */
  function setMinWaitPeriodSeconds(uint256 period) public onlyOwner {
    emit MinWaitPeriodUpdated(s_minWaitPeriodSeconds, period);
    s_minWaitPeriodSeconds = period;
  }

  /**
   * @notice Gets configuration information for a subscription on the watchlist.
   */
  function getSubscriptionInfo(
    uint64 subscriptionId
  ) external view returns (bool isActive, uint96 minBalanceJuels, uint96 topUpAmountJuels, uint56 lastTopUpTimestamp) {
    Target memory target = s_targets[subscriptionId];
    return (target.isActive, target.minBalanceJuels, target.topUpAmountJuels, target.lastTopUpTimestamp);
  }

  /**
   * @notice Gets the list of subscription ids being watched.
   */
  function getWatchList() external view returns (uint64[] memory) {
    return s_watchList;
  }

  /**
   * @notice Pause the contract, which prevents executing performUpkeep.
   */
  function pause() external onlyOwner {
    _pause();
  }

  /**
   * @notice Unpause the contract.
   */
  function unpause() external onlyOwner {
    _unpause();
  }

  modifier onlyKeeperRegistry() {
    if (msg.sender != s_keeperRegistryAddress) {
      revert OnlyKeeperRegistry();
    }
    _;
  }
}

What's next

Stay updated on the latest Chainlink news