Documentation
Loading...

Build Token Locking & Signed Reward Claims with BuildBear's Time Advancement Plugin

A hands-on tutorial for creating a QuestManager contract that locks tokens, verifies admin signatures, and distributes rewards. Test real on-chain time-based flows with BuildBear’s Time Advancement.

Introduction

In this tutorial, we’ll build Token Rewards Contract system that lets owners or admins create quests, lock reward funds (native or ERC20), and distribute them to winners after the quest ends.

Instead of using mocked timestamps in tests, you’ll use BuildBear’s Time Advancement feature to actually warp the blockchain time forward inside your Sandbox. This lets you see your contract’s behavior as if days had passed, directly in the BuildBear Explorer and Debugger.


What We Will Learn

In this guide, we will build and explore a Quest-Manager smart contract, focusing on handling time-sensitive rewards and leveraging BuildBear's Time Advancement feature to test and interact with smart contracts.

  • Contract Foundation: Create and deploy a Quest-Manager contract capable of locking reward funds, both in native tokens (like ETH/POL) and standard ERC20 tokens.
  • Quest Creation: Learn how to initialize a new quest by defining its reward pool and setting a completion deadline using a Unix timestamp.
  • Reward Mechanics: Fund a quest by locking native tokens directly into the contract, understanding how the contract securely holds the rewards.
  • Off-Chain Verification: Implement a secure claim system where users can claim rewards only with a valid, off-chain signature from an admin, preventing unauthorized access.
  • Testing Time Constraints: Simulate a real-world scenario by attempting to claim a reward before the quest's end time, confirming that the transaction is correctly reverted.
  • BuildBear's Time Advancement: Discover how BuildBear's environment allows you to fast-forward time in your testnet, solving the classic challenge of testing time-dependent logic without long waits.
  • Successful Reward Claim: Advance the blockchain's timestamp and successfully execute a reward claim, verifying that the user receives the correct amount.
  • BuildBear Explorer: Use the BuildBear Explorer to monitor all contract deployments, transactions, and internal calls in real-time, just like a mainnet block explorer.
  • Debugging with Sentio: Dive deep into transaction execution by using the integrated Sentio debugger to step through code, inspect variables, and resolve issues efficiently.

Project Repository

We’ve published this tutorial’s code on GitHub:


Directory and Project Structure

QuestManager.sol
utils/VerifySignature.sol
interfaces/IERC20.sol
constants/MainnetConstants.sol
SignedQuestManager.Deploy.s.sol
CreateQuest.s.sol
SignAndClaim.s.sol
HelperConfig.s.sol
SignedQuestManager.t.sol
foundry.toml
Makefile
.env
.env.example

How It Works

Quest Lifecycle Diagram

high-level diagram

  • The quest owner/admin creates a new quest on the QuestManager contract.
  • Owner deposits rewards (either native token like ETH or ERC-20s) into the contract.
  • QuestManager emits a FundsLocked event.
  • After the lock period, the owner signs a message off-chain declaring the winner and reward amount.
  • The signed message is sent to the winner.
  • Winner submits the signature and metadata to claimRewards() on QuestManager.
  • QuestManager verifies the signature, ensures reward is unclaimed and valid.
  • Rewards are transferred to the winner, and RewardClaimed is emitted.

Contract Call Flow Diagram

contract-internals

  • Admin/owner signs a message off-chain declaring the winner and reward metadata (questId, address, amount, etc.).
  • The winner receives the signature + metadata and calls claimRewards() on the QuestManager.
  • QuestManager passes inputs to the VerifySignature contract, which:
    • Rebuilds the hashed message
    • Recovers the signer address
  • If the signer is verified to be the admin/owner:
    • QuestManager transfers the reward (native or ERC-20) to the winner.
    • Emits RewardClaimed.
  • If the signer is not authorized:
    • The transaction reverts with an error.
  • This ensures replay protection and authentic reward distribution.

Step 1: Create Sandbox & Install Plugins


Step 2: Environment Variables

Copy .env.example to .env and fill in:

PRIVATE_KEY=
WINNER_PRIVATE_KEY=
MNEMONIC=
BUILDBEAR_RPC=

Need a new wallet keypair? Create one with cast

cast wallet new

Once you have configured your wallets, you will need to fund your wallets with enough funds to:

Why do we need to pre-fund wallet-addresses?

The creators of tasks and events need to lock some native/erc20 tokens to distribute to the winners, thus needs to be funded with either whitelisted ERC20s or Native tokens, whichever may be used for creating a task/event.

In foundry.toml we set up a buildbear RPC endpoint to point at our Sandbox.

