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 |
| 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 | 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:
curl -sS https://randoo.xyz/randoo.json -o randoo.json
jq -r '.networks.saigon | .coordinator, .chainId, .rpc, .implementation' randoo.jsonConfirm 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 networks.<network>.implementation in randoo.json (the proxy is the only address consumers use; this check only confirms it has not been swapped). 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 whenforgeis absent (withFOUNDRY_DIRset 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 inRandooConsumerTemplate.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)
// 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;
}
}// 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.
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/rpcFrom 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.
// 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
- Scratch Foundry project outside the target repo (Tier 0 with the machine's
forge; if absent, print the install withFOUNDRY_DIR="$SCRATCH/.foundry"for A2 to run; that install is Tier 2):
export SCRATCH="$(mktemp -d)" && cd "$SCRATCH" && forge init --no-git randoo-check && cd randoo-check- 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 bothSWAP POINTs, then:
forge test -vv
cast call 0xC66eB2e7EE91145875000Ad46B55600000000001 "estimateRequestRandomFee(uint256,uint256)(uint256)" 250000 25000000000 --rpc-url https://saigon-testnet.roninchain.com/rpcPass: tests green with only forge-std installed, and a non-zero quote.
- Optional live request on Saigon, only if A2 asks (Tier 2). Preflight, approval prompt, then print for A2:
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.
# 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 / networkLast verified 2026-08-25.