Blockchain / Global / Address

Contract

0x881419C59a5fA95ab5381EfABDc8a9d6f9269F6C

Balance
0 SOMI
Transactions sent
1
Type
Contract · 6,248 bytes of code

SpinWheel Verified

Compiler
solc 0.8.30+commit.73712a01
Language
Solidity
Optimization
Yes, 200 runs
EVM version
shanghai
License
—
Verified
2026-10-12

examples/SpinWheel.sol

and 3 more files

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

import {VRFConsumerBaseV2Plus} from "../src/VRFConsumerBaseV2Plus.sol";
import {ISomniaVRFCoordinator} from "../src/interfaces/ISomniaVRFCoordinator.sol";

/// @title SpinWheel
/// @notice Permissionless spin-the-wheel registry backed by Somnia VRF.
///         Anyone creates a wheel (labels + weights, all on-chain). Anyone
///         spins it. The coordinator delivers the random word through
///         `fulfillRandomWords`, the winning segment is picked by weighted
///         draw and stored next to the raw word so the result is replayable.
///
///         One consumer contract serves every wheel, so "deploying a wheel" is
///         a single cheap `createWheel` transaction rather than a bytecode
///         deploy (Somnia charges 3125 gas per deployed byte).
contract SpinWheel is VRFConsumerBaseV2Plus {
    // ─── Bounds ───────────────────────────────────────────────────────────────

    uint8 public constant MIN_SEGMENTS = 2;
    uint8 public constant MAX_SEGMENTS = 24;
    uint16 public constant MAX_WEIGHT = 10_000;
    uint256 public constant MAX_NAME_BYTES = 48;
    uint256 public constant MAX_LABEL_BYTES = 32;

    uint32 public constant CALLBACK_GAS_LIMIT = 800_000;
    uint16 public constant COMMIT_DELAY_BLOCKS = 16;

    // ─── Storage ──────────────────────────────────────────────────────────────

    struct Wheel {
        address creator;
        uint64 createdAt;
        uint32 spins;        // spins requested (landed or pending)
        uint32 totalWeight;
        string name;
        string[] labels;
        uint16[] weights;
    }

    struct Spin {
        uint64 wheelId;
        address spinner;
        bool verifiable;
        bool landed;
        uint8 segment;
        uint256 word;
    }

    Wheel[] internal _wheels;
    mapping(uint256 => Spin) internal _spins;          // requestId → spin
    mapping(uint256 => uint256[]) internal _spinsOf;   // wheelId → requestIds

    // ─── Events ───────────────────────────────────────────────────────────────

    event WheelCreated(
        uint256 indexed wheelId,
        address indexed creator,
        string name,
        string[] labels,
        uint16[] weights
    );
    event SpinRequested(
        uint256 indexed wheelId,
        uint256 indexed requestId,
        address indexed spinner,
        bool verifiable
    );
    event Landed(
        uint256 indexed wheelId,
        uint256 indexed requestId,
        address indexed spinner,
        uint8 segment,
        string label,
        uint256 word
    );

    // ─── Errors ───────────────────────────────────────────────────────────────

    error BadSegmentCount(uint256 provided);
    error LengthMismatch();
    error NameTooLong();
    error LabelTooLong(uint256 index);
    error EmptyLabel(uint256 index);
    error BadWeight(uint256 index);
    error UnknownWheel(uint256 wheelId);
    error UnknownSpin(uint256 requestId);
    error AlreadyLanded(uint256 requestId);

    constructor(address coordinator) VRFConsumerBaseV2Plus(coordinator) {}

    // ─── Create ───────────────────────────────────────────────────────────────

    /// @notice Create a wheel. Labels and weights are stored on-chain so any
    ///         observer can replay a spin from its word.
    function createWheel(string calldata name, string[] calldata labels, uint16[] calldata weights)
        external
        returns (uint256 wheelId)
    {
        uint256 n = labels.length;
        if (n < MIN_SEGMENTS || n > MAX_SEGMENTS) revert BadSegmentCount(n);
        if (weights.length != n) revert LengthMismatch();
        if (bytes(name).length > MAX_NAME_BYTES) revert NameTooLong();

        uint32 total;
        for (uint256 i = 0; i < n; i++) {
            uint256 lb = bytes(labels[i]).length;
            if (lb == 0) revert EmptyLabel(i);
            if (lb > MAX_LABEL_BYTES) revert LabelTooLong(i);
            if (weights[i] == 0 || weights[i] > MAX_WEIGHT) revert BadWeight(i);
            total += weights[i];
        }

        wheelId = _wheels.length;
        Wheel storage w = _wheels.push();
        w.creator = msg.sender;
        w.createdAt = uint64(block.timestamp);
        w.totalWeight = total;
        w.name = name;
        for (uint256 i = 0; i < n; i++) {
            w.labels.push(labels[i]);
            w.weights.push(weights[i]);
        }

        emit WheelCreated(wheelId, msg.sender, name, labels, weights);
    }

    // ─── Spin ─────────────────────────────────────────────────────────────────

    /// @notice Request a spin. The consumer pays only tx gas; the coordinator
    ///         pays for entropy delivery. On mainnet the coordinator rejects
    ///         `useVerifiableEntropy = false`.
    function spin(uint256 wheelId, bool useVerifiableEntropy) external returns (uint256 requestId) {
        if (wheelId >= _wheels.length) revert UnknownWheel(wheelId);

        requestId = COORDINATOR.requestRandomWords(
            ISomniaVRFCoordinator.RandomWordsRequest({
                callbackGasLimit: CALLBACK_GAS_LIMIT,
                commitDelayBlocks: COMMIT_DELAY_BLOCKS,
                numWords: 1,
                useVerifiableEntropy: useVerifiableEntropy
            })
        );

        _spins[requestId] = Spin({
            wheelId: uint64(wheelId),
            spinner: msg.sender,
            verifiable: useVerifiableEntropy,
            landed: false,
            segment: 0,
            word: 0
        });
        _spinsOf[wheelId].push(requestId);
        _wheels[wheelId].spins += 1;

        emit SpinRequested(wheelId, requestId, msg.sender, useVerifiableEntropy);
    }

    function fulfillRandomWords(uint256 requestId, uint256[] memory words) internal override {
        Spin storage s = _spins[requestId];
        if (s.spinner == address(0)) revert UnknownSpin(requestId);
        if (s.landed) revert AlreadyLanded(requestId);

        Wheel storage w = _wheels[s.wheelId];
        uint8 seg = pickSegment(words[0], w.weights, w.totalWeight);

        s.landed = true;
        s.segment = seg;
        s.word = words[0];

        emit Landed(s.wheelId, requestId, s.spinner, seg, w.labels[seg], words[0]);
    }

    /// @notice Weighted draw. Pure so anyone can replay a result off-chain.
    function pickSegment(uint256 word, uint16[] memory weights, uint32 totalWeight)
        public
        pure
        returns (uint8)
    {
        uint256 r = word % totalWeight;
        uint256 acc;
        for (uint256 i = 0; i < weights.length; i++) {
            acc += weights[i];
            if (r < acc) return uint8(i);
        }
        return uint8(weights.length - 1); // unreachable when weights sum to totalWeight
    }

    // ─── Views ────────────────────────────────────────────────────────────────

    function wheelCount() external view returns (uint256) {
        return _wheels.length;
    }

    function getWheel(uint256 wheelId)
        external
        view
        returns (
            address creator,
            uint64 createdAt,
            uint32 spins,
            uint32 totalWeight,
            string memory name,
            string[] memory labels,
            uint16[] memory weights
        )
    {
        if (wheelId >= _wheels.length) revert UnknownWheel(wheelId);
        Wheel storage w = _wheels[wheelId];
        return (w.creator, w.createdAt, w.spins, w.totalWeight, w.name, w.labels, w.weights);
    }

    function getSpin(uint256 requestId) external view returns (Spin memory) {
        return _spins[requestId];
    }

    function spinsOf(uint256 wheelId) external view returns (uint256[] memory) {
        return _spinsOf[wheelId];
    }
}