[profile.default]
src = "src"
out = "out"
libs = ["lib"]
fs_permissions = [
    { access = "read", path = "./broadcast/SignedQuestManager.Deploy.s.sol" },
]

remappings = ["@openzeppelin/contracts=lib/openzeppelin-contracts/contracts/"]

[rpc_endpoints]
buildbear = "${BUILDBEAR_RPC}"

We can also create a Makefile to simplify commands for building, deploying, and interacting with our contract:

SHELL := /bin/bash

# Makefile

.PHONY: install build deploy-sourcify deploy-etherscan create-new-quest claim-quest-reward

install:
	@echo "Installing dependencies..."
	forge install

build:
	@echo "Building project..."
	forge build


deploy-sourcify:
	forge script script/SignedQuestManager.Deploy.s.sol \
	--rpc-url buildbear \
	--verifier sourcify \
	--verify --verifier-url https://rpc.buildbear.io/verify/sourcify/server/SANDBOX_ID \
	--broadcast

deploy-etherscan:
	forge script script/SignedQuestManager.Deploy.s.sol \
	--rpc-url buildbear \
	--etherscan-api-key "verifyContract" \
	--verifier-url "https://rpc.buildbear.io/verify/etherscan/SANDBOX_ID" \
	--broadcast \
	--verify

create-new-quest:
	forge script script/CreateQuest.s.sol \
	--rpc-url buildbear \
	--broadcast


claim-quest-reward:
	 forge script script/SignAndClaim.s.sol \
	 --rpc-url buildbear \
	 --broadcast

Replace SANDBOX_ID with your actual BuildBear Sandbox ID

Lastly, we will also need a helper config script to manage network configurations in HelperConfig.s.sol. We have multiple network configs for various testnets and mainnets, but we only ever need Ethereum Mainnet Sandbox for this tutorial.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {Script} from "forge-std/Script.sol";

contract HelperConfig is Script {
    struct NetworkConfig {
        uint256 deployerKey;
    }

    NetworkConfig public activeNetworkConfig;
    uint256 public DEFAULT_ANVIL_PRIVATE_KEY = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80;
    mapping(uint256 chainId => NetworkConfig) public networkConfigs;

    error HelperConfig__InvalidChainId();

    constructor() {
        /* Activate Mainnet Configs */

        networkConfigs[1] = getEthMainnetConfig();
    }
    //////////////////////////////////////////////////////////////*/

    function getConfig() public view returns (NetworkConfig memory) {
        return getConfigByChainId(block.chainid);
    }

    function getConfigByChainId(uint256 chainId) public view returns (NetworkConfig memory) {
        if (networkConfigs[chainId].deployerKey != uint256(0)) {
            return networkConfigs[chainId];
        } else {
            revert HelperConfig__InvalidChainId();
        }
    }
    /**
     * Testnet Configs
     */

    function getPolygonAmoyConfig() public view returns (NetworkConfig memory) {
        return NetworkConfig({deployerKey: vm.envUint("PRIVATE_KEY")});
    }

    function getOptimismSepoliaConfig() public view returns (NetworkConfig memory) {
        return NetworkConfig({deployerKey: vm.envUint("PRIVATE_KEY")});
    }

    function getBaseSepoliaConfig() public view returns (NetworkConfig memory) {
        return NetworkConfig({deployerKey: vm.envUint("PRIVATE_KEY")});
    }

    function getOrCreateAnvilConfig() public view returns (NetworkConfig memory) {
        return NetworkConfig({deployerKey: DEFAULT_ANVIL_PRIVATE_KEY});
    }

    function getEthSepoliaConfig() public view returns (NetworkConfig memory) {
        return NetworkConfig({deployerKey: vm.envUint("PRIVATE_KEY")});
    }

    function getArbitrumSepoliaConfig() public view returns (NetworkConfig memory) {
        return NetworkConfig({deployerKey: vm.envUint("PRIVATE_KEY")});
    }

    /**
     * Mainnet Configs
     */
    function getEthMainnetConfig() public view returns (NetworkConfig memory) {
        return NetworkConfig({deployerKey: vm.envUint("PRIVATE_KEY")});
    }

    function getOptimismMainnetConfig() public view returns (NetworkConfig memory) {
        return NetworkConfig({deployerKey: vm.envUint("PRIVATE_KEY")});
    }

    function getBaseMainnetConfig() public view returns (NetworkConfig memory) {
        return NetworkConfig({deployerKey: vm.envUint("PRIVATE_KEY")});
    }

    function getPolygonMainnetConfig() public view returns (NetworkConfig memory) {
        return NetworkConfig({deployerKey: vm.envUint("PRIVATE_KEY")});
    }

    function getArbitrumMainnetConfig() public view returns (NetworkConfig memory) {
        return NetworkConfig({deployerKey: vm.envUint("PRIVATE_KEY")});
    }
}

