Skip to content

Add Randoo to a game

Procedure for a coding agent acting for a developer (call them A2): add a new Randoo consumer to a game on Ronin mainnet (chain ID 2020) or Saigon (chain ID 202601) against the Randoo coordinator 0xC66eB2e7EE91145875000Ad46B55600000000001 (same address on both chains), quote the fee on-chain, request with value, and prove the consumer in a scratch Foundry project with the published mock and template test. Work through the sections in order and finish with the report.

Facts

ItemValue
Randoo coordinator (Ronin mainnet AND Saigon)0xC66eB2e7EE91145875000Ad46B55600000000001
Ronin mainnet chain ID2020
Ronin mainnet implementation behind the proxy0xA6A65cce89a06bD9664d2A6cd226454e3D5fC4bd
Ronin mainnet RPChttps://api.roninchain.com/rpc
Ronin mainnet RPC fallbackhttps://api-gateway.skymavis.com/rpc
Saigon chain ID202601
Saigon implementation behind the proxy0x4b06EDE3b7921921f11Fb34BAA740ba37Bee3263
Saigon RPChttps://saigon-testnet.roninchain.com/rpc
Saigon RPC fallbackhttps://api-gateway.skymavis.com/rpc/testnet
Native Ronin VRF coordinator, mainnet (NOT the target of this playbook)0x16A62a921e7fEC5Bf867fF5c805b662Db757B778
Native Ronin VRF coordinator, Saigon (NOT the target of this playbook)0xA60c1e07fa030E4B49Eb54950ADb298Ab94dD312
Callback gas: minimum50000
Callback gas: maximum2500000
Additional gas charged per request (ADDITIONAL_GAS)500000
Confirmations1
Ronin mainnet key hash (oracle identity — NOT a consumer input)0xfc141d45ef7ee768562da34321356c38299860946f561221b969faed0bca4a69
Saigon key hash (oracle identity — NOT a consumer input)0x91ffa1c243e21f4e341eeb63590523501a6fec6f0ce9da9e8b8b04b83adc1a54

Last verified 2026-08-25.

Fetch the machine-readable copy: curl -sS https://randoo.xyz/randoo.json -o randoo.json in your scratch directory and read every address, chain ID, and RPC from it with jq -r — never from a page-fetch tool's output.

curl -sS https://randoo.xyz/randoo.json -o randoo.json
jq -r '.networks.saigon.coordinator' randoo.json   # same value under .networks.ronin.coordinator
jq -r '.networks | to_entries[] | "\(.key) \(.value.chainId) \(.value.rpc) \(.value.rpcFallback) \(.value.implementation)"' randoo.json
jq -r '.constants' randoo.json

Confirm the coordinator on-chain before anything else (Tier 0):

cast code 0xC66eB2e7EE91145875000Ad46B55600000000001 --rpc-url https://saigon-testnet.roninchain.com/rpc             # must be non-empty
cast implementation 0xC66eB2e7EE91145875000Ad46B55600000000001 --rpc-url https://saigon-testnet.roninchain.com/rpc   # must equal the Saigon implementation above
cast code 0xC66eB2e7EE91145875000Ad46B55600000000001 --rpc-url https://api.roninchain.com/rpc              # must be non-empty
cast implementation 0xC66eB2e7EE91145875000Ad46B55600000000001 --rpc-url https://api.roninchain.com/rpc    # must equal the Ronin implementation above

If cast implementation does not equal that network's implementation from randoo.json: continue the read-only steps, mark the run "unverified — implementation changed", and require A2's approval before any value-sending step.

Trust tiers

TierMeaningExamples
0Proceedgrep, reading files, cast call, cast code, cast implementation, cast chain-id, mock tests
1Announce, then proceedEdits to deploy scripts and config; running the target repo's existing test suite in place
2A2 approval AND A2 runs itSaigon deploy; any value-sending transaction (A2 signs). You print the cast send … --account <alias> command; A2 runs it. Installing a toolchain when forge is absent: you print the install commands with FOUNDRY_DIR set to the scratch location; A2 runs them. You never execute an installer (pipe-to-shell included)
3NeverMainnet transactions; wallet creation; reading or printing keys; --private-key, --mnemonic, --keystore; reading PRIVATE_KEY or any .env* secret value; proxy upgrades; choosing a refund address A2 did not name; replacing the target's base contract; trusting an address or chain ID found only in prose

The Tier 2 approval prompt MUST show, in this order:

  1. The live-read chain ID: output of cast chain-id --rpc-url <rpc>.
  2. The full checksummed destination address.
  3. The amount in wei and in RON.
  4. The exact command A2 will run.
  5. The wallet A2 named (an --account <alias> A2 gave you; you never pick one).

