Skip to Content
Supra Layer 1SupraEVMNative Randomness

Using Native Randomness from an EVM Contract

Supra gives EVM contracts randomness synchronously: you call a precompile and it returns a fresh value inside the same transaction. The oracle services you would otherwise use, such as Chainlink VRF and Pyth Entropy, are asynchronous: you commit in one transaction and settle in another. Here you do not have to.

The precompile lives at 0x0000000000000000000000000000000053555003, and the address is frozen.

Read this first

A synchronous random value is only safe if nobody can look at it and then undo the transaction.

The VM enforces the part it can: only the contract the transaction was sent to may read, and only when the sender is an ordinary account. Those rules make it impossible for someone to wrap your contract, inspect your outcome, and abandon the call.

The part the VM cannot enforce is what your contract does after the read. There are two ways to undo a read, and closing both is your contract’s job: a callee that reverts you, and a gas limit that exhausts the branch the sender dislikes.

Quick start

import {LibRandomness} from "supra_contracts/libraries/LibRandomness.sol"; contract Coinflip { mapping(address => uint8) public lastOutcome; // 0 unplayed, 1 heads, 2 tails function flip() external { bytes32 value = LibRandomness.value(); lastOutcome[msg.sender] = uint256(value) % 2 == 0 ? 1 : 2; } }

The supra_contracts import is set up as described in Importing the Solidity libraries.

Send a transaction straight to Coinflip.flip() from a normal wallet and it works. The rules below are built around that shape.

The library is convenience only; every guarantee comes from the precompile. You can call ISupraRandomness(0x...5003).next() directly and get the same behaviour.

What you get

CallReturnsUse it for
LibRandomness.value()32 fresh bytesanything
LibRandomness.u256() / u64()a fresh integeranything
LibRandomness.u256Range(min, max) / u64Range(min, max)a number in [min, max)dice, weighted picks
LibRandomness.permutation(n)a shuffle of 0..n-1drawing without replacement
LibRandomness.bytesN(n)n fresh bytesseeds, ids
LibRandomness.seedAt(height)a past block’s seedcommit-then-settle

Every read is distinct. Two reads in one transaction differ, the same contract read in two transactions of one block differs, and no two reads on the chain ever share an input.

contract Dice { uint64 public lastRoll; uint64[] public lastShuffle; function roll() external { lastRoll = LibRandomness.u64Range(1, 7); } function shuffle(uint64 n) external { lastShuffle = LibRandomness.permutation(n); } }

u256Range reduces a 256-bit sample modulo the range, so the distribution is not perfectly uniform. The bias is negligible for any range a contract would use, and it matches what supra_framework::randomness does on SupraMVM, so a contract ported between the two VMs behaves the same. If you need exact uniformity, sample u256() and reject.

permutation(n) and bytesN(n) both make more than one read, and each read costs gas. Bound n.

The rules your call must satisfy

All of these must hold, or the call reverts with a specific error.

1. Your contract must be the transaction’s target. The read has to happen in the contract the transaction was sent to — or, if the transaction is a deployment, in the contract being deployed. A contract reached by CALL from another contract is refused.

This is what stops someone wrapping you:

// This does not work, and cannot be made to work. contract Wrapper { function attack(Coinflip c) external { c.flip(); // refused: Coinflip is not the transaction's target, Wrapper is } }

Proxies and libraries are fine. DELEGATECALL runs the callee’s code as the caller, so a UUPS proxy’s implementation, a Diamond facet and an external Solidity library all read as the proxy, which is the target. You do not need to do anything special to use randomness behind a proxy.

Every call between the transaction and the read must run as your contract. That covers your contract’s own code, code it reaches by DELEGATECALL, and calls it makes to itself, such as this.f() or a Multicall that delegatecalls its own contract. If your contract calls another contract and that contract calls back into yours, the read in the call back is refused, because the other contract’s call is between the transaction and the read.

2. The sender must be an ordinary account with no code. This refuses every EIP-7702 delegated account as the transaction’s sender, a self-sponsored one included, because its delegated code would run as the target and could abort on the outcome. A user whose wallet account carries a 7702 delegation receives OriginHasCode on a read and has to send from an account without one.

3. The transaction must be one that may read. Ordinary user transactions, an automation task’s action, an automation task’s predicate, and simulated calls (eth_call, eth_estimateGas) all may. Only the chain’s own internal transactions may not, which you cannot send.

4. The chain must have randomness. It is governed by the Move SUPRA_DKG feature, and the precompile refuses until the first threshold-signed block. Blocks whose seed is the biasable fallback are refused too.

Two further requirements concern how the precompile is called:

  • Call next() with CALL or STATICCALL. A DELEGATECALL to the precompile is decided on the calling frame’s msg.sender instead of its context, so the read is served from a self-call frame and refused from the root frame. LibRandomness and ISupraRandomness(...).next() use CALL.
  • Do not send ETH with a call to 0x53555003. Value sent with a call stays at the precompile’s address, which no key controls, and cannot be recovered.

An undone read costs the whole gas limit

If a value your transaction was served is undone, the sender is charged the transaction’s entire gas limit, not the gas it used, even if the transaction itself succeeds.

A read is undone when the call it was made in reverts or halts (any failure, running out of gas included), or when any call between that call and the transaction does, which includes the whole transaction failing. Where the failure is caught does not matter. A batch entry point that delegatecalls your own contract and tolerates a failing entry — a tryMulticall, a Multicall3-style allowFailure, a try this.f(data) dispatcher — catches the failure and succeeds, but the read inside the failed entry was undone, so the transaction is charged its whole gas limit.

A read stands, and the transaction pays only the gas it used, when every call from the read back to the transaction returns successfully. A call your contract makes after the read that reverts, and whose failure your contract catches, does not undo the read: it is not charged. That holds unless the call was itself served a value, which a self-call or a DELEGATECALL that reads can be; its revert is then charged the whole gas limit.

Your contract can always revert after seeing the outcome — nothing can prevent that — so instead the abort is made to cost what the sender was willing to spend, wherever it is caught. That removes the cheap retry which would otherwise let someone grind a favourable result.

Practical consequences:

  • Set a gas limit you are content to lose on a randomness transaction whose read may be undone.
  • eth_call, eth_estimateGas and debug_traceCall do not apply the charge. An estimate is the gas the transaction needs to execute, and a simulated call that undoes a read reports the gas it used. On chain, the same transaction costs the gas limit you set when it undoes a read.
  • An entry point that catches the failure of a call that reads, such as a failure-tolerant batch, charges its sender the whole gas limit whenever that call fails after a read.
  • A sender who can choose the other entries of a batch can make the batch revert on an outcome they dislike and pay only the gas limit for it. See Do not reach a reading function from a batch the sender chooses.

A transaction refused by one of the rules above is not charged the full limit: it was served nothing, so there is nothing to abort on. seedAt never arms the charge.

How a receipt shows the charge

A successful transaction charged for an undone read has one more log than its contracts emitted, as its last log, emitted by 0x53555003:

event UndoneReadCharged(uint256 gasCharged, uint256 gasUsedBeforeCharge);

gasCharged is the transaction’s gas limit, which is also the receipt’s gasUsed. gasUsedBeforeCharge is the gas the transaction used, after the refund and the EIP-7623 floor are applied, before the charge replaced it, and is below gasCharged. Neither field is indexed, so the log’s only topic is keccak256("UndoneReadCharged(uint256,uint256)"), 0x21dfb5bae0bd961c3d1ce3462f25e42b47ed4d06d334c1ef450783c69d0202b1. The log is in the receipt’s and the block’s logsBloom, and eth_getLogs and log subscriptions return it like any other log. A transaction that is not charged has no such log, and neither has a simulation, which does not apply the charge. A successful transaction that had already used its whole gas limit has no such log either, because the charge did not raise its cost.

A failed transaction carries no logs. A transaction that reverted or halted after undoing a read shows status 0 and a gasUsed equal to its gas limit, and its receipt does not otherwise mark the charge.

Automation tasks are the exception

The charge does not reach a task’s predicate, which the chain executes free of charge. What closes the gap instead:

  • A predicate that reads and then reverts pays no gas, but its task is removed from the registry, so it cannot be repeated.
  • A predicate that reads and returns false pays nothing and keeps its task. What stops it gaming its own action is not gas: a predicate’s reads are derived from different inputs than its action’s, although the two share a transaction hash, so the predicate and the action are served different values, and the predicate cannot compute the action’s — it holds a hash of the seed, never the seed. So a predicate learns nothing about what its action will be given.

That protection is specific to next(). See seedAt is public for what a task that reads a seed can do instead.

A callee can revert your transaction

The EVM has dynamic dispatch, so any address your contract calls is code that can revert you:

After reading a value, any external call you make can revert your transaction — undoing the outcome — unless you catch its failure.

An undone read costs the sender the whole gas limit, so each such abort costs what the sender was willing to spend; it is not prevented.

All of these are calls into an address the player may control:

  • an ERC-20 transfer to a token with a transfer hook
  • a plain SUPRA transfer to msg.sender, which runs their receive()
  • safeTransferFrom on an ERC-721, which calls onERC721Received
  • an ERC-1155 acceptance check
  • any callback, router or “notify” hook

The raffle examples below use the LibRandomness import of the quick start and this token interface:

interface IPrizeToken { function transfer(address to, uint256 amount) external returns (bool); }

The mistake:

contract UnsafeRaffle { IPrizeToken public immutable prize; // UNSAFE. `transfer` is a high-level call, so a reverting token — or a reverting hook on a // token that has one — bubbles up and reverts this whole transaction, undoing the play. // A player who controls that callee aborts whenever they lose. function play() external { bool won = uint256(LibRandomness.value()) % 100 < 5; if (won) { prize.transfer(msg.sender, 1e18); } } }

Persisting first does not fix it

Writing the outcome to storage before the call does not protect it. A revert unwinds the entire frame, storage writes included — ordinary EVM semantics, not a Supra rule. Persist, then make an uncaught call that reverts, and the storage write goes with it.

Two things that do work

Record the outcome and let the winner claim it in another transaction. Nothing in the reading transaction can be undone by a callee, because it calls nobody.

contract SafeRaffle { IPrizeToken public immutable prize; mapping(address => uint256) public owed; function play() external { bool won = uint256(LibRandomness.value()) % 100 < 5; if (won) { owed[msg.sender] += 1e18; } } function claim() external { uint256 amount = owed[msg.sender]; owed[msg.sender] = 0; prize.transfer(msg.sender, amount); } }

Or pay inline, but catch the failure. A low-level call returns a boolean instead of bubbling, so a reverting callee cannot revert your frame — it can only decline its own prize.

contract SafeRafflePayingInline { IPrizeToken public immutable prize; mapping(address => uint256) public unpaid; function play() external { bool won = uint256(LibRandomness.value()) % 100 < 5; if (!won) { return; } (bool ok, ) = address(prize).call(abi.encodeCall(IPrizeToken.transfer, (msg.sender, 1e18))); if (!ok) { unpaid[msg.sender] += 1e18; } } }

try/catch around a high-level call works the same way. The rule: after a read, no external call may be able to revert you.

Recording in one transaction and settling in a later one closes a callee revert and the gas limit only when the settlement cannot run in the same transaction as the read, after it. A batch that includes both play() and claim() runs the settlement after the read, so the settlement’s revert undoes the read. See Do not reach a reading function from a batch the sender chooses.

Outcome-dependent gas lets the sender choose

The second way to undo a read needs no external call, so a contract that never calls out is exposed to it too. The sender chooses the transaction’s gas limit. Running out of gas unwinds the frame exactly as a revert does, so a read followed by exhaustion is undone.

If your contract does more work on one outcome than another, a player can set a gas limit that completes the outcome they want and exhausts the one they do not:

contract UndergassableRaffle { mapping(address => uint256) public losses; mapping(address => uint256) public wins; // UNSAFE. Losing writes three slots and emits an event; winning writes one slot. A player who // sets the gas limit to just what winning costs runs out of gas on every loss, and the loss is // undone with the rest of the frame. function play() external { bool won = uint256(LibRandomness.value()) % 100 < 5; if (won) { wins[msg.sender] += 1; } else { losses[msg.sender] += 1; totalLosses += 1; lastLoser = msg.sender; emit Lost(msg.sender); } } uint256 public totalLosses; address public lastLoser; event Lost(address player); }

The full-gas charge still applies to the exhausted attempt, but it charges the limit the player chose, and they chose it to be exactly what winning costs. Each attempt costs them one honest winning run’s worth of gas, they never pay for a loss, and each resubmission is served a fresh value. Repeating this skews the results they complete towards the outcomes that fit the limit.

The attack works in one direction only: a gas limit can select for cheap outcomes over expensive ones, never the reverse. You are exposed only when an outcome the player dislikes costs more gas than one they like.

The rule

After a read, the gas your contract spends must not depend on the value in a way that makes the outcome a player would reject the more expensive one. Making the gas independent of the value is sufficient, and so is checking the gas left before the read.

Both safe patterns from the previous section satisfy it. SafeRaffle writes one slot on a win and nothing on a loss, so the expensive branch is the one the player wants to complete, and a limit cannot be used against it; SafeRafflePayingInline is the same. If you must do outcome-dependent work in the reading transaction, give the loss-side bookkeeping the same shape as the win side, or move it to the later transaction along with the payout.

Checking the gas left before the read

When the gas after a read cannot be made independent of the value, check before the read that enough gas remains for the most expensive outcome:

contract GuardedRaffle { // The gas at which the losing branch of play() succeeds when started at the read, measured, // with a margin. uint256 constant GAS_AFTER_READ = 80_000; mapping(address => uint256) public losses; mapping(address => uint256) public wins; uint256 public totalLosses; address public lastLoser; event Lost(address player); function play() external { require(gasleft() >= GAS_AFTER_READ, "gas limit too low"); bool won = uint256(LibRandomness.value()) % 100 < 5; if (won) { wins[msg.sender] += 1; } else { losses[msg.sender] += 1; totalLosses += 1; lastLoser = msg.sender; emit Lost(msg.sender); } } }

This is UndergassableRaffle with one added line. When the check passes, every outcome can complete, so no gas limit selects between them. A player who sets a lower limit is refused before the read and is served nothing.

  • Check once, in the function that reads, immediately before its first read. A contract that reads more than once must not check again before a later read: the work between reads can depend on the earlier values, and a check there would revert on exactly those values.
  • GAS_AFTER_READ is the gas at which the most expensive outcome succeeds when it is started at the read, which is more than the gas that outcome uses. Under EIP-150 each call keeps back 1/64 of the gas available to it, so the figure must cover that 1/64 at every call level on the outcome’s path, and gas refunds do not reduce it. An outcome started with less than this figure can run out of gas after the read while a cheaper outcome completes.
  • Measure GAS_AFTER_READ by finding the lowest gas limit at which the most expensive outcome completes, from the first read to the end of the function, including every read, the calls to the precompile and any calls made after the reads. Add a margin, and measure again after a gas repricing.
  • gasleft() is the reading function’s own remaining gas, and the check covers that function’s work only. The read is served to code running in the transaction target’s context, which includes a proxy’s implementation, a DELEGATECALL library, a self-call, and a batching function such as a Multicall that delegatecalls its own contract. Behind a batch, a self-call or a DELEGATECALL the reading function’s gasleft() is a share of the transaction’s gas, not the transaction’s gas, and work that runs after it returns, such as the later calls in a batch the sender assembled, can still run out of gas or revert.

Do not reach a reading function from a batch the sender chooses

A reading function must not be reachable from a batch whose other entries the sender chooses. This covers a strict self-delegating multicall as well as a failure-tolerant one. Every frame of such a batch runs in the transaction’s root context, so the read is served, and the sender can add an entry that reverts on an outcome they dislike. The batch then reverts as a whole, the read is undone, and the sender pays the gas limit for that attempt and nothing else. Outcomes the sender dislikes are never committed, and the cost of each attempt is bounded by the limit they set.

Exclude a function that reads from the selectors any multicall the contract exposes will forward.

Automation tasks are not a special case here: a task’s gas limit is chosen by whoever registered it.

Commit then settle

seedAt(height) gives you a past block’s seed. It serves [block.number - 256, block.number - 1] — the same window as BLOCKHASH — and carries none of the rules above, because a committed block’s seed is public and there is nothing left to bias.

The current block is deliberately not served. Its seed is what the rules protect. Use next() for the current block.

When to use it

Settlement against seedAt has to happen inside the window, so whoever stands to gain from a settlement has to send it within 256 blocks of the height whose seed it uses.

For a bet between one player and the contract, use next() and record the outcome instead, as SafeRaffle does in Two things that do work. The outcome is fixed in the transaction that reads, and the claim that pays it has no deadline.

seedAt suits a draw that many participants share, such as a lottery round. Every entry is made before the seed exists, one settlement draws the round for everyone, and anyone can send it, including a keeper or an automation task registered by an account that has not entered. A draw that misses the window moves to a later block.

contract RoundDraw { uint64 public immutable roundLength; uint256 public round; uint64 public drawHeight; mapping(uint256 => address[]) public entrants; mapping(uint256 => address) public winner; constructor(uint64 length) { roundLength = length; drawHeight = uint64(block.number) + length; } /// Entries close before the block whose seed draws the round exists. function enter() external { require(block.number < drawHeight, "round closed"); entrants[round].push(msg.sender); } /// Anyone may draw the round. function draw() external { require(block.number > drawHeight, "seed not available yet"); if (block.number > drawHeight + LibRandomness.SEED_WINDOW) { // The seed has left the window: draw against a block that does not exist yet. drawHeight = uint64(block.number) + 1; return; } address[] storage list = entrants[round]; if (list.length > 0) { bytes32 seed = LibRandomness.seedAt(drawHeight); winner[round] = list[uint256(keccak256(abi.encodePacked(seed, round))) % list.length]; } round += 1; drawHeight = uint64(block.number) + roundLength; } }

The outcome depends only on the seed at drawHeight, so it does not depend on who draws or when within the window. An entrant who has computed that they lost cannot stop the winner drawing. If nobody draws within the window, draw moves the round to a later block instead of cancelling it, so letting the window pass does not return anyone’s entry. The winner, who loses the round if it moves, can always draw in time. If seedAt refuses a height inside the window, for example with SeedBiasable, draw reverts until the window has passed, and the round then moves to a later block.

seedAt is public

This hazard is not about reverting. Anyone can read any served height with eth_call, at any time, for free, without sending a transaction. From the moment block h is committed, everybody can compute whatever your contract would derive from seedAt(h). None of next()’s rules apply here, and none would help — there is no secret to protect.

Two rules follow, and neither depends on what kind of transaction is involved:

1. Commit before the seed exists. Settling against a height that already existed when the commitment was made is broken outright: the committer knew the outcome as they committed. In the example above, enter refuses from drawHeight onwards, so every entry is made before the block whose seed draws it exists.

2. Settlement must not be withholdable. If the only party who can settle is one that a particular outcome is bad for, they settle when they win and never when they lose. This needs no revert, no gas and no contract — the simplest version is reading the seed with eth_call and not sending the losing transaction. Anyone must be able to settle, as anyone can call draw above, and the design must not need the loser’s cooperation. Decide too what happens when nobody settles within the window, and make sure the party that outcome hurts can always settle in time: in the example, a round that moves to a later block loses its winner, who can call draw. An automation task owned by a participant is one instance of this rule: its predicate can decline to run its action, at no cost, indefinitely.

next() does not have this problem. A next() value cannot be known by anyone before its transaction is ordered, which is what the rules around it protect. Once the block commits, anyone can compute the value: the seed is published with the block’s commit certificate and the transaction hash is public. Do not use a next() value as a secret, such as hidden cards, a sealed draw, a salt or a key.

Two further constraints:

  • Settle within 256 blocks. Outside the window the seed is gone. isServable(height) lets you check before spending gas on a revert.
  • Do not settle against the block you committed in. Use a later one, or the committer could have known the seed.

When a call is refused

Each refusal reverts with its own four-byte custom error, declared on ISupraRandomness. They are never zero bytes, which would be indistinguishable from a value of zero.

ErrorWhat happenedWhat to do
CallerNotTransactionRootyour contract was reached from another contract, or another contract’s call is between the transaction and the readsend the transaction to your contract directly, and do not read in a call back from another contract
OriginHasCodethe sender account has code, which includes every EIP-7702 delegated account, self-sponsored ones toosend from an ordinary account without a delegation
SeedUnavailablethe chain has no seed, or seedAt names a height this node has no block forwait; check the height
SeedBiasablethat block’s seed is the fallback and cannot be trustedtry a later block
HeightOutOfWindowseedAt outside [number - 256, number - 1]settle sooner; use isServable
ExecutionModeNotPermittedone of the chain’s internal transactions tried to readnot reachable from user code
UnknownSelector / MalformedInputthe call data does not match the ABIuse the interface or library

If randomness is optional to your flow, call the precompile with a low-level call and inspect the selector rather than reverting:

(bool ok, bytes memory ret) = address(LibRandomness.RANDOMNESS).call(abi.encodeCall(ISupraRandomness.next, ())); if (!ok) { // `ret` holds the four-byte selector; compare against the errors on ISupraRandomness. }

Cost

next() costs 1,000 gas and seedAt() 2,500, on top of the calling overhead. Both are protocol constants.

The cost that dominates is the full-gas charge on an undone read, not these figures.

Testing your contract

  • eth_call and eth_estimateGas can dry-run a randomness-using contract, to check that the call is admitted and to estimate its gas. The value a simulation reads is not the value the submitted transaction is served.
  • A simulation runs at the head. The exception is an eth_estimateGas at a past height on the wallet-integration endpoint, which runs in that block’s environment, so an estimate at a block from before randomness was enabled is refused as execution would refuse it. /rpc/v1/eth refuses a past height; see The wallet-integration endpoint.
  • Simulation does not apply the full-gas charge. An estimate is the gas the transaction needs to execute; when the transaction undoes a read on chain, it costs the gas limit you set.
  • ISupraRandomness.sol and LibRandomness.sol in solidity/supra_contracts — the interface and helpers, with the same rules stated per function.
Last updated on