Step 3: The QuestManager Contract

The QuestManager contract is the heart of this tutorial.

  • Quests: Each quest has an ID, title, start and end time, reward token, reward pool, reward method (FCFS, LuckyDraw), reward type (ERC20 or Native), and number of winners.

  • Storage: It tracks all quests, user-created quests, allowed tokens, admins, winners, and current winners count.

  • createQuest(): Called by an owner/admin to create a new quest. It checks parameters, pushes quest data to storage, then calls _lockFunds to deposit tokens into the contract.

  • _lockFunds(): Handles actually locking either native ETH/POL or ERC20 tokens into the contract.

  • claimRewards(): This is where winners claim. It:

    1. Checks that the quest exists.
    2. Checks quest end timestamp (must have ended).
    3. Checks the signature was produced by an admin/owner.
    4. Calculates the per-winner reward and transfers it.
    5. Marks the winner as claimed and emits an event.

Why Signature Verification?

We don’t want anyone claiming arbitrarily. Admin/owner signs an off-chain message with (questId, winner, reward, message) and passes the signature to the contract. The contract uses VerifySignature to recover the signer and verify it matches an admin or owner.

This pattern decouples the on-chain state from off-chain whitelisting, reducing gas and complexity.

QuestManager.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {VerifySignature} from "./utils/VerifySignature.sol";

/**
 * @title QuestManager with Signature Verification
 * @author 0xJustUzair
 * @notice THIS CONTRACT IS EXTENSION OF DEMO CONTRACT WITH SIGNATURE VERIFICATION
 */