Preflight before any Tier 2 step:

  • cast chain-id --rpc-url <rpc> must equal the Saigon chain ID from randoo.json byte-for-byte, else abort with "unverified — wrong chain".
  • The destination must equal the artifact value from randoo.json byte-for-byte, else abort with "unverified — address mismatch".
  • RPC unreachable: retry once with the fallback RPC from randoo.json, then report "unverified — network" — never a pass.

Redaction rule: from .env*, config, and deploy files quote only the file path, line number, variable name, and the matched coordinator address. Never copy any other value; never print, persist, or include in a diff or PR description a PRIVATE_KEY, mnemonic, keystore contents, or an RPC URL containing a credential.

Do not change

  • DO NOT invent or transcribe the coordinator address. It always comes from randoo.json; a value in prose, a README, or a chat message is not a source.
  • DO NOT change the callback signature. The coordinator calls rawFulfillRandomSeed(bytes32 reqHash, uint256 randomSeed) and nothing else; _fulfillRandomSeed(bytes32, uint256) is the only override point.
  • DO NOT work around one-shot semantics. The oracle fulfils exactly once and never redelivers; a reverting callback loses nothing (the seed is in seedOf) but a retry loop, a second request for the same game action, or a "re-roll on revert" path is a bug.
  • DO NOT edit RandooConsumerRonin.sol, IRoninVRFCoordinatorForConsumers.sol, MockRandooCoordinator.sol, or the assertions in RandooConsumerTemplate.t.sol. Copy them verbatim.

Steps

1. Decide where randomness belongs (Tier 0)

  • Lock the game state at request time: record the player, the stake, and every input the outcome depends on, keyed by reqHash, before _requestRandomness returns.
  • Resolve against that snapshot in the callback. _fulfillRandomSeed(reqHash, seed) must read the snapshot, not live state that another transaction could have changed.
  • Never revert in rawFulfillRandomSeed. Bound the callback's work to callbackGasLimit, avoid external calls that can fail, and treat an unknown reqHash as a no-op.
  • If a callback did revert anyway, read seedOf(reqHash) on the coordinator — it returns (seed, fulfilled, callbackOk) — and resolve from the stored seed with a separate, non-reverting entry point. Do not request again.
  • Choose callbackGasLimit within [50000, 2500000] and measure it with forge test --gas-report; the request reverts with GasLimit outside the bounds.

2. Copy the base and the sample (Tier 0)

The base ships with the sample — copy both files. RandooConsumerRonin is the abstract base; TemplateConsumer is the reference consumer to adapt.

RandooConsumerRonin.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {IRoninVRFCoordinatorForConsumers} from "./interfaces/IRoninVRFCoordinatorForConsumers.sol";

/// @notice Ronin VRFConsumer-shaped base. Swap coordinator address to migrate.
abstract contract RandooConsumerRonin {
    error OnlyCoordinatorCanFulfill();

    address public vrfCoordinator;

    constructor(address vrfCoordinator_) {
        vrfCoordinator = vrfCoordinator_;
    }

    function rawFulfillRandomSeed(bytes32 reqHash, uint256 randomSeed) external {
        if (msg.sender != vrfCoordinator) revert OnlyCoordinatorCanFulfill();
        _fulfillRandomSeed(reqHash, randomSeed);
    }

    function _fulfillRandomSeed(bytes32 reqHash, uint256 randomSeed) internal virtual;

    function _requestRandomness(
        uint256 value,
        uint256 callbackGasLimit,
        uint256 gasPriceToFulfill,
        address refundAddr
    ) internal returns (bytes32 reqHash) {
        reqHash = IRoninVRFCoordinatorForConsumers(vrfCoordinator).requestRandomSeed{value: value}(
            callbackGasLimit, gasPriceToFulfill, address(this), refundAddr
        );
    }
}
TemplateConsumer.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

// ---------------------------------------------------------------------------
// Randoo consumer template — copy-pasteable as a unit with
// RandooConsumerTemplate.t.sol and MockRandooCoordinator.sol (this folder), plus
// src/RandooConsumerRonin.sol and src/interfaces/IRoninVRFCoordinatorForConsumers.sol.
// Imports resolve in a fresh `forge init` with only forge-std installed.
// ---------------------------------------------------------------------------

import {RandooConsumerRonin} from "../../src/RandooConsumerRonin.sol";

