Provably fair smart contract development
Main blockchain randomness problem — deterministic environment. All network nodes must reach same result, so random must be predictable for all post-hoc. But if it's predictable post-hoc — miner or node operator can predict it beforehand. That's why block.prevrandao (formerly block.difficulty), block.timestamp, and blockhash() aren't safe randomness sources for bets.
Real case: lottery contract used blockhash(block.number - 1) as seed. Miner producing winning block could simply not publish it and try again — until blockhash gave winning result. Called block withholding attack.
Chainlink VRF v2.5: how it works
Chainlink VRF (Verifiable Random Function) — cryptographically verifiable randomness. Contract requests randomness, Chainlink oracle generates it with cryptographic proof, proof verified in smart contract before result use. If proof fails verification — transaction reverts.
Key moment: oracle can't predict what randomness it will generate for request, because seed includes future blockhash oracle doesn't know at request time. Cryptographic commitment to future.
VRF v2.5 integration
VRF v2.5 supports two payment modes: via subscription (prepaid LINK balance) and native token (pay ETH/MATIC on the fly). Subscription preferred for high-frequency requests.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";
contract ProvablyFairLottery is VRFConsumerBaseV2Plus {
uint256 public s_subscriptionId;
bytes32 public keyHash; // gas lane
uint32 public callbackGasLimit = 100000;
uint16 public requestConfirmations = 3; // minimum 3 blocks wait
mapping(uint256 => address) private requestToPlayer;
mapping(uint256 => uint256) private requestToGameId;
event RandomnessRequested(uint256 requestId, address player, uint256 gameId);
event GameResolved(uint256 gameId, address player, uint256 randomWord, bool won);
function requestRandomness(uint256 gameId) external returns (uint256 requestId) {
requestId = s_vrfCoordinator.requestRandomWords(
VRFV2PlusClient.RandomWordsRequest({
keyHash: keyHash,
subId: s_subscriptionId,
requestConfirmations: requestConfirmations,
callbackGasLimit: callbackGasLimit,
numWords: 1,
extraArgs: VRFV2PlusClient._argsToBytes(
VRFV2PlusClient.ExtraArgsV1({nativePayment: false})
)
})
);
requestToPlayer[requestId] = msg.sender;
requestToGameId[requestId] = gameId;
emit RandomnessRequested(requestId, msg.sender, gameId);
}
function fulfillRandomWords(uint256 requestId, uint256[] calldata randomWords) internal override {
address player = requestToPlayer[requestId];
uint256 gameId = requestToGameId[requestId];
// Use modulo to get number in range
// Important: modulo bias exists for non-powers-of-two, but acceptable for gaming
uint256 result = randomWords[0] % 100; // 0-99
bool won = result < 40; // 40% win chance
// Effects before interactions
delete requestToPlayer[requestId];
delete requestToGameId[requestId];
if (won) {
_sendPrize(player, gameId);
}
emit GameResolved(gameId, player, randomWords[0], won);
}
}
Why requestConfirmations matters
3 block confirmations means callback arrives in ~36 seconds on Ethereum. Not bug, protection: oracle can't know blockhash for block not yet mined. 1 confirmation gives fewer guarantees because 1-block reorg possible. For high-stakes games recommend 5-7 confirmations.
Commit-Reveal: when VRF is overkill
For scenarios not needing immediate verification, commit-reveal scheme works without external oracle and free infrastructure-wise.
Scheme:
- Player in transaction sends
hash(secret + nonce)— commitment - Operator (or other user) reveals their
secretin next block - Randomness =
keccak256(playerSecret XOR operatorSecret XOR blockhash)
Classic commit-reveal vulnerability: operator sees player's secret before reveal and can decide not to reveal his (griefing). Protection: timeout with penalization — if operator doesn't reveal within N blocks, loses deposit, player gets refund.
Commit-reveal suits: NFT mint order randomization post-reveal, raffle winner selection with small stakes, games where both sides motivated to complete round.
Fairness verification on frontend
Provably fair without user verification ability is just marketing. Implement full verification cycle:
// User can independently check result
async function verifyGameResult(gameId: string) {
const events = await contract.queryFilter(
contract.filters.GameResolved(gameId)
);
const { randomWord, requestId } = events[0].args;
// Get proof from Chainlink
const proofData = await fetchChainlinkVRFProof(requestId);
// Verify locally
const isValid = verifyVRFProof(proofData.proof, proofData.publicKey, randomWord);
return {
gameId,
randomWord: randomWord.toString(),
result: randomWord.mod(100).toNumber(),
proofValid: isValid,
txHash: events[0].transactionHash,
};
}
Auditing provably fair contracts
Specific attack vectors to check:
Front-running before reveal. If result predictable from pending transaction (commit-reveal scheme) — attacker can rush to bet on winning outcome. Protection: commitment fixed before player knows operator seed.
Replay attack on requestId. What if callback called twice for one requestId? Contract must mark fulfilled requests and reject repeat.
Griefing via unfulfilled requests. If player created many uncompleted VRF requests (didn't wait callback), may block contract logic dependent on pending state. Limit active requests per address.
Result depending on gas price. Some contracts use gasleft() or tx.gasprice as additional entropy. Makes result predictable for MEV bots.
Provably fair contract development with Chainlink VRF: 3-5 working days. Cost calculated individually.