contract QuestManager is Ownable {
    /*
    @dev - adding indexed param will change the value of the field
    */
    event QuestManager__EventCreated(string _id);
    event QuestManager__FundsLocked(string _id, address indexed token, uint256 amount);
    event QuestManager__RewardClaimed(string _id, address indexed winner, uint256 amount);
    /*---------------Custom Types -----------------*/

    enum RewardType {
        ERC20, // default ERC20
        NATIVE
    }

    enum RewardMethod {
        FCFS, // default FCFS
        LUCKY_DRAW
    }

    /*
    @params - id: unique identifier for the quest from db
    @params - title: title of the quest
    @params - startTimestamp: start time of the quest
    @params - endTimestamp: end time of the quest
    @params - rewardToken: address of the reward token
    @params - rewardTokenPool: total reward pool / amount of reward tokens
    @params - rewardMethod: method of rewarding the users
    @params - rewardType: type of reward token
    @params - winners: number of winners for the quest

    */
    struct Quest {
        string id;
        string title;
        uint256 startTimestamp;
        uint256 endTimestamp;
        address rewardToken;
        uint256 rewardTokenPool;
        RewardMethod rewardMethod;
        RewardType rewardType;
        uint256 winners;
    }

    /*---------------Storage Variables -----------------*/

    using Strings for string;

    VerifySignature public immutable i_verificaitonContract;
    Quest[] public quests;
    // keep track of user to quests mapping
    mapping(address user => Quest[]) public userQuests;
    // keep track of questId to Quest mapping
    mapping(string id => Quest) public questById;
    // isAllowedToken is used to whitelist the tokens
    mapping(address token => bool isWhiteListed) public isAllowedToken;
    // keep track of if the user is an admin
    mapping(address user => bool isAdmin) public admin;
    // keep track of the allowed total winners for the quest
    mapping(string id => uint256 currentWinners) public currentTotalWinners;
    // keep track of if the user is the quest winner
    mapping(string questId => mapping(address winner => bool isWinner)) public isQuestWinner;
    // keep track of winners for quests
    mapping(string questId => address[] winners) public questWinners;

    modifier _onlyAdminOrOwner() {
        require(msg.sender == owner() || admin[msg.sender], "Not admin/owner");
        _;
    }

    constructor(address _owner, address[] memory _allowedTokenAddresses) Ownable(_owner) {
        uint256 len = _allowedTokenAddresses.length;
        for (uint256 i = 0; i < len;) {
            isAllowedToken[_allowedTokenAddresses[i]] = true;
            unchecked {
                ++i;
            }
        }
        isAllowedToken[address(0)] = true;
        i_verificaitonContract = new VerifySignature();
    }

    function addAdmin(address user) external onlyOwner {
        admin[user] = true;
    }

    function removeAdmin(address user) external onlyOwner {
        admin[user] = false;
    }

    function createQuest(
        string memory _id,
        string memory _title,
        uint256 _startTimestamp,
        uint256 _endTimestamp,
        address _rewardToken,
        uint256 _rewardTokenPool,
        RewardMethod _rewardMethod,
        RewardType _rewardType,
        uint256 _winners
    ) public payable {
        require((questById[_id].id.equal("")), "Quest already exists");
        require(isAllowedToken[_rewardToken], "Token not allowed");
        require(bytes(_title).length > 0, "Title is empty");
        require(_endTimestamp > _startTimestamp, "Invalid Quest Time");
        require(_rewardTokenPool > 0, "Reward amount is 0");

        Quest memory quest = Quest({
            id: _id,
            title: _title,
            startTimestamp: _startTimestamp,
            endTimestamp: _endTimestamp,
            rewardToken: _rewardToken,
            rewardTokenPool: _rewardTokenPool,
            rewardMethod: _rewardMethod,
            rewardType: _rewardType,
            winners: _winners
        });

        quests.push(quest);
        userQuests[msg.sender].push(quest);
        questById[_id] = quest;

        _lockFunds(quest.id, quest.rewardToken);

        emit QuestManager__EventCreated(_id);
    }

    function _lockFunds(string memory _id, address token) internal {
        Quest memory quest = questById[_id];
        uint256 amount = quest.rewardTokenPool;
        require(amount > 0, "Amount is 0");
        if (address(token) == address(0) || quest.rewardType == RewardType.NATIVE) {
            // logic to lock
            require(msg.value == amount, "Native Deposit failed");
        } else {
            require(isAllowedToken[quest.rewardToken], "Token is not allowed");
            bool result = IERC20(token).transferFrom(msg.sender, address(this), amount);
            require(result, "ERC20 Deposit failed");
        }
        emit QuestManager__FundsLocked(_id, token, amount);
    }

    // @TODO REMOVE ADMIN

    function claimRewards(
        address _signer,
        bytes memory signature,
        string memory _message,
        string memory _id,
        address payable winner
    ) external {
        // logic to claim rewards on behalf of user
        Quest memory quest = questById[_id];
        require(quest.id.equal(_id), "Quest not found");
        require(!isQuestWinner[quest.id][address(winner)], "Reward already claimed");
        require(block.timestamp >= quest.endTimestamp, "Quest not ended yet");
        require(quest.winners > currentTotalWinners[_id], "All rewards claimed");
        uint256 reward = calcuateTokenReward(_id);

        (bool status, address signer) =
            i_verificaitonContract.verify(_signer, _id, address(winner), reward, _message, signature);
        require(status && (signer == owner() || admin[signer]), "Claim Not Signed by Admin / Owner");

        if (quest.rewardType == RewardType.NATIVE) {
            // logic to claim rewards for Native
            (bool success,) = payable(winner).call{value: reward}("");
            require(success, "Native Claim failed");
        } else {
            // logic to claim rewards for ERC20
            IERC20(quest.rewardToken).balanceOf(address(this));
            IERC20(quest.rewardToken).approve(address(winner), reward);
            bool success = IERC20(quest.rewardToken).transfer(winner, reward);
            require(success, "ERC20 Claim failed");
        }

        isQuestWinner[quest.id][address(winner)] = true;
        questWinners[quest.id].push(address(winner));
        unchecked {
            ++currentTotalWinners[_id];
        }
        emit QuestManager__RewardClaimed(_id, address(winner), reward);
    }

    function calcuateTokenReward(string memory _questId) public view returns (uint256) {
        Quest memory quest = questById[_questId];
        require(!quest.id.equal(""), "No Quest Found");
        uint256 rewardPool = quest.rewardTokenPool;
        require(rewardPool > 0, "Reward Pool is 0");
        uint256 numWinners = quest.winners;
        require(numWinners > 0, "No Winners");
        uint256 result = (rewardPool / numWinners);
        return result;
    }

    function getQuests() external view returns (Quest[] memory) {
        return quests;
    }

    function getUserQuests(address user) external view returns (Quest[] memory) {
        return userQuests[user];
    }

    function getQuestWinners(string memory _questId) external view returns (address[] memory) {
        return questWinners[_questId];
    }

    function checkIfUserIsWinner(string memory _questId, address user) external view returns (bool) {
        return isQuestWinner[_questId][user];
    }

    function getCurrentTotalWinners(string memory _questId) external view returns (uint256) {
        return currentTotalWinners[_questId];
    }

    function getPendingNumWinnersForQuest(string memory _questId) external view returns (uint256) {
        Quest memory quest = questById[_questId];
        return quest.winners - currentTotalWinners[_questId];
    }

    // Receive native tokens to lock funds
    fallback() external payable {}

    receive() external payable {}
}