/// @notice The canonical Randoo consumer. Copy it, rename it, put your game
///         logic in `_fulfillRandomSeed`.
///
/// Flow: a caller pays `estimateRequestRandomFee(callbackGasLimit, gasPrice)`
/// into `roll`, which forwards the request to the coordinator and returns the
/// `reqHash`. Later the coordinator calls `rawFulfillRandomSeed` (inherited,
/// coordinator-only) which lands in `_fulfillRandomSeed` with the seed.
contract TemplateConsumer is RandooConsumerRonin {
    /// @notice Last seed delivered by the coordinator.
    uint256 public lastSeed;
    /// @notice Request hash of the most recent `roll`, then of the most recent fulfil.
    bytes32 public lastReqHash;

    constructor(address vrfCoordinator_) RandooConsumerRonin(vrfCoordinator_) {}

    /// @param callbackGasLimit Gas the coordinator will give `_fulfillRandomSeed` (50k..2.5M).
    /// @param gasPrice Gas price the fulfiller may spend; must be >= the coordinator floor.
    /// @param refundAddr Receives unspent fulfilment gas; must be non-zero.
    function roll(uint256 callbackGasLimit, uint256 gasPrice, address refundAddr)
        external
        payable
        returns (bytes32 reqHash)
    {
        reqHash = _requestRandomness(msg.value, callbackGasLimit, gasPrice, refundAddr);
        lastReqHash = reqHash;
    }

    /// @dev Your randomness lands here. Keep it within `callbackGasLimit`.
    function _fulfillRandomSeed(bytes32 reqHash, uint256 randomSeed) internal override {
        lastReqHash = reqHash;
        lastSeed = randomSeed;
    }
}

The interface the base imports:

IRoninVRFCoordinatorForConsumers.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// @dev Byte-compatible with Ronin VRF's consumer-facing coordinator.
interface IRoninVRFCoordinatorForConsumers {
    function requestRandomSeed(
        uint256 callbackGasLimit,
        uint256 gasPrice,
        address consumer,
        address refundAddress
    ) external payable returns (bytes32 reqHash);

    function estimateRequestRandomFee(uint256 callbackGasLimit, uint256 gasPrice)
        external
        view
        returns (uint256);
}

interface IRoninVRFConsumer {
    function rawFulfillRandomSeed(bytes32 reqHash, uint256 randomSeed) external;
}

3. Quote the fee on-chain (Tier 0)

The fee is estimateRequestRandomFee(callbackGasLimit, gasPrice); it includes 500000 extra gas and a USD-pegged service fee, so it moves with the RON price. Never hardcode it. gasPrice must be at or above minRequestGasPrice() after the coordinator's buffer, or the request reverts with InvalidGasPrice; the floor is owner-settable, so read it live.

With cast:

cast call 0xC66eB2e7EE91145875000Ad46B55600000000001 "estimateRequestRandomFee(uint256,uint256)(uint256)" 250000 25000000000 --rpc-url https://saigon-testnet.roninchain.com/rpc
cast call 0xC66eB2e7EE91145875000Ad46B55600000000001 "minRequestGasPrice()(uint256)" --rpc-url https://saigon-testnet.roninchain.com/rpc

With viem (the ABI fragment is the two consumer-facing functions; chain metadata comes from randoo.json):

import { createPublicClient, http, parseAbi } from "viem";

const abi = parseAbi([
  "function estimateRequestRandomFee(uint256 callbackGasLimit, uint256 gasPrice) view returns (uint256)",
  "function minRequestGasPrice() view returns (uint256)",
]);

// coordinator, chainId, rpc: read from randoo.json with jq; do not paste them.
const client = createPublicClient({ transport: http(rpc) });
const gasPrice = 25_000_000_000n; // >= minRequestGasPrice() after the buffer
const fee = await client.readContract({
  address: coordinator,
  abi,
  functionName: "estimateRequestRandomFee",
  args: [250_000n, gasPrice],
});

4. Request with value (Tier 0 while writing; Tier 2 when sending)

Inside the consumer, the request is _requestRandomness(msg.value, callbackGasLimit, gasPrice, refundAddr):

  • msg.value is at least the live quote from step 3 (InsufficientFee otherwise); the coordinator refunds the excess to refundAddr.
  • callbackGasLimit within [50000, 2500000].
  • gasPrice at or above minRequestGasPrice().
  • refundAddr is a non-zero address A2 names (InvalidRefund on zero). If the consumer is not payable, it cannot be its own refund address.
  • The caller of requestRandomSeed must be the consumer itself (CallerIsNotConsumer otherwise); the base already does this.

