Smart Contracts
Build the RCT game token and VRF-powered slot machine contracts
Contract Architecture
We'll build two contracts:
-
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
-
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:
- Your contract implements
IVRFConsumerinterface - Call
coordinator.requestRandomNumbers()to request randomness - Coordinator calls back your
rawFulfillRandomNumbers()with results (3-5ms!) - Process the random numbers and emit events
Writing the Contracts
Create RCT Token Contract
Navigate to your Foundry project:
cd foundry-appCreate 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 spendingfaucet(): Refills broke players with 1000 RCT (only works at 0 balance)hasMintedmapping: 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:
- VRF returns numbers → modulo 10 → gives 0-9
- 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:
-
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
SpinRequestedevent - Returns
requestIdfor tracking
-
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
SpinResultevent with result array and payout - Cleans up request mapping
Events:
SpinRequested(requestId, player): Emitted when spin is initiatedSpinResult(requestId, result, payout): Emitted when VRF delivers result
Compile the Contracts
forge buildThis generates ABIs in:
out/RCT.sol/RCT.jsonout/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 \
--broadcastAlternatively, use a keystore for better security:
forge script script/Deploy.s.sol:DeployScript \
--rpc-url https://testnet.riselabs.xyz \
--account <keystore-name> \
--broadcastLearn 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.jsonUpdate 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 RCTExpected 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.