RISE Logo-Light

Smart Contract

Build a VRF-powered rock-paper-scissors contract with ticket-based game economy

Contract Architecture

Our smart contract will:

  • Accept ticket purchases from players (0.001 ETH per ticket)
  • Request random numbers from RISE VRF Coordinator
  • Receive VRF fulfillment with random AI choice
  • Emit events for the backend to watch
  • Manage ticket balances and request mappings

VRF Integration Overview

RISE VRF uses a standard consumer pattern:

  1. Your contract implements IVRFConsumer interface
  2. Call coordinator.requestRandomNumbers() to request randomness
  3. Coordinator calls back your rawFulfillRandomNumbers() with results
  4. Process the random numbers and emit events

Writing the Contract

Create Contract File

Navigate to your Foundry project and create the contract:

cd foundry-project

Create src/RockPaperScissors.sol:

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

// RISE VRF Interfaces
interface IVRFCoordinator {
    function requestRandomNumbers(
        uint32 numNumbers,
        uint256 seed
    ) external returns (uint256 requestId);
}

interface IVRFConsumer {
    function rawFulfillRandomNumbers(
        uint256 requestId,
        uint256[] memory randomNumbers
    ) external;
}

contract RockPaperScissors is IVRFConsumer {
    error NotEnoughEtherSent();
    error OnlyCoordinator();

    event ChoiceRequested(uint256 indexed requestId, address indexed player);
    event ChoiceResult(uint256 indexed requestId, uint256 result);

    uint constant COST_PER_GAME = 0.001 ether;

    address payable public sponsor;
    IVRFCoordinator public coordinator;

    mapping(address => uint256) public ticketBalance;
    mapping(uint256 => address) public requestToPlayer;

    modifier onlySponsor() {
        require(msg.sender == sponsor, "not sponsor");
        _;
    }

    modifier onlyCoordinator() {
        if (msg.sender != address(coordinator)) revert OnlyCoordinator();
        _;
    }

    constructor(address _coordinator, address payable _sponsor) {
        coordinator = IVRFCoordinator(_coordinator);
        sponsor = _sponsor;
    }

    function buyTickets(uint _numTickets) external payable {
        if (msg.value < _numTickets * COST_PER_GAME) {
            revert NotEnoughEtherSent();
        }

        (bool sent, ) = sponsor.call{value: msg.value}("");
        require(sent, "failed");

        ticketBalance[msg.sender] += _numTickets;
    }

    function request(address _player) external onlySponsor {
        require(ticketBalance[_player] > 0, "No tickets");

        // Request random number from RISE VRF (no fees!)
        uint256 requestId = coordinator.requestRandomNumbers(
            1,
            uint256(keccak256(abi.encode(_player, block.timestamp, block.number)))
        );

        ticketBalance[_player] -= 1;
        requestToPlayer[requestId] = _player;

        emit ChoiceRequested(requestId, _player);
    }

    function rawFulfillRandomNumbers(
        uint256 requestId,
        uint256[] memory randomNumbers
    ) external override onlyCoordinator {
        require(randomNumbers.length > 0, "No random numbers");
        require(requestToPlayer[requestId] != address(0), "Invalid request");

        uint256 result = randomNumbers[0] % 3; // 0=rock, 1=paper, 2=scissors

        emit ChoiceResult(requestId, result);

        delete requestToPlayer[requestId];
    }

    receive() external payable {}
    fallback() external payable {}
}

Understanding the Contract

Constructor Parameters:

  • _coordinator: RISE VRF Coordinator address (0x9d57aB4517ba97349551C876a01a7580B1338909 on testnet)
  • _sponsor: Backend wallet that pays for VRF requests

Key Functions:

  1. buyTickets(): Players purchase game tickets

    • Forwards ETH to sponsor (who pays for VRF)
    • Increments player's ticket balance
  2. request(): Sponsor initiates VRF request for a player

    • Only callable by sponsor (backend)
    • Requests 1 random number from coordinator
    • Uses unique seed per request
    • Decrements ticket, stores request mapping
    • Emits ChoiceRequested event
  3. rawFulfillRandomNumbers(): VRF callback

    • Only callable by coordinator
    • Receives random number array
    • Calculates AI choice: randomNumbers[0] % 3
    • Emits ChoiceResult event with 0=rock, 1=paper, 2=scissors
    • Cleans up request mapping