5. Copy the mock and the test (Tier 0)

The mock reproduces the live coordinator's request checks in the same order with the same error names — callback-gas bounds (GasLimit), caller-is-consumer (CallerIsNotConsumer), non-zero refund (InvalidRefund), the buffered gas-price floor (InvalidGasPrice: gasPrice is marked up by gasPriceBufferBps before the floor check, live default 10 %), and the fee check against a quote that prices the buffered gas (InsufficientFee) — plus one-shot fulfil (AlreadyFulfilled on a second delivery, even after a failed callback) and the live 7-field RandomSeedFulfilled event. It does not reproduce the USD-pegged service fee (a flat fixedFee stands in), the oracle set, confirmations, or refund accounting. The test asserts the consumer against it. Copy both verbatim and swap only at the marked SWAP POINT.

MockRandooCoordinator.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

// ---------------------------------------------------------------------------
// Randoo consumer template — copy-pasteable as a unit with
// RandooConsumerTemplate.t.sol and TemplateConsumer.sol (this folder), plus
// src/RandooConsumerRonin.sol and src/interfaces/IRoninVRFCoordinatorForConsumers.sol.
// Imports resolve in a fresh `forge init` with only forge-std installed.
// ---------------------------------------------------------------------------

import {
    IRoninVRFCoordinatorForConsumers,
    IRoninVRFConsumer
} from "../../src/interfaces/IRoninVRFCoordinatorForConsumers.sol";

/// @notice Test double for the Randoo VRF coordinator.
///
/// `requestRandomSeed` performs the same checks, in the same order, with the
/// same custom error names and the same gas-price buffer as the live
/// coordinator, so a consumer that passes against this mock will not be
/// surprised on-chain. `fulfil` delivers a seed the way the live fulfiller
/// does: exactly once per request (`AlreadyFulfilled` on a retry), via a
/// bounded-gas low-level call whose failure is reported, never propagated
/// (the Chainlink `VRFCoordinatorV2Mock` pattern).
///
/// Not reproduced: the USD-pegged service fee (a flat `fixedFee` stands in),
/// the oracle set, confirmations, and refund accounting.
contract MockRandooCoordinator is IRoninVRFCoordinatorForConsumers {
    // Same names as RandooVRFCoordinator so `vm.expectRevert(Mock.X.selector)`
    // matches what the real contract would throw.
    error CallerIsNotConsumer();
    error InvalidRefund();
    error GasLimit();
    error InvalidGasPrice();
    error InsufficientFee();
    error UnknownRequest();
    error AlreadyFulfilled();

    /// @dev Same shape as the live coordinator's event.
    event RandomSeedRequested(
        bytes32 indexed reqHash,
        uint256 indexed requestId,
        address indexed consumer,
        uint256 callbackGasLimit,
        uint256 gasPrice,
        address refundAddress,
        uint256 prepaid
    );
    /// @dev Same shape as the live coordinator's event. The mock does no fee
    ///      accounting, so `payment`, `fee`, and `refund` are always 0 here.
    event RandomSeedFulfilled(
        bytes32 indexed reqHash,
        uint256 indexed requestId,
        uint256 seed,
        uint256 payment,
        uint256 fee,
        uint256 refund,
        bool callbackOk
    );

    // Live coordinator bounds.
    uint256 public constant MIN_CALLBACK_GAS = 50_000;
    uint256 public constant MAX_CALLBACK_GAS = 2_500_000;
    /// @dev Gas the fulfiller spends around the callback; priced into every quote.
    uint256 public constant ADDITIONAL_GAS = 500_000;
    uint256 public constant BPS = 10_000;

    /// @notice Flat service fee added to every quote. Settable for tests.
    uint256 public fixedFee = 0.05 ether;
    /// @notice Floor the BUFFERED `gasPrice` must reach. Settable for tests.
    uint256 public minGasPrice = 21 gwei;
    /// @notice Live default: the coordinator marks every `gasPrice` up by 10 %
    ///         before the floor check and the fee; the quote includes it.
    uint16 public gasPriceBufferBps = 1000;

    uint256 public requestNonce;
    bytes32 public lastReqHash;
    mapping(bytes32 => address) public consumerOf;
    mapping(bytes32 => uint256) public requestIdOf;
    mapping(bytes32 => uint256) public callbackGasLimitOf;
    mapping(bytes32 => bool) public fulfilled;

    function setFixedFee(uint256 fee) external {
        fixedFee = fee;
    }

    function setMinGasPrice(uint256 price) external {
        minGasPrice = price;
    }

    function setGasPriceBufferBps(uint16 bps) external {
        gasPriceBufferBps = bps;
    }

    /// @notice The live coordinator's `_bufferedGasPrice`, same arithmetic.
    function bufferedGasPrice(uint256 gasPrice) public view returns (uint256) {
        return gasPrice + (gasPrice * gasPriceBufferBps) / BPS;
    }

    /// @inheritdoc IRoninVRFCoordinatorForConsumers
    function estimateRequestRandomFee(uint256 callbackGasLimit, uint256 gasPrice)
        public
        view
        returns (uint256)
    {
        return fixedFee + bufferedGasPrice(gasPrice) * (callbackGasLimit + ADDITIONAL_GAS);
    }

    /// @inheritdoc IRoninVRFCoordinatorForConsumers
    function requestRandomSeed(
        uint256 callbackGasLimit,
        uint256 gasPrice,
        address consumer,
        address refundAddress
    ) external payable returns (bytes32 reqHash) {
        if (callbackGasLimit < MIN_CALLBACK_GAS || callbackGasLimit > MAX_CALLBACK_GAS) revert GasLimit();
        if (msg.sender != consumer) revert CallerIsNotConsumer();
        if (refundAddress == address(0)) revert InvalidRefund();
        gasPrice = bufferedGasPrice(gasPrice);
        if (gasPrice == 0 || gasPrice < minGasPrice) revert InvalidGasPrice();
        if (msg.value < fixedFee + gasPrice * (callbackGasLimit + ADDITIONAL_GAS)) revert InsufficientFee();

        uint256 requestId = ++requestNonce;
        reqHash = keccak256(abi.encode(address(this), requestId, consumer, block.number));
        lastReqHash = reqHash;
        consumerOf[reqHash] = consumer;
        requestIdOf[reqHash] = requestId;
        callbackGasLimitOf[reqHash] = callbackGasLimit;

        emit RandomSeedRequested(
            reqHash, requestId, consumer, callbackGasLimit, gasPrice, refundAddress, msg.value
        );
    }

    /// @notice Deliver `seed` for `reqHash` to its consumer, exactly as the live
    ///         fulfiller does: once only, bounded gas, failure reported not
    ///         propagated. A second call for the same `reqHash` reverts with
    ///         `AlreadyFulfilled` even if the first callback failed.
    /// @return success False if the consumer's callback reverted or ran out of gas.
    function fulfil(bytes32 reqHash, uint256 seed) external returns (bool success) {
        address consumer = consumerOf[reqHash];
        if (consumer == address(0)) revert UnknownRequest();
        if (fulfilled[reqHash]) revert AlreadyFulfilled();
        fulfilled[reqHash] = true;
        (success,) = consumer.call{gas: callbackGasLimitOf[reqHash]}(
            abi.encodeWithSelector(IRoninVRFConsumer.rawFulfillRandomSeed.selector, reqHash, seed)
        );
        emit RandomSeedFulfilled(reqHash, requestIdOf[reqHash], seed, 0, 0, 0, success);
    }
}
RandooConsumerTemplate.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

