RISE Logo-Light

Smart Contracts

Build the RCT game token and VRF-powered slot machine contracts

Contract Architecture

We'll build two contracts:

  1. RCT (RISE Casino Token): ERC20 token that powers the game economy

    • Pre-mints 10M tokens to the slot machine (house bankroll)
    • One-time 1000 token claim for new players
    • Faucet for broke players (only works at 0 balance)
    • Auto-approves SlotMachine for seamless UX
  2. SlotMachine: VRF-powered gaming contract

    • Accepts 10 RCT per spin
    • Requests 3 random numbers from RISE VRF
    • Maps numbers to symbols with weighted probabilities
    • Distributes tiered payouts for matching symbols
    • Emits events for real-time UI updates

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 (3-5ms!)
  4. Process the random numbers and emit events

Writing the Contracts

Create RCT Token Contract

Navigate to your Foundry project:

cd foundry-app

Create src/RCT.sol:

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

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract RCT is ERC20 {
    mapping(address => bool) public hasMinted;
    address public immutable slotMachine;

    constructor(address _slotMachine) ERC20("RISE Casino Token", "RCT") {
        slotMachine = _slotMachine;
        // Mint 10,000,000 RCT to SlotMachine contract as house bankroll
        _mint(_slotMachine, 10_000_000 * 10**decimals());
    }

    function mintInitial() external {
        require(!hasMinted[msg.sender], "Already minted initial tokens");
        hasMinted[msg.sender] = true;
        _mint(msg.sender, 1000 * 10**decimals());

        // Auto-approve SlotMachine to spend user's RCT tokens
        _approve(msg.sender, slotMachine, type(uint256).max);
    }

    function faucet() external {
        require(balanceOf(msg.sender) == 0, "Balance must be 0 to use faucet");
        _mint(msg.sender, 1000 * 10**decimals());
    }
}

Key Features:

  • constructor: Mints 10M RCT to the SlotMachine address (house bankroll)
  • mintInitial(): One-time claim of 1000 RCT + auto-approves SlotMachine for unlimited spending
  • faucet(): Refills broke players with 1000 RCT (only works at 0 balance)
  • hasMinted mapping: Tracks which users have claimed their initial tokens

The auto-approve in mintInitial() is crucial for UX - users never need to approve spending!

Create SlotMachine Contract

Create src/SlotMachine.sol:

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

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

// 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 SlotMachine is IVRFConsumer {
    using SafeERC20 for IERC20;

    error OnlyCoordinator();
    error InsufficientTokenBalance();

    event SpinRequested(uint256 indexed requestId, address indexed player);
    event SpinResult(uint256 indexed requestId, uint8[3] result, uint256 payout);

    uint256 constant COST_PER_SPIN = 10 * 10**18; // 10 RCT

    // Payout structure based on symbol rarity
    uint256 constant CHERRY_PAYOUT = 15 * 10**18;  // 15 RCT (0-3)
    uint256 constant LEMON_PAYOUT = 30 * 10**18;   // 30 RCT (4-6)
    uint256 constant DIAMOND_PAYOUT = 100 * 10**18; // 100 RCT (7-8)
    uint256 constant LUCKY9_PAYOUT = 500 * 10**18;  // 500 RCT (9)

    IVRFCoordinator public coordinator;
    IERC20 public rctToken;
    address public owner;
    mapping(uint256 => address) public requestToPlayer;

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

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    constructor(address _coordinator, address _rctToken) {
        coordinator = IVRFCoordinator(_coordinator);
        rctToken = IERC20(_rctToken);
        owner = msg.sender;
    }

    function setRCTToken(address _rctToken) external onlyOwner {
        rctToken = IERC20(_rctToken);
    }

    // Helper function to map number to symbol ID
    function getSymbolId(uint8 num) internal pure returns (uint8) {
        if (num <= 3) return 0;  // Cherry
        if (num <= 6) return 1;  // Lemon
        if (num <= 8) return 2;  // Diamond
        return 3;                 // Lucky 9
    }

    function spin() external returns (uint256) {
        // Check player has enough RCT tokens
        if (rctToken.balanceOf(msg.sender) < COST_PER_SPIN) {
            revert InsufficientTokenBalance();
        }

        // Transfer 10 RCT from player to contract
        rctToken.safeTransferFrom(msg.sender, address(this), COST_PER_SPIN);

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

        requestToPlayer[requestId] = msg.sender;
        emit SpinRequested(requestId, msg.sender);

        return requestId;
    }

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

        address player = requestToPlayer[requestId];

        // Map to 0-9 range (10 possible values)
        uint8[3] memory result = [
            uint8(randomNumbers[0] % 10),
            uint8(randomNumbers[1] % 10),
            uint8(randomNumbers[2] % 10)
        ];

        // Map to symbol IDs
        uint8[3] memory symbolIds = [
            getSymbolId(result[0]),
            getSymbolId(result[1]),
            getSymbolId(result[2])
        ];

        // Check if all three SYMBOLS match
        bool isWin = symbolIds[0] == symbolIds[1] && symbolIds[1] == symbolIds[2];
        uint256 payout = 0;

        if (isWin) {
            // Determine payout based on which symbol won
            if (symbolIds[0] == 0) {
                payout = CHERRY_PAYOUT;
            } else if (symbolIds[0] == 1) {
                payout = LEMON_PAYOUT;
            } else if (symbolIds[0] == 2) {
                payout = DIAMOND_PAYOUT;
            } else {
                payout = LUCKY9_PAYOUT;
            }

            rctToken.safeTransfer(player, payout);
        }

        emit SpinResult(requestId, result, payout);
        delete requestToPlayer[requestId];
    }
}

Understanding the SlotMachine Contract