Step 4: Deploy Script and Deploying the QuestManager Contract

We can create a simple deploy script:

SignedQuestManager.Deploy.s.sol

// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.25;

import {Script} from "forge-std/Script.sol";
import {HelperConfig} from "./HelperConfig.s.sol";
import {console2} from "forge-std/console2.sol";
import {QuestManager} from "../src/QuestManager.sol";
import {IERC20} from "../src/interfaces/IERC20.sol";
import {MainnetConstants} from "../src/constants/MainnetConstants.sol";

contract MainnetDeploySignedQuestManager is Script {
    IERC20 weth;
    IERC20 usdc;
    IERC20 link;

    QuestManager questManager;
    MainnetConstants constants;

    function run() public {
        uint256 deployerKey;
        address deployer;
        string memory mnemonic = vm.envString("MNEMONIC");
        console2.log(mnemonic);
        (deployer, deployerKey) = deriveRememberKey(mnemonic, 0);

        HelperConfig helperConfig = new HelperConfig();

        HelperConfig.NetworkConfig memory config = helperConfig.getConfig();
        vm.startBroadcast(config.deployerKey);

        constants = new MainnetConstants();
        weth = IERC20(constants.WETH());
        link = IERC20(constants.LINK());
        usdc = IERC20(constants.USDC());

        address[] memory tokenAddresses = new address[](4);
        tokenAddresses[0] = address(weth);
        tokenAddresses[1] = address(usdc);
        tokenAddresses[2] = address(link);

        questManager = new QuestManager(address(vm.addr(config.deployerKey)), tokenAddresses);

        console2.log("WETH address : ", address(weth));
        console2.log("USDC address : ", address(usdc));
        console2.log("link address : ", address(link));
        console2.log("verification contract : ", address(questManager.i_verificaitonContract()));
        console2.log("questManager deployed at : ", address(questManager));
        vm.stopBroadcast();
    }
}

Use the command from Makefile:

make deploy-sourcify

You can alternatively use deploy-etherscan to deploy and verify on Etherscan.

This will deploy QuestManager with whitelisted tokens to your Sandbox. Check the BuildBear Explorer for the deployment transaction.

sourcify deploy

You can view the contract address in the BuildBear Explorer.

verified contract


Step 5: Create a Quest

Similar to the deploy script, we can create a script to create a new quest. It will refer to the latest contract deployment from run-latest.json in broadcast directory

CreateQuest.s.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {Script} from "forge-std/Script.sol";
import {QuestManager} from "../src/QuestManager.sol";
import {console2} from "forge-std/console2.sol";
import {stdJson} from "forge-std/StdJson.sol";

contract CreateQuest is Script {
    using stdJson for string;

    function run() external {
        // load json from broadcast file
        string memory root = vm.projectRoot();
        string memory path = string.concat(root, "/broadcast/SignedQuestManager.Deploy.s.sol/1/run-latest.json");
        string memory json = vm.readFile(path);

        // extract QuestManager address
        address questManagerAddr = json.readAddress(".transactions[1].contractAddress");
        console2.log("QuestManager at:", questManagerAddr);

        QuestManager questManager = QuestManager(payable(questManagerAddr));

        // params
        string memory questId = "customQuest-02";
        string memory title = "BuildBear Demo Quest";
        uint256 startTime = block.timestamp;
        uint256 endTime = block.timestamp + 3 days;
        address rewardToken = address(0); // native
        uint256 rewardPool = 3 ether;
        QuestManager.RewardMethod method = QuestManager.RewardMethod.FCFS;
        QuestManager.RewardType rewardType = QuestManager.RewardType.NATIVE;
        uint256 winners = 3;

        uint256 deployerKey = vm.envUint("PRIVATE_KEY");

        vm.startBroadcast(deployerKey);
        questManager.createQuest{value: rewardPool}(
            questId, title, startTime, endTime, rewardToken, rewardPool, method, rewardType, winners
        );
        vm.stopBroadcast();

        console2.log("Quest created:", questId);
    }
}

Run the following command from Makefile:

make create-new-quest

This calls CreateQuest.s.sol which:

  • Reads the deployed QuestManager address from broadcast JSON
  • Creates a quest with ID "customQuest-01"
  • Locks 3 ether as reward pool for 3 winners
  • Start time = now, End time = now + 3 days

