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
| Item | Value |
|---|---|
| Randoo coordinator (Ronin mainnet AND Saigon) | 0xC66eB2e7EE91145875000Ad46B55600000000001 |
| Ronin mainnet chain ID | 2020 |
| Ronin mainnet implementation behind the proxy | 0xA6A65cce89a06bD9664d2A6cd226454e3D5fC4bd |
| Ronin mainnet RPC | https://api.roninchain.com/rpc |
| Ronin mainnet RPC fallback | https://api-gateway.skymavis.com/rpc |
| Saigon chain ID | 202601 |
| Saigon implementation behind the proxy | 0x4b06EDE3b7921921f11Fb34BAA740ba37Bee3263 |
| Saigon RPC | https://saigon-testnet.roninchain.com/rpc |
| Saigon RPC fallback | https://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: minimum | 50000 |
| Callback gas: maximum | 2500000 |
Additional gas charged per request (ADDITIONAL_GAS) | 500000 |
| Confirmations | 1 |
| 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.jsonConfirm 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 aboveIf 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
| Tier | Meaning | Examples |
|---|---|---|
| 0 | Proceed | grep, reading files, cast call, cast code, cast implementation, cast chain-id, mock tests |
| 1 | Announce, then proceed | Edits to deploy scripts and config; running the target repo's existing test suite in place |
| 2 | A2 approval AND A2 runs it | Saigon 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) |
| 3 | Never | Mainnet 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:
- The live-read chain ID: output of
cast chain-id --rpc-url <rpc>. - The full checksummed destination address.
- The amount in wei and in RON.
- The exact command A2 will run.
- 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 fromrandoo.jsonbyte-for-byte, else abort with "unverified — wrong chain".- The destination must equal the artifact value from
randoo.jsonbyte-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 inRandooConsumerTemplate.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_requestRandomnessreturns. - 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 tocallbackGasLimit, avoid external calls that can fail, and treat an unknownreqHashas 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
callbackGasLimitwithin [50000, 2500000] and measure it withforge test --gas-report; the request reverts withGasLimitoutside 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.
// 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
);
}
}// 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:
// 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/rpcWith 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.valueis at least the live quote from step 3 (InsufficientFeeotherwise); the coordinator refunds the excess torefundAddr.callbackGasLimitwithin [50000, 2500000].gasPriceat or aboveminRequestGasPrice().refundAddris a non-zero address A2 names (InvalidRefundon zero). If the consumer is notpayable, it cannot be its own refund address.- The caller of
requestRandomSeedmust be the consumer itself (CallerIsNotConsumerotherwise); 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.
// 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);
}
}// 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
- 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-checkOnly 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.
- 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-
In
test/templates/RandooConsumerTemplate.t.sol, replaceTemplateConsumerwith the new consumer at bothSWAP POINTmarkers (the type and the constructor). Leave every assertion as it is. The consumer must inheritRandooConsumerRoninand expose a payable entry point that calls_requestRandomness. -
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/rpcforge test must pass with only forge-std installed and the quote must be non-zero.
- Optional live request on Saigon (Tier 2). Only if A2 asks for it. Run the preflight (
cast chain-idequals the Saigon chain ID fromrandoo.json; destination equals therandoo.jsoncoordinator), 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.