Symbol Mapping System:

The contract uses a two-step mapping system:

  1. VRF returns numbers → modulo 10 → gives 0-9
  2. Numbers map to symbol IDs:
    • 0-3 → Symbol ID 0 (Cherry) = 40% probability
    • 4-6 → Symbol ID 1 (Lemon) = 30% probability
    • 7-8 → Symbol ID 2 (Diamond) = 20% probability
    • 9 → Symbol ID 3 (Lucky 9) = 10% probability

Why Symbol IDs?

Without symbol IDs, [0, 1, 2] would be a loss because the numbers don't match. But all three are cherries! The getSymbolId() function maps them all to 0, making it a valid win.

Key Functions:

  1. spin(): Player initiates a spin

    • Validates player has 10 RCT balance
    • Transfers 10 RCT to contract
    • Requests 3 random numbers from VRF coordinator
    • Uses unique seed: keccak256(abi.encode(msg.sender, block.timestamp, block.number))
    • Stores request mapping
    • Emits SpinRequested event
    • Returns requestId for tracking
  2. rawFulfillRandomNumbers(): VRF callback (called by coordinator only)

    • Receives array of 3 random numbers
    • Maps each to 0-9 range via modulo
    • Converts numbers to symbol IDs
    • Checks if all 3 symbol IDs match
    • Calculates payout based on winning symbol
    • Transfers payout to player if win
    • Emits SpinResult event with result array and payout
    • Cleans up request mapping

Events:

  • SpinRequested(requestId, player): Emitted when spin is initiated
  • SpinResult(requestId, result, payout): Emitted when VRF delivers result

Compile the Contracts

forge build

This generates ABIs in:

  • out/RCT.sol/RCT.json
  • out/SlotMachine.sol/SlotMachine.json

Deploy to RISE Testnet

Create deployment script script/Deploy.s.sol:

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

import "forge-std/Script.sol";
import "../src/RCT.sol";
import "../src/SlotMachine.sol";

contract DeployScript is Script {
    function run() external {
        uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");

        vm.startBroadcast(deployerPrivateKey);

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

        // Deploy SlotMachine first to get its address
        SlotMachine slotMachine = new SlotMachine(coordinator, address(0));

        // Deploy RCT with SlotMachine address
        RCT rct = new RCT(address(slotMachine));

        // Set RCT token address in SlotMachine
        slotMachine.setRCTToken(address(rct));

        console.log("RCT deployed at:", address(rct));
        console.log("SlotMachine deployed at:", address(slotMachine));

        vm.stopBroadcast();
    }
}

Deploy using your private key:

forge script script/Deploy.s.sol:DeployScript \
  --rpc-url https://testnet.riselabs.xyz \
  --private-key 0xYOUR_PRIVATE_KEY_HERE \
  --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 both deployed contract addresses!

Extract Contract ABIs

Copy the ABIs to your frontend:

# From foundry-app directory
cat out/SlotMachine.sol/SlotMachine.json | jq '.abi' > ../src/constants/slotMachineAbi.json
cat out/RCT.sol/RCT.json | jq '.abi' > ../src/constants/rctAbi.json

Update Constants

Update src/constants/index.ts with your deployed addresses:

import SLOT_MACHINE_ABI from './slotMachineAbi.json'
import RCT_ABI from './rctAbi.json'

export const SLOT_MACHINE_ADDRESS = "0x6d30dE27786Fd46F468Ea16C34243386d2b11153" as const
export const RCT_TOKEN_ADDRESS = "0xA42a61FB25323923999e747c73dDCCb2C3547B0B" as const

export { SLOT_MACHINE_ABI, RCT_ABI }

Replace with your actual deployed addresses.

Key Concepts

Request Seed

The seed ensures each VRF request is unique:

uint256(keccak256(abi.encode(msg.sender, block.timestamp, block.number)))

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

Symbol vs Number Matching

Wrong approach (matches numbers):

bool isWin = result[0] == result[1] && result[1] == result[2];

This would fail for [0, 1, 2] even though they're all cherries!

Correct approach (matches symbols):

uint8[3] memory symbolIds = [getSymbolId(result[0]), getSymbolId(result[1]), getSymbolId(result[2])];
bool isWin = symbolIds[0] == symbolIds[1] && symbolIds[1] == symbolIds[2];

This correctly identifies [0, 1, 2] as triple cherries.

Payout Calculations

if (symbolIds[0] == 0) payout = CHERRY_PAYOUT;  // 15 RCT
else if (symbolIds[0] == 1) payout = LEMON_PAYOUT;  // 30 RCT
else if (symbolIds[0] == 2) payout = DIAMOND_PAYOUT;  // 100 RCT
else payout = LUCKY9_PAYOUT;  // 500 RCT

Expected value for 10 RCT spin:

  • Cherry win (0.4 × 0.4 × 0.4 = 6.4%): 15 RCT → 0.96 RCT expected
  • Lemon win (0.3 × 0.3 × 0.3 = 2.7%): 30 RCT → 0.81 RCT expected
  • Diamond win (0.2 × 0.2 × 0.2 = 0.8%): 100 RCT → 0.80 RCT expected
  • Lucky 9 win (0.1 × 0.1 × 0.1 = 0.1%): 500 RCT → 0.50 RCT expected

Total expected return: ~31% (69% house edge)

Auto-Approve Pattern

_approve(msg.sender, slotMachine, type(uint256).max);

When users call mintInitial(), they automatically approve the SlotMachine for unlimited RCT spending. This means:

  • No separate approval transaction needed
  • Users can spin immediately after claiming
  • Seamless UX with no extra popups

Next Steps

Your smart contracts are deployed! Next, we'll implement session keys to enable completely gasless, popup-free gameplay.