Other files

  • src/VRFConsumerBaseV2Plus.sol
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.30;
    
    import {ISomniaVRFCoordinator} from "./interfaces/ISomniaVRFCoordinator.sol";
    import {IVRFConsumer} from "./interfaces/IVRFConsumer.sol";
    
    /// @title VRFConsumerBaseV2Plus
    /// @notice Base contract for VRF consumers on Somnia. Mirrors the shape of
    ///         Chainlink's `VRFConsumerBaseV2Plus` so existing consumer code can
    ///         migrate by changing imports.
    ///
    /// @dev    Consumers implement `fulfillRandomWords`. The coordinator calls
    ///         `rawFulfillRandomWords`, which access-checks and forwards.
    abstract contract VRFConsumerBaseV2Plus is IVRFConsumer {
        error OnlyCoordinatorCanFulfill(address expected, address actual);
    
        ISomniaVRFCoordinator public immutable COORDINATOR;
    
        constructor(address coordinator) {
            COORDINATOR = ISomniaVRFCoordinator(coordinator);
        }
    
        /// @notice Called by the coordinator when random words are ready.
        function fulfillRandomWords(uint256 requestId, uint256[] memory randomWords) internal virtual;
    
        /// @inheritdoc IVRFConsumer
        function rawFulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) external override {
            if (msg.sender != address(COORDINATOR)) {
                revert OnlyCoordinatorCanFulfill(address(COORDINATOR), msg.sender);
            }
            fulfillRandomWords(requestId, randomWords);
        }
    }
    
  • src/interfaces/ISomniaVRFCoordinator.sol
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.30;
    
    /// @title ISomniaVRFCoordinator
    /// @notice Reactivity-native VRF coordinator for Somnia. Consumers request
    ///         random words; the coordinator delivers them via a callback after
    ///         a commit delay.
    ///
    ///         Two entropy modes:
    ///
    ///         Mode A — fast (`useVerifiableEntropy = false`)
    ///           Entropy from `blockhash(revealBlock - 1)`. Cheap and low-latency,
    ///           but the block producer for the reveal block can influence it.
    ///           Testnet-grade only.
    ///
    ///         Mode B — verifiable (`useVerifiableEntropy = true`)
    ///           Entropy mixes drand (League of Entropy publicly-verifiable
    ///           beacon) with the blockhash. The coordinator fires a Somnia
    ///           Agents JSON API Request at reveal time; the resulting seed is
    ///           `keccak256(drand, blockhash, requestId, consumer, revealBlock)`.
    ///           Compromising either drand or the reveal-block producer alone
    ///           does not compromise the result.
    interface ISomniaVRFCoordinator {
        struct RandomWordsRequest {
            uint32 callbackGasLimit;
            uint16 commitDelayBlocks;
            uint32 numWords;
            bool useVerifiableEntropy;
        }
    
        struct Request {
            address consumer;
            uint32 callbackGasLimit;
            uint32 numWords;
            uint64 revealBlock;
            bool useVerifiableEntropy;
            bool fulfilled;
            bytes32 capturedBlockhash;
        }
    
        event RandomWordsRequested(
            uint256 indexed requestId,
            address indexed consumer,
            uint64 revealBlock,
            uint32 numWords,
            uint32 callbackGasLimit,
            bool useVerifiableEntropy
        );
    
        event BlockhashCaptured(
            uint256 indexed requestId,
            uint64 revealBlock,
            bytes32 blockhashValue
        );
    
        event DrandRequested(
            uint256 indexed requestId,
            uint256 indexed agentRequestId
        );
    
        event RandomWordsFulfilled(
            uint256 indexed requestId,
            address indexed consumer,
            uint64 revealBlock,
            bool callbackSuccess,
            bool usedVerifiableEntropy
        );
    
        error CommitDelayTooShort(uint16 provided, uint16 minimum);
        error CommitDelayTooLong(uint16 provided, uint16 maximum);
        error CallbackGasTooHigh(uint32 provided, uint32 maximum);
        error NumWordsOutOfRange(uint32 provided, uint32 minimum, uint32 maximum);
        error UnknownRequest(uint256 requestId);
        error UnknownAgentRequest(uint256 agentRequestId);
        error NotPrecompile(address caller);
        error NotAgentPlatform(address caller);
        error DrandAlreadyResolved(uint256 requestId);
        error DrandResponseFailed(uint8 status);
    
        /// @notice Request `numWords` uniformly random uint256 values, delivered via
        ///         the caller's `fulfillRandomWords(requestId, uint256[] words)` callback
        ///         after `commitDelayBlocks` blocks (Mode A) or after the drand round
        ///         following that block resolves (Mode B).
        function requestRandomWords(RandomWordsRequest calldata req)
            external
            returns (uint256 requestId);
    
        /// @notice Read a pending or historical request.
        function getRequest(uint256 requestId) external view returns (Request memory);
    }
    
  • src/interfaces/IVRFConsumer.sol
    // SPDX-License-Identifier: MIT
    pragma solidity ^0.8.30;
    
    /// @title IVRFConsumer
    /// @notice Callback interface a VRF consumer must implement.
    ///         Signature mirrors Chainlink's VRFConsumerBaseV2Plus for portability.
    interface IVRFConsumer {
        function rawFulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) external;
    }
    