Events:

  • ChoiceRequested: Emitted when VRF request is made
  • ChoiceResult: Emitted when VRF delivers random choice

Compile the Contract

forge build

This generates the ABI in out/RockPaperScissors.sol/RockPaperScissors.json.

Deploy to RISE Testnet

Create deployment script script/Deploy.s.sol:

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

import "forge-std/Script.sol";
import "../src/RockPaperScissors.sol";

contract DeployScript is Script {
    function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
        address payable sponsor = payable(vm.envAddress("SPONSOR_ADDRESS"));

        vm.startBroadcast(deployerPrivateKey);

        // RISE Testnet VRF Coordinator
        address coordinator = 0x9d57aB4517ba97349551C876a01a7580B1338909;

        RockPaperScissors rps = new RockPaperScissors(coordinator, sponsor);

        console.log("RockPaperScissors deployed to:", address(rps));

        vm.stopBroadcast();
    }
}

Create .env in foundry-project directory:

PRIVATE_KEY=0x...your_deployer_private_key
SPONSOR_ADDRESS=0x...your_sponsor_wallet_address

Deploy using your private key:

forge script script/Deploy.s.sol:DeployScript \
  --rpc-url https://testnet.riselabs.xyz \
  --private-key $PRIVATE_KEY \
  --broadcast

Alternatively, use a keystore for better security:

forge script script/Deploy.s.sol:DeployScript \
  --rpc-url https://testnet.riselabs.xyz \
  --account <keystore-name> \
  --broadcast

Learn how to create a keystore: Foundry Keystore Guide

Save the deployed contract address!

Extract Contract ABI

Copy the ABI from the compiled output:

cat out/RockPaperScissors.sol/RockPaperScissors.json | jq '.abi' > ../src/constants/abi.json

Or manually copy the ABI array from out/RockPaperScissors.sol/RockPaperScissors.json.

Update Constants

Update src/constants/index.ts with your deployed contract address and ABI:

export const RPS_ADDRESS = "0x5723cA8d0E664D8BFE301aA8b0c7DbBaa43E5806" as const

export const ABI = [
  {
    "type": "constructor",
    "inputs": [
      { "name": "_coordinator", "type": "address", "internalType": "address" },
      { "name": "_sponsor", "type": "address", "internalType": "address payable" }
    ],
    "stateMutability": "nonpayable"
  },
  {
    "type": "function",
    "name": "buyTickets",
    "inputs": [{ "name": "_numTickets", "type": "uint256", "internalType": "uint256" }],
    "outputs": [],
    "stateMutability": "payable"
  },
  {
    "type": "function",
    "name": "request",
    "inputs": [{ "name": "_player", "type": "address", "internalType": "address" }],
    "outputs": [],
    "stateMutability": "nonpayable"
  },
  {
    "type": "function",
    "name": "ticketBalance",
    "inputs": [{ "name": "", "type": "address", "internalType": "address" }],
    "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }],
    "stateMutability": "view"
  },
  {
    "type": "event",
    "name": "ChoiceRequested",
    "inputs": [
      { "name": "requestId", "type": "uint256", "indexed": true, "internalType": "uint256" },
      { "name": "player", "type": "address", "indexed": true, "internalType": "address" }
    ],
    "anonymous": false
  },
  {
    "type": "event",
    "name": "ChoiceResult",
    "inputs": [
      { "name": "requestId", "type": "uint256", "indexed": true, "internalType": "uint256" },
      { "name": "result", "type": "uint256", "indexed": false, "internalType": "uint256" }
    ],
    "anonymous": false
  },
  {
    "type": "error",
    "name": "NotEnoughEtherSent",
    "inputs": []
  },
  {
    "type": "error",
    "name": "OnlyCoordinator",
    "inputs": []
  }
] as const

Replace with your actual ABI from the compilation output.

Key VRF Concepts

Request Seed

The seed ensures each VRF request is unique:

uint256(keccak256(abi.encode(_player, block.timestamp, block.number)))

This combines player address, timestamp, and block number for uniqueness.

Random Number Range

VRF returns a uint256 random number. We mod it to get our choice:

uint256 result = randomNumbers[0] % 3;  // 0, 1, or 2

This maps to rock (0), paper (1), scissors (2).

Next Steps

Your smart contract is deployed and ready! Next, we'll build the backend API that orchestrates VRF requests and watches for events.