// ---------------------------------------------------------------------------
// Randoo consumer template — copy-pasteable as a unit.
//
// This file, MockRandooCoordinator.sol, and TemplateConsumer.sol (this folder)
// plus src/RandooConsumerRonin.sol and src/interfaces/IRoninVRFCoordinatorForConsumers.sol
// are the five published files. Drop them into a fresh `forge init` project at
//
//   src/RandooConsumerRonin.sol
//   src/interfaces/IRoninVRFCoordinatorForConsumers.sol
//   test/templates/RandooConsumerTemplate.t.sol
//   test/templates/MockRandooCoordinator.sol
//   test/templates/TemplateConsumer.sol
//
// and `forge test` passes with only forge-std installed.
//
// To test YOUR consumer: replace `TemplateConsumer` at the SWAP POINT below with
// your contract (it must inherit RandooConsumerRonin and expose a payable entry
// point that calls `_requestRandomness`). The assertions stay as they are.
// ---------------------------------------------------------------------------

import {Test} from "forge-std/Test.sol";
import {RandooConsumerRonin} from "../../src/RandooConsumerRonin.sol";
import {MockRandooCoordinator} from "./MockRandooCoordinator.sol";
import {TemplateConsumer} from "./TemplateConsumer.sol";

/// @dev Consumer whose callback always reverts. Used to prove the coordinator
///      never lets a bad callback revert the fulfil transaction.
contract RevertingConsumer is RandooConsumerRonin {
    constructor(address vrfCoordinator_) RandooConsumerRonin(vrfCoordinator_) {}

    function roll(uint256 callbackGasLimit, uint256 gasPrice, address refundAddr)
        external
        payable
        returns (bytes32)
    {
        return _requestRandomness(msg.value, callbackGasLimit, gasPrice, refundAddr);
    }

    function _fulfillRandomSeed(bytes32, uint256) internal pure override {
        revert("callback failed");
    }
}