You’ll see the quest creation transaction in the BuildBear Explorer. You can also observe 3 ether sent to the contract to lock. (Check rewardPool variable in the above script)

If you want you can also lock ERC20 tokens instead of native tokens. Just change the rewardToken address to a whitelisted ERC20 token address and ensure your deployer wallet has enough balance and has approved the contract to spend the tokens.

To modify the script to use ERC20 instead of native, refer to the following detailed test file

Example of ERC20 Quest Creation inside the SignedQuestManager.t.sol test file
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {Test} from "forge-std/Test.sol";
import {QuestManager} from "../../src/QuestManager.sol";
import {console} from "forge-std/console.sol";
import {IERC20} from "../../src/interfaces/IERC20.sol";
import {MainnetConstants} from "../../src/constants/MainnetConstants.sol";

contract QuestManagerTest is Test {
    address owner = makeAddr("owner");
    address admin1 = makeAddr("admin1");
    address admin2 = makeAddr("admin2");
    address user1 = makeAddr("user1");
    address user2 = makeAddr("user2");
    address user3 = makeAddr("user3");
    address user4 = makeAddr("user4");
    MainnetConstants constants;

    IERC20 weth;
    IERC20 link;
    IERC20 usdc;

    QuestManager questManager;

    function setUp() public {
        string memory bbMainnet = vm.envString("BUILDBEAR_RPC");
        uint256 fork = vm.createFork(bbMainnet);
        vm.selectFork(fork);
        constants = new MainnetConstants();
        weth = IERC20(constants.WETH());
        link = IERC20(constants.LINK());
        usdc = IERC20(constants.USDC());

        string[] memory tokenNames = new string[](4);
        tokenNames[0] = "WETH";
        tokenNames[1] = "LINK";
        tokenNames[2] = "USDC";

        address[] memory tokenAddresses = new address[](4);
        tokenAddresses[0] = address(weth);
        tokenAddresses[1] = address(link);
        tokenAddresses[2] = address(usdc);

        vm.deal(owner, 100 ether);
        // vm.deal(admin1, 100 ether);
        // vm.deal(admin2, 100 ether);
        // vm.deal(user1, 100 ether);
        // vm.deal(user2, 100 ether);
        // vm.deal(user3, 100 ether);
        // vm.deal(user4, 100 ether);

        vm.startBroadcast(owner);
        questManager = new QuestManager(owner, tokenAddresses);
        questManager.transferOwnership(owner);

        deal(address(weth), owner, 10 ether);
        deal(address(weth), admin1, 10 ether);
        deal(address(weth), admin2, 10 ether);
        deal(address(weth), user1, 10 ether);
        deal(address(weth), user2, 10 ether);
        deal(address(weth), user3, 10 ether);
        deal(address(weth), user4, 10 ether);

        deal(address(usdc), owner, 1000 ether);
        deal(address(usdc), admin1, 1000 ether);
        deal(address(usdc), admin2, 1000 ether);
        deal(address(usdc), user1, 1000 ether);
        deal(address(usdc), user2, 1000 ether);
        deal(address(usdc), user3, 10 ether);
        deal(address(usdc), user4, 10 ether);

        deal(address(link), owner, 1000 ether);
        deal(address(link), admin1, 1000 ether);
        deal(address(link), admin2, 1000 ether);
        deal(address(link), user1, 1000 ether);
        deal(address(link), user2, 1000 ether);
        deal(address(link), user3, 10 ether);
        deal(address(link), user4, 10 ether);

        vm.stopBroadcast();

        console.log("address of owner ", address(owner));
        console.log("address of admin1 ", address(admin1));
        console.log("address of admin2 ", address(admin2));
        console.log("address of user1 ", address(user1));
        console.log("address of user2 ", address(user2));
        console.log("address of user3 ", address(user3));
        console.log("address of user4 ", address(user4));
    }

    function test_claimRewardsSignedByNonAdminNonOwner() public {
        (, uint256 key) = makeAddrAndKey("owner");

        vm.startBroadcast(owner);
        weth.approve(address(questManager), 1e18);
        questManager.createQuest(
            "4a",
            "Test Quest",
            block.timestamp,
            block.timestamp + 1 days,
            address(weth),
            1e18,
            QuestManager.RewardMethod(0),
            QuestManager.RewardType(0),
            3
        );

        // Steps to sign claimRewards Message

        uint256 rewards = questManager.calcuateTokenReward("4a");

        bytes32 message = questManager.i_verificaitonContract().getMessageHash("4a", user1, rewards, "");
        bytes32 signedMessageHash = questManager.i_verificaitonContract().getEthSignedMessageHash(message);
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, signedMessageHash);
        bytes memory signature = abi.encodePacked(r, s, v);

        vm.stopBroadcast();

        vm.startBroadcast(user1);

        uint256 balanceBeforeUser1 = weth.balanceOf(user1);

        vm.warp(block.timestamp + 16 days);

        questManager.claimRewards(owner, signature, "", "4a", payable(user1));

        console.log("weth balance of user1 ", weth.balanceOf(user1) - balanceBeforeUser1);

        assert(weth.balanceOf(user1) - balanceBeforeUser1 == rewards);
        vm.stopBroadcast();
    }

    function test_claimRewardsSignedByOwner() public {
        (, uint256 key) = makeAddrAndKey("user2");

        vm.startBroadcast(user2);
        weth.approve(address(questManager), 1e18);
        questManager.createQuest(
            "4a",
            "Test Quest",
            block.timestamp,
            block.timestamp + 1 days,
            address(weth),
            1e18,
            QuestManager.RewardMethod(0),
            QuestManager.RewardType(0),
            3
        );

        // Steps to sign claimRewards Message

        uint256 rewards = questManager.calcuateTokenReward("4a");

        bytes32 message = questManager.i_verificaitonContract().getMessageHash("4a", user1, rewards, "");
        bytes32 signedMessageHash = questManager.i_verificaitonContract().getEthSignedMessageHash(message);
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, signedMessageHash);
        bytes memory signature = abi.encodePacked(r, s, v);

        vm.stopBroadcast();

        vm.startBroadcast(user1);
        vm.warp(block.timestamp + 16 days);
        vm.expectRevert("Claim Not Signed by Admin / Owner");
        questManager.claimRewards(owner, signature, "", "4a", payable(user1));

        vm.stopBroadcast();
    }

    function test_createCustomQuest() public {
        // 6648f67fbab2293153b9b798, Test with NATIVE , 1716057660000, 1717872060000, 0x0000000000000000000000000000000000000000, 1, 1, 1, 3)

        vm.startBroadcast(owner);
        questManager.createQuest{value: 1e18}(
            "6648f67fbab2293153b9b798",
            "Test with NATIVE",
            1716057660000,
            1717872060000,
            0x0000000000000000000000000000000000000000,
            1e18,
            QuestManager.RewardMethod(1),
            QuestManager.RewardType(1),
            3
        );
        vm.stopBroadcast();
    }

    function test_createCustomQuestERC20() public {
        // 6648f67fbab2293153b9b798, Test with NATIVE , 1716057660000, 1717872060000, 0x0000000000000000000000000000000000000000, 1, 1, 1, 3)

        vm.startBroadcast(owner);
        usdc.approve(address(questManager), 1e6);
        questManager.createQuest(
            "6648f67fbab2293153b9b798",
            "Test with NATIVE",
            1716057660000,
            1717872060000,
            address(usdc),
            1,
            QuestManager.RewardMethod(1),
            QuestManager.RewardType(0),
            3
        );
        vm.stopBroadcast();
    }

    function test_claimRewardsSignedByOwnerWithDBData() public {
        (, uint256 key) = makeAddrAndKey("owner");
        vm.startBroadcast(owner);
        deal(address(weth), owner, 100 ether);
        vm.stopBroadcast();

        vm.startBroadcast(owner);
        weth.approve(address(questManager), 5e16);
        questManager.createQuest(
            "664afb3aa1ca7805e2c6b0ac",
            "Test Quest",
            block.timestamp,
            block.timestamp + 1 days,
            address(weth),
            5e16,
            QuestManager.RewardMethod(1),
            QuestManager.RewardType(0),
            5
        );

        // Steps to sign claimRewards Message

        uint256 rewards = questManager.calcuateTokenReward("664afb3aa1ca7805e2c6b0ac");

        bytes32 message = questManager.i_verificaitonContract().getMessageHash(
            "664afb3aa1ca7805e2c6b0ac", 0xA72e562f24515C060F36A2DA07e0442899D39d2c, rewards, "Signed By : owner"
        );

        bytes32 signedMessageHash = questManager.i_verificaitonContract().getEthSignedMessageHash(message);
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, signedMessageHash);
        bytes memory signature = abi.encodePacked(r, s, v);

        (bool isValid, address signer) = questManager.i_verificaitonContract().verify(
            owner,
            "664afb3aa1ca7805e2c6b0ac",
            0xA72e562f24515C060F36A2DA07e0442899D39d2c,
            rewards,
            "Signed By : owner",
            signature
        );
        console.log(isValid, signer);

        vm.stopBroadcast();

        vm.startBroadcast(0xA72e562f24515C060F36A2DA07e0442899D39d2c);
        vm.warp(block.timestamp + 16 days);
        // vm.expectRevert("Claim Not Signed by Admin / Owner");
        questManager.claimRewards(
            owner,
            signature,
            "Signed By : owner",
            "664afb3aa1ca7805e2c6b0ac",
            payable(0xA72e562f24515C060F36A2DA07e0442899D39d2c)
        );

        vm.stopBroadcast();
    }
}