ABI · 30 entries
json
[
  {
    "type": "constructor",
    "inputs": [
      {
        "name": "coordinator",
        "type": "address",
        "internalType": "address"
      }
    ],
    "stateMutability": "nonpayable"
  },
  {
    "name": "AlreadyLanded",
    "type": "error",
    "inputs": [
      {
        "name": "requestId",
        "type": "uint256",
        "internalType": "uint256"
      }
    ]
  },
  {
    "name": "BadSegmentCount",
    "type": "error",
    "inputs": [
      {
        "name": "provided",
        "type": "uint256",
        "internalType": "uint256"
      }
    ]
  },
  {
    "name": "BadWeight",
    "type": "error",
    "inputs": [
      {
        "name": "index",
        "type": "uint256",
        "internalType": "uint256"
      }
    ]
  },
  {
    "name": "EmptyLabel",
    "type": "error",
    "inputs": [
      {
        "name": "index",
        "type": "uint256",
        "internalType": "uint256"
      }
    ]
  },
  {
    "name": "LabelTooLong",
    "type": "error",
    "inputs": [
      {
        "name": "index",
        "type": "uint256",
        "internalType": "uint256"
      }
    ]
  },
  {
    "name": "LengthMismatch",
    "type": "error",
    "inputs": []
  },
  {
    "name": "NameTooLong",
    "type": "error",
    "inputs": []
  },
  {
    "name": "OnlyCoordinatorCanFulfill",
    "type": "error",
    "inputs": [
      {
        "name": "expected",
        "type": "address",
        "internalType": "address"
      },
      {
        "name": "actual",
        "type": "address",
        "internalType": "address"
      }
    ]
  },
  {
    "name": "UnknownSpin",
    "type": "error",
    "inputs": [
      {
        "name": "requestId",
        "type": "uint256",
        "internalType": "uint256"
      }
    ]
  },
  {
    "name": "UnknownWheel",
    "type": "error",
    "inputs": [
      {
        "name": "wheelId",
        "type": "uint256",
        "internalType": "uint256"
      }
    ]
  },
  {
    "name": "Landed",
    "type": "event",
    "inputs": [
      {
        "name": "wheelId",
        "type": "uint256",
        "indexed": true,
        "internalType": "uint256"
      },
      {
        "name": "requestId",
        "type": "uint256",
        "indexed": true,
        "internalType": "uint256"
      },
      {
        "name": "spinner",
        "type": "address",
        "indexed": true,
        "internalType": "address"
      },
      {
        "name": "segment",
        "type": "uint8",
        "indexed": false,
        "internalType": "uint8"
      },
      {
        "name": "label",
        "type": "string",
        "indexed": false,
        "internalType": "string"
      },
      {
        "name": "word",
        "type": "uint256",
        "indexed": false,
        "internalType": "uint256"
      }
    ],
    "anonymous": false
  },
  {
    "name": "SpinRequested",
    "type": "event",
    "inputs": [
      {
        "name": "wheelId",
        "type": "uint256",
        "indexed": true,
        "internalType": "uint256"
      },
      {
        "name": "requestId",
        "type": "uint256",
        "indexed": true,
        "internalType": "uint256"
      },
      {
        "name": "spinner",
        "type": "address",
        "indexed": true,
        "internalType": "address"
      },
      {
        "name": "verifiable",
        "type": "bool",
        "indexed": false,
        "internalType": "bool"
      }
    ],
    "anonymous": false
  },
  {
    "name": "WheelCreated",
    "type": "event",
    "inputs": [
      {
        "name": "wheelId",
        "type": "uint256",
        "indexed": true,
        "internalType": "uint256"
      },
      {
        "name": "creator",
        "type": "address",
        "indexed": true,
        "internalType": "address"
      },
      {
        "name": "name",
        "type": "string",
        "indexed": false,
        "internalType": "string"
      },
      {
        "name": "labels",
        "type": "string[]",
        "indexed": false,
        "internalType": "string[]"
      },
      {
        "name": "weights",
        "type": "uint16[]",
        "indexed": false,
        "internalType": "uint16[]"
      }
    ],
    "anonymous": false
  },
  {
    "name": "CALLBACK_GAS_LIMIT",
    "type": "function",
    "inputs": [],
    "outputs": [
      {
        "name": "",
        "type": "uint32",
        "internalType": "uint32"
      }
    ],
    "stateMutability": "view"
  },
  {
    "name": "COMMIT_DELAY_BLOCKS",
    "type": "function",
    "inputs": [],
    "outputs": [
      {
        "name": "",
        "type": "uint16",
        "internalType": "uint16"
      }
    ],
    "stateMutability": "view"
  },
  {
    "name": "COORDINATOR",
    "type": "function",
    "inputs": [],
    "outputs": [
      {
        "name": "",
        "type": "address",
        "internalType": "contract ISomniaVRFCoordinator"
      }
    ],
    "stateMutability": "view"
  },
  {
    "name": "MAX_LABEL_BYTES",
    "type": "function",
    "inputs": [],
    "outputs": [
      {
        "name": "",
        "type": "uint256",
        "internalType": "uint256"
      }
    ],
    "stateMutability": "view"
  },
  {
    "name": "MAX_NAME_BYTES",
    "type": "function",
    "inputs": [],
    "outputs": [
      {
        "name": "",
        "type": "uint256",
        "internalType": "uint256"
      }
    ],
    "stateMutability": "view"
  },
  {
    "name": "MAX_SEGMENTS",
    "type": "function",
    "inputs": [],
    "outputs": [
      {
        "name": "",
        "type": "uint8",
        "internalType": "uint8"
      }
    ],
    "stateMutability": "view"
  },
  {
    "name": "MAX_WEIGHT",
    "type": "function",
    "inputs": [],
    "outputs": [
      {
        "name": "",
        "type": "uint16",
        "internalType": "uint16"
      }
    ],
    "stateMutability": "view"
  },
  {
    "name": "MIN_SEGMENTS",
    "type": "function",
    "inputs": [],
    "outputs": [
      {
        "name": "",
        "type": "uint8",
        "internalType": "uint8"
      }
    ],
    "stateMutability": "view"
  },
  {
    "name": "createWheel",
    "type": "function",
    "inputs": [
      {
        "name": "name",
        "type": "string",
        "internalType": "string"
      },
      {
        "name": "labels",
        "type": "string[]",
        "internalType": "string[]"
      },
      {
        "name": "weights",
        "type": "uint16[]",
        "internalType": "uint16[]"
      }
    ],
    "outputs": [
      {
        "name": "wheelId",
        "type": "uint256",
        "internalType": "uint256"
      }
    ],
    "stateMutability": "nonpayable"
  },
  {
    "name": "getSpin",
    "type": "function",
    "inputs": [
      {
        "name": "requestId",
        "type": "uint256",
        "internalType": "uint256"
      }
    ],
    "outputs": [
      {
        "name": "",
        "type": "tuple",
        "components": [
          {
            "name": "wheelId",
            "type": "uint64",
            "internalType": "uint64"
          },
          {
            "name": "spinner",
            "type": "address",
            "internalType": "address"
          },
          {
            "name": "verifiable",
            "type": "bool",
            "internalType": "bool"
          },
          {
            "name": "landed",
            "type": "bool",
            "internalType": "bool"
          },
          {
            "name": "segment",
            "type": "uint8",
            "internalType": "uint8"
          },
          {
            "name": "word",
            "type": "uint256",
            "internalType": "uint256"
          }
        ],
        "internalType": "struct SpinWheel.Spin"
      }
    ],
    "stateMutability": "view"
  },
  {
    "name": "getWheel",
    "type": "function",
    "inputs": [
      {
        "name": "wheelId",
        "type": "uint256",
        "internalType": "uint256"
      }
    ],
    "outputs": [
      {
        "name": "creator",
        "type": "address",
        "internalType": "address"
      },
      {
        "name": "createdAt",
        "type": "uint64",
        "internalType": "uint64"
      },
      {
        "name": "spins",
        "type": "uint32",
        "internalType": "uint32"
      },
      {
        "name": "totalWeight",
        "type": "uint32",
        "internalType": "uint32"
      },
      {
        "name": "name",
        "type": "string",
        "internalType": "string"
      },
      {
        "name": "labels",
        "type": "string[]",
        "internalType": "string[]"
      },
      {
        "name": "weights",
        "type": "uint16[]",
        "internalType": "uint16[]"
      }
    ],
    "stateMutability": "view"
  },
  {
    "name": "pickSegment",
    "type": "function",
    "inputs": [
      {
        "name": "word",
        "type": "uint256",
        "internalType": "uint256"
      },
      {
        "name": "weights",
        "type": "uint16[]",
        "internalType": "uint16[]"
      },
      {
        "name": "totalWeight",
        "type": "uint32",
        "internalType": "uint32"
      }
    ],
    "outputs": [
      {
        "name": "",
        "type": "uint8",
        "internalType": "uint8"
      }
    ],
    "stateMutability": "pure"
  },
  {
    "name": "rawFulfillRandomWords",
    "type": "function",
    "inputs": [
      {
        "name": "requestId",
        "type": "uint256",
        "internalType": "uint256"
      },
      {
        "name": "randomWords",
        "type": "uint256[]",
        "internalType": "uint256[]"
      }
    ],
    "outputs": [],
    "stateMutability": "nonpayable"
  },
  {
    "name": "spin",
    "type": "function",
    "inputs": [
      {
        "name": "wheelId",
        "type": "uint256",
        "internalType": "uint256"
      },
      {
        "name": "useVerifiableEntropy",
        "type": "bool",
        "internalType": "bool"
      }
    ],
    "outputs": [
      {
        "name": "requestId",
        "type": "uint256",
        "internalType": "uint256"
      }
    ],
    "stateMutability": "nonpayable"
  },
  {
    "name": "spinsOf",
    "type": "function",
    "inputs": [
      {
        "name": "wheelId",
        "type": "uint256",
        "internalType": "uint256"
      }
    ],
    "outputs": [
      {
        "name": "",
        "type": "uint256[]",
        "internalType": "uint256[]"
      }
    ],
    "stateMutability": "view"
  },
  {
    "name": "wheelCount",
    "type": "function",
    "inputs": [],
    "outputs": [
      {
        "name": "",
        "type": "uint256",
        "internalType": "uint256"
      }
    ],
    "stateMutability": "view"
  }
]

Send feedback

Agents: POST the same to /api/feedback.