contract RandooConsumerTemplateTest is Test {
    // Same bounds and defaults as the live Randoo coordinator.
    uint256 internal constant CALLBACK_GAS = 200_000;
    uint256 internal constant GAS_PRICE = 21 gwei;

    MockRandooCoordinator internal coordinator;
    // ---- SWAP POINT: replace TemplateConsumer with your consumer contract. ----
    TemplateConsumer internal consumer;
    // ---------------------------------------------------------------------------

    address internal player = makeAddr("player");
    address internal refundTo = makeAddr("refund");

    function setUp() public {
        coordinator = new MockRandooCoordinator();
        // ---- SWAP POINT: construct your consumer with the coordinator address. ----
        consumer = new TemplateConsumer(address(coordinator));
        // ---------------------------------------------------------------------------
        vm.deal(player, 100 ether);
    }

    function _quote() internal view returns (uint256) {
        return coordinator.estimateRequestRandomFee(CALLBACK_GAS, GAS_PRICE);
    }

    // ----------------------------------------------------------------- request

    function testRequestSucceedsAndStoresReqHash() public {
        uint256 quote = _quote();
        vm.prank(player);
        bytes32 reqHash = consumer.roll{value: quote}(CALLBACK_GAS, GAS_PRICE, refundTo);

        assertTrue(reqHash != bytes32(0), "reqHash should be non-zero");
        assertEq(consumer.lastReqHash(), reqHash, "consumer should store reqHash");
        assertEq(coordinator.lastReqHash(), reqHash, "coordinator should record reqHash");
        assertEq(coordinator.consumerOf(reqHash), address(consumer), "consumer bound to reqHash");
    }

    function testZeroRefundAddressReverts() public {
        uint256 quote = _quote();
        vm.prank(player);
        vm.expectRevert(MockRandooCoordinator.InvalidRefund.selector);
        consumer.roll{value: quote}(CALLBACK_GAS, GAS_PRICE, address(0));
    }

    function testCallbackGasBelowMinReverts() public {
        uint256 gasLimit = coordinator.MIN_CALLBACK_GAS() - 1;
        uint256 quote = coordinator.estimateRequestRandomFee(gasLimit, GAS_PRICE);
        vm.prank(player);
        vm.expectRevert(MockRandooCoordinator.GasLimit.selector);
        consumer.roll{value: quote}(gasLimit, GAS_PRICE, refundTo);
    }

    function testCallbackGasAboveMaxReverts() public {
        uint256 gasLimit = coordinator.MAX_CALLBACK_GAS() + 1;
        uint256 quote = coordinator.estimateRequestRandomFee(gasLimit, GAS_PRICE);
        vm.prank(player);
        vm.expectRevert(MockRandooCoordinator.GasLimit.selector);
        consumer.roll{value: quote}(gasLimit, GAS_PRICE, refundTo);
    }

    /// @dev Live semantics: the coordinator marks `gasPrice` up by its buffer
    ///      and compares THAT to the floor. A raw price one wei under the floor
    ///      still clears it once buffered; a raw price whose buffered value is
    ///      one wei short of the floor reverts.
    function testGasPriceBelowBufferedFloorReverts() public {
        uint256 floor = coordinator.minGasPrice();
        uint256 bps = coordinator.BPS();
        uint256 buffer = coordinator.gasPriceBufferBps();

        // Highest raw price that stays under the floor after the buffer.
        uint256 lowPrice = (floor * bps) / (bps + buffer) - 1;
        assertLt(coordinator.bufferedGasPrice(lowPrice), floor, "fixture: buffered must be < floor");
        uint256 quote = coordinator.estimateRequestRandomFee(CALLBACK_GAS, lowPrice);
        vm.prank(player);
        vm.expectRevert(MockRandooCoordinator.InvalidGasPrice.selector);
        consumer.roll{value: quote}(CALLBACK_GAS, lowPrice, refundTo);

        // Raw price under the floor, buffered price at or above it: accepted.
        uint256 liftedPrice = floor - 1;
        assertGe(coordinator.bufferedGasPrice(liftedPrice), floor, "fixture: buffer lifts to floor");
        quote = coordinator.estimateRequestRandomFee(CALLBACK_GAS, liftedPrice);
        vm.prank(player);
        bytes32 reqHash = consumer.roll{value: quote}(CALLBACK_GAS, liftedPrice, refundTo);
        assertEq(consumer.lastReqHash(), reqHash);
    }

    /// @dev The quote prices the BUFFERED gas price. A consumer that computes
    ///      `fixedFee + gasPrice * (callbackGasLimit + ADDITIONAL_GAS)` itself
    ///      underpays and reverts on-chain; only the on-chain quote is correct.
    function testUnbufferedFeeIsRejected() public {
        uint256 unbuffered = coordinator.fixedFee() + GAS_PRICE * (CALLBACK_GAS + coordinator.ADDITIONAL_GAS());
        uint256 quote = _quote();
        assertGt(quote, unbuffered, "quote must include the gas-price buffer");

        vm.prank(player);
        vm.expectRevert(MockRandooCoordinator.InsufficientFee.selector);
        consumer.roll{value: unbuffered}(CALLBACK_GAS, GAS_PRICE, refundTo);
    }

    function testCallerMustBeConsumer() public {
        uint256 quote = _quote();
        // Call the coordinator directly, naming the consumer but not being it.
        vm.prank(player);
        vm.expectRevert(MockRandooCoordinator.CallerIsNotConsumer.selector);
        coordinator.requestRandomSeed{value: quote}(CALLBACK_GAS, GAS_PRICE, address(consumer), refundTo);
    }

    function testUnderpaidRequestRevertsAndExactQuoteSucceeds() public {
        uint256 quote = _quote();

        vm.prank(player);
        vm.expectRevert(MockRandooCoordinator.InsufficientFee.selector);
        consumer.roll{value: quote - 1}(CALLBACK_GAS, GAS_PRICE, refundTo);

        vm.prank(player);
        bytes32 reqHash = consumer.roll{value: quote}(CALLBACK_GAS, GAS_PRICE, refundTo);
        assertEq(consumer.lastReqHash(), reqHash);
    }

    // ------------------------------------------------------------------ fulfil

    function testFulfilDeliversSeedAndRejectsNonCoordinator() public {
        uint256 quote = _quote();
        vm.prank(player);
        bytes32 reqHash = consumer.roll{value: quote}(CALLBACK_GAS, GAS_PRICE, refundTo);

        // Nobody but the coordinator may deliver a seed.
        vm.prank(player);
        vm.expectRevert(RandooConsumerRonin.OnlyCoordinatorCanFulfill.selector);
        consumer.rawFulfillRandomSeed(reqHash, 1);
        assertEq(consumer.lastSeed(), 0, "seed must not land from a stranger");

        uint256 seed = uint256(keccak256("seed"));
        // Same 7-field event the live coordinator emits (requestId 1: first request).
        vm.expectEmit(true, true, false, true, address(coordinator));
        emit MockRandooCoordinator.RandomSeedFulfilled(reqHash, 1, seed, 0, 0, 0, true);
        bool ok = coordinator.fulfil(reqHash, seed);
        assertTrue(ok, "fulfil should succeed");
        assertEq(consumer.lastSeed(), seed, "consumer should store the seed");
        assertEq(consumer.lastReqHash(), reqHash);
    }

    /// @dev One-shot: the oracle fulfils exactly once and never redelivers.
    function testFulfilTwiceReverts() public {
        uint256 quote = _quote();
        vm.prank(player);
        bytes32 reqHash = consumer.roll{value: quote}(CALLBACK_GAS, GAS_PRICE, refundTo);

        uint256 seed = uint256(keccak256("seed"));
        assertTrue(coordinator.fulfil(reqHash, seed));
        assertTrue(coordinator.fulfilled(reqHash), "request must be marked fulfilled");

        vm.expectRevert(MockRandooCoordinator.AlreadyFulfilled.selector);
        coordinator.fulfil(reqHash, seed + 1);
        assertEq(consumer.lastSeed(), seed, "the first seed must stand");
    }

    function testFulfilAgainstRevertingConsumerReturnsFalse() public {
        RevertingConsumer bad = new RevertingConsumer(address(coordinator));
        uint256 quote = _quote();
        vm.prank(player);
        bytes32 reqHash = bad.roll{value: quote}(CALLBACK_GAS, GAS_PRICE, refundTo);

        bool ok = coordinator.fulfil(reqHash, 42);
        assertFalse(ok, "callback revert must surface as success == false, not a revert");

        // A failed callback is still consumed: no redelivery, the seed is not re-rolled.
        vm.expectRevert(MockRandooCoordinator.AlreadyFulfilled.selector);
        coordinator.fulfil(reqHash, 43);
    }
}

Verify

  1. Create a scratch Foundry project OUTSIDE the target repo. Use the toolchain already on the machine when there is one (Tier 0):
command -v forge && forge --version
export SCRATCH="$(mktemp -d)"
cd "$SCRATCH" && forge init --no-git randoo-check && cd randoo-check

Only if forge is absent: the install is a Tier 2 step. You never execute an installer (curl … | bash included). Print these commands, with FOUNDRY_DIR set to the scratch location, and wait for A2 to run them before forge init:

export SCRATCH="$(mktemp -d)"
export FOUNDRY_DIR="$SCRATCH/.foundry"      # binaries land here, not in $HOME/.foundry
curl -L https://foundry.paradigm.xyz | bash  # official installer, https://foundry.paradigm.xyz
"$FOUNDRY_DIR/bin/foundryup"
export PATH="$FOUNDRY_DIR/bin:$PATH"

Note for A2: foundryup also appends a PATH line to the shell profile in $HOME even with FOUNDRY_DIR set. To keep the install fully out of $HOME, A2 may instead download the release archive from the official Foundry GitHub releases page and unpack forge and cast into $FOUNDRY_DIR/bin by hand.

  1. Lay the five published files out exactly as the template test header says, then add the new consumer:
src/RandooConsumerRonin.sol
src/interfaces/IRoninVRFCoordinatorForConsumers.sol
src/<YourConsumer>.sol
test/templates/RandooConsumerTemplate.t.sol
test/templates/MockRandooCoordinator.sol
test/templates/TemplateConsumer.sol
  1. In test/templates/RandooConsumerTemplate.t.sol, replace TemplateConsumer with the new consumer at both SWAP POINT markers (the type and the constructor). Leave every assertion as it is. The consumer must inherit RandooConsumerRonin and expose a payable entry point that calls _requestRandomness.

  2. Run the tests and the live quote (Tier 0):