Step 6: Claiming Rewards by the Winner(s)

Use the SignAndClaim.s.sol script. It:

  • Reads quest info and calculates the per-winner reward
  • Signs the claim off-chain using the admin/owner private key (PRIVATE_KEY)
  • Broadcasts the claim transaction as the winner (WINNER_PRIVATE_KEY)

SignAndClaim.s.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import {Script} from "forge-std/Script.sol";
import {QuestManager} from "../src/QuestManager.sol";
import {console2} from "forge-std/console2.sol";
import {stdJson} from "forge-std/StdJson.sol";

contract ClaimRewardsScript is Script {
    using stdJson for string;

    function run() external {
        string memory root = vm.projectRoot();
        string memory path = string.concat(root, "/broadcast/SignedQuestManager.Deploy.s.sol/1/run-latest.json");
        string memory json = vm.readFile(path);

        address questManagerAddr = json.readAddress(".transactions[1].contractAddress");
        console2.log("QuestManager at:", questManagerAddr);

        QuestManager questManager = QuestManager(payable(questManagerAddr));
        uint256 winnerKey = vm.envUint("WINNER_PRIVATE_KEY");
        uint256 signerKey = vm.envUint("PRIVATE_KEY");

        // --- parameters ---
        string memory questId = "customQuest-02";
        address winner = vm.addr(winnerKey);
        address signer = vm.addr(signerKey); // must be owner or admin

        uint256 reward = questManager.calcuateTokenReward(questId);

        // sign message

        bytes32 message =
            questManager.i_verificaitonContract().getMessageHash(questId, winner, reward, "Signed for claim");
        bytes32 ethSignedMessageHash = questManager.i_verificaitonContract().getEthSignedMessageHash(message);

        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, ethSignedMessageHash);
        bytes memory signature = abi.encodePacked(r, s, v);

        // broadcast as winner to claim
        vm.startBroadcast(winnerKey);
        questManager.claimRewards(signer, signature, "Signed for claim", questId, payable(winner));
        vm.stopBroadcast();

        console2.log("Rewards claimed by", winner);
    }
}

