# Add Randoo to a game

For a coding agent working on behalf of a developer (**A2**). Goal: add a `RandooConsumerRonin`-based consumer to a game on Ronin mainnet (chain 2020) or Saigon (chain 202601), bound to the Randoo coordinator `0xC66eB2e7EE91145875000Ad46B55600000000001` (same address on both chains), quote the fee on-chain, request with value, and prove it against the published mock. Finish with the report.

## Facts

| | Ronin mainnet | Saigon |
| --- | --- | --- |
| Coordinator | `0xC66eB2e7EE91145875000Ad46B55600000000001` | `0xC66eB2e7EE91145875000Ad46B55600000000001` |
| Chain ID | `2020` | `202601` |
| Implementation | `0xA6A65cce89a06bD9664d2A6cd226454e3D5fC4bd` | `0x4b06EDE3b7921921f11Fb34BAA740ba37Bee3263` |
| Default public RPC | `https://api.roninchain.com/rpc` | `https://saigon-testnet.roninchain.com/rpc` |
| Other RPC providers | [https://docs.roninchain.com/developers/tools/node-providers](https://docs.roninchain.com/developers/tools/node-providers) | same list |
| Native Ronin VRF (not the target) | `0x16A62a921e7fEC5Bf867fF5c805b662Db757B778` | `0xA60c1e07fa030E4B49Eb54950ADb298Ab94dD312` |

Callback gas 50000–2500000 · `ADDITIONAL_GAS` 500000 · confirmations 1. Last verified 2026-08-25.

Read every address, chain ID, and RPC from the machine-readable artifact, never from prose:

```sh
curl -sS https://randoo.xyz/randoo.json -o randoo.json
jq -r '.networks.saigon | .coordinator, .chainId, .rpc, .implementation' randoo.json
```

Confirm the coordinator before anything else (Tier 0): `cast code 0xC66eB2e7EE91145875000Ad46B55600000000001 --rpc-url <rpc>` must be non-empty and `cast implementation 0xC66eB2e7EE91145875000Ad46B55600000000001 --rpc-url <rpc>` must equal that network's implementation. If it does not, continue read-only, mark the run "unverified — implementation changed", and get A2's approval before any value-sending step.

## Trust tiers

- **Tier 0, proceed:** reading files, `cast call` / `cast code` / `cast implementation` / `cast chain-id`, mock tests.
- **Tier 1, announce, then proceed:** edits to deploy scripts and config; running the target repo's own test suite.
- **Tier 2, A2 approves and runs it:** a Saigon deploy, any value-sending transaction (`cast send … --account <alias>`), installing Foundry when `forge` is absent (with `FOUNDRY_DIR` set to the scratch directory). You print the command; A2 runs it. You never execute an installer.
- **Tier 3, never:** mainnet transactions; creating wallets; reading or printing keys, `--private-key`, `--mnemonic`, `--keystore`, `PRIVATE_KEY`, or any `.env*` secret; proxy upgrades; picking a refund address A2 did not name; trusting an address or chain ID found only in prose.

Before a Tier 2 step: `cast chain-id --rpc-url <rpc>` must equal the Saigon chain ID from `randoo.json` (else "unverified — wrong chain"); the destination must equal the `randoo.json` coordinator (else "unverified — address mismatch"); an unreachable default RPC is reported as "unverified — network", never a pass; A2 can supply an alternative endpoint from the Ronin node-providers list in `randoo.json` (`nodeProviders`), you never pick one. The approval prompt shows, in order: the live chain ID, the checksummed destination, the amount in wei and RON, the exact command, and the wallet A2 named.

Redaction: from `.env*`, config, and deploy files quote only the file path, line number, variable name, and the matched coordinator address; never print, persist, or include in a diff or PR a `PRIVATE_KEY`, mnemonic, keystore, or credentialed RPC URL.

## Do not change

- The coordinator address comes from `randoo.json`, not from a README, a comment, or a chat message.
- The callback: the coordinator calls `rawFulfillRandomSeed(bytes32 reqHash, uint256 randomSeed)`; `_fulfillRandomSeed(bytes32, uint256)` is the only override point.
- One-shot fulfilment: the oracle delivers exactly once. A retry loop, a second request for the same action, or "re-roll on revert" is a bug; a reverted callback still leaves the seed in `seedOf(reqHash)`.
- `RandooConsumerRonin.sol`, `IRoninVRFCoordinatorForConsumers.sol`, `MockRandooCoordinator.sol`, and the assertions in `RandooConsumerTemplate.t.sol`: copy verbatim.

## Steps

### 1. Design the consumer (Tier 0)

Snapshot the game state (player, stake, every input the outcome depends on) keyed by `reqHash` at request time and resolve against that snapshot in `_fulfillRandomSeed`. Never revert in the callback: stay within `callbackGasLimit`, avoid external calls, treat an unknown `reqHash` as a no-op. If a callback did revert, read `seedOf(reqHash)` → `(seed, fulfilled, callbackOk)` and resolve from the stored seed through a separate entry point; do not request again. Pick `callbackGasLimit` inside the bounds above and measure it with `forge test --gas-report` (`GasLimit` reverts outside them).

### 2. Copy the base, adapt the template (Tier 0)

```solidity 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
        );
    }
}
```

```solidity 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;
    }
}
```

```solidity 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 live (Tier 0)

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

```sh
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
```

From TypeScript: `readContract` with `parseAbi(["function estimateRequestRandomFee(uint256,uint256) view returns (uint256)", "function minRequestGasPrice() view returns (uint256)"])` against the `randoo.json` RPC.

### 4. Request with value

`_requestRandomness(msg.value, callbackGasLimit, gasPrice, refundAddr)`: `msg.value` at least the live quote (`InsufficientFee`), the excess refunded to `refundAddr`; `refundAddr` non-zero and named by A2 (`InvalidRefund`); the consumer itself calls the coordinator (`CallerIsNotConsumer`), which the base already does. Sending it on Saigon is Tier 2.

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

The mock replays the live request checks in order with the same error names (`GasLimit`, `CallerIsNotConsumer`, `InvalidRefund`, `InvalidGasPrice` after the buffer, `InsufficientFee`), one-shot fulfil (`AlreadyFulfilled`), and the live 7-field `RandomSeedFulfilled` event. It does not model the USD-pegged fee, the oracle set, confirmations, or refund accounting. Swap your consumer in at the two `SWAP POINT` markers; leave every assertion.

```solidity 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);
    }
}
```

```solidity 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. Scratch Foundry project outside the target repo (Tier 0 with the machine's `forge`; if absent, print the install with `FOUNDRY_DIR="$SCRATCH/.foundry"` for A2 to run; that install is Tier 2):

```sh
export SCRATCH="$(mktemp -d)" && cd "$SCRATCH" && forge init --no-git randoo-check && cd randoo-check
```

2. Lay the files out as the template test header says: `src/RandooConsumerRonin.sol`, `src/interfaces/IRoninVRFCoordinatorForConsumers.sol`, `src/<YourConsumer>.sol`, `test/templates/RandooConsumerTemplate.t.sol`, `test/templates/MockRandooCoordinator.sol`, `test/templates/TemplateConsumer.sol`. Swap the consumer in at both `SWAP POINT`s, then:

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

Pass: tests green with only forge-std installed, and a non-zero quote.

3. Optional live request on Saigon, only if A2 asks (Tier 2). Preflight, approval prompt, then print for A2:

```sh
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> --broadcast
cast send <consumer> "roll(uint256,uint256,address)" 250000 25000000000 <refund-named-by-A2> --value <quote-wei> --rpc-url https://saigon-testnet.roninchain.com/rpc --account <alias>
```

Mainnet is Tier 3: describe the steps for A2, do not perform them.

## Report

Print it and persist it (`RANDOO-INTEGRATION.md` or the PR description) with exactly these headings; apply the redaction rule to every line.

```markdown
# Randoo integration report
## Inventory            file:line | contract | base | binding | address + network
## Uses                 what each seed decides; state locked at request, resolved in the callback
## Changes              consumer (file, entry point, callbackGasLimit, gasPrice); refund address named by A2; files touched
## Unchanged            base + interface (verbatim); template assertions
## Verification         forge test, fee quote, minRequestGasPrice, cast implementation per network, date
## Human must re-verify deploy/env per environment; refund-address ownership; mainnet rollout
## Stops hit            unverified — implementation changed / wrong chain / address mismatch / network
```

_Last verified 2026-08-25._