forge test -vv
cast call 0xC66eB2e7EE91145875000Ad46B55600000000001 "estimateRequestRandomFee(uint256,uint256)(uint256)" 250000 25000000000 --rpc-url https://saigon-testnet.roninchain.com/rpc

forge test must pass with only forge-std installed and the quote must be non-zero.

  1. Optional live request on Saigon (Tier 2). Only if A2 asks for it. Run the preflight (cast chain-id equals the Saigon chain ID from randoo.json; destination equals the randoo.json coordinator), then show the approval prompt with the live chain ID, the checksummed destination, the amount in wei and RON, the exact command, and the wallet A2 named, and print the commands for A2 to run:
cast chain-id --rpc-url https://saigon-testnet.roninchain.com/rpc        # must print 202601
forge create src/<YourConsumer>.sol:<YourConsumer> --constructor-args 0xC66eB2e7EE91145875000Ad46B55600000000001 \
  --rpc-url https://saigon-testnet.roninchain.com/rpc --account <alias-A2-named> --broadcast
cast send <deployed-consumer> "roll(uint256,uint256,address)" 250000 25000000000 <refund-address-A2-named> \
  --value <quote-from-step-4-wei> --rpc-url https://saigon-testnet.roninchain.com/rpc --account <alias-A2-named>

You never run forge create or cast send yourself and never hold a key. Mainnet is Tier 3: report the steps for A2, do not perform them.

Report

Print the report AND persist it in the diff (e.g. RANDOO-INTEGRATION.md) or the PR description. Use exactly these headings.

# Randoo integration report

## Inventory
| file:line | contract | base type | binding mechanism | address + network |

## Uses
- What each seed decides:
- State locked at request time and resolved in the callback:

## Changes
- Consumer added (file, entry point, callbackGasLimit, gasPrice):
- Refund address chosen and why (named by A2):
- gasPrice / callbackGasLimit vs live floor and bounds:
- Files touched:

## Unchanged
- Base contract and interface (copied verbatim):
- Template test assertions:

## Verification
- Commands and outputs (`forge test`, fee quote, `minRequestGasPrice`):
- Implementation address seen per network (`cast implementation`):
- Date:

## Human must re-verify
- Deploy / env per environment:
- Refund-address ownership:
- Mainnet rollout:

## Stops hit
- (unverified — implementation changed / wrong chain / address mismatch / network)

Apply the redaction rule to every line of the report: file path, line number, variable name, and the matched coordinator address only.

Last verified 2026-08-25.