Run the following command:

make claim-quest-reward

Reverted Call to Claim Quest Rewards (Before Time Advancement)

Before the quest end time, the claimRewards call will revert with "Quest not ended yet". This is because the quest is still active (3 days from the time of creation) and cannot be claimed yet.


Step 7: Advance Time in BuildBear Dashboard

We’ll use the BuildBear Dashboard:

  1. Open your BuildBear dashboard and choose your Sandbox.
  2. Click on Launch under the Time Advancement app to open the time-advancement.
  3. A window will open, where you can interact with and allow to set your Sandbox to a future time.
  4. Choose a time after the quest end time (3 days from quest creation) and click Advance Time.

Step 8: Successful Claim after Time Advancement

This time, the claim transaction succeeds. You’ll see QuestManager__RewardClaimed event and the winner’s balance updated in the BuildBear Explorer.

Run the command again:

make claim-quest-reward

We can again view the claim transaction in the BuildBear Explorer.


Step 9: Debugging with Sentio (Optional)

If you installed the Sentio Plugin, you can view:

  • Quest creation events
  • Fund locking
  • Successful claim trace and fund flow, etc

Open the claim reward tx and click on "View Trace on Sentio":

Fund Flow

This tab provides a high-level overview of fund movement directions throughout the transaction.

Call Trace

This tab displays the complete transaction call trace with detailed internal calls, offering granular control over trace depth exploration.

Events

This tab lists all events emitted during transaction execution.

State

This tab shows contract and EOA state changes before and after the transaction.


Conclusion

You’ve now built a QuestManager system in BuildBear:

  • Real token locking (native or ERC20)
  • Admin-signed reward claims
  • Fail-then-succeed claim flow using BuildBear Time Advancement
  • Fully observable on BuildBear Explorer and Debugger

This tutorial shows how a user can use BuildBear to test, deploy and interact with time-sensitive contract(s) without mocks or simulations.