Using Native Randomness from Move
Supra Move modules obtain synchronous randomness from supra_framework::randomness. The block
prologue writes a per-block seed into PerBlockRandomness.
Where the seed comes from
A seed exists only while the SUPRA_DKG feature is enabled. It is one of two values:
- The BLS threshold signature on the block’s quorum certificate, when the committee that certified the block holds DKG threshold keys. Computing the signature takes as many committee members as the certificate’s signing threshold, each member counting once regardless of stake. While fewer members than that collude, the seed can be neither predicted before the block is certified nor biased by validators, module developers, or users.
- The committed block hash, when the certifying committee holds no threshold keys. The hash is known to validators before the block commits and depends on contents the proposer chose, so it is predictable and biasable.
A committee holds threshold keys only for an epoch that follows an epoch in which SUPRA_DKG was
enabled, because the DKG runs at the end of an epoch to produce the next epoch’s keys, and it does
not run at the end of the genesis epoch. With SUPRA_DKG enabled, the block hash is therefore the
seed for exactly:
- the genesis epoch and the epoch after it, on a network whose genesis enables
SUPRA_DKG; - the first epoch after governance enables
SUPRA_DKG, including a re-enablement after it was disabled.
supra_framework::randomness serves draw from either seed, and no Move function reports which one
the current block has. Supra mainnet and Supra testnet both run with threshold-signature seeds, so
neither serves the block-hash seed. A local network generated with SUPRA_DKG enabled serves it for
its first two epochs, and a local network with SUPRA_DKG disabled has no seed at all (see
When a draw aborts).
The value is random, not secret. Transaction execution and its results are visible on chain. Do not use this API to create private keys, hidden card orders, or other secrets.
Quick start
This entry function stores a die roll in [1, 7). It is private and carries the #[randomness]
attribute required by the VM.
module example::dice {
use std::signer;
use supra_framework::randomness;
struct LastRoll has key {
value: u64,
}
#[randomness]
entry fun roll(account: &signer) acquires LastRoll {
let account_address = signer::address_of(account);
let value = randomness::u64_range(1, 7);
if (exists<LastRoll>(account_address)) {
borrow_global_mut<LastRoll>(account_address).value = value;
} else {
move_to(account, LastRoll { value });
};
}
#[view]
public fun last_roll(account: address): u64 acquires LastRoll {
borrow_global<LastRoll>(account).value
}
}Submit example::dice::roll as the transaction payload. A user’s Move script or a public entry
function cannot replace that transaction boundary.
Randomness API
The integer functions return values across the full range of their type. Every range uses an
inclusive lower bound and an exclusive upper bound: [min_incl, max_excl).
| Function | Result |
|---|---|
bytes(n) | n random bytes; n = 0 returns an empty vector |
u8_integer() | a random u8 |
u16_integer() | a random u16 |
u32_integer() | a random u32 |
u64_integer() | a random u64 |
u128_integer() | a random u128 |
u256_integer() | a random u256 |
u8_range(min_incl, max_excl) | a random u8 in [min_incl, max_excl) |
u16_range(min_incl, max_excl) | a random u16 in [min_incl, max_excl) |
u32_range(min_incl, max_excl) | a random u32 in [min_incl, max_excl) |
u64_range(min_incl, max_excl) | a random u64 in [min_incl, max_excl) |
u128_range(min_incl, max_excl) | a random u128 in [min_incl, max_excl) |
u256_range(min_incl, max_excl) | a random u256 in [min_incl, max_excl) |
permutation(n) | a random permutation of [0, 1, ..., n - 1]; n = 0 returns an empty vector |
The range functions require min_incl < max_excl. An empty or reversed range aborts during
arithmetic. Their modulo reduction has negligible bias rather than perfect uniformity. Applications
that require exact uniformity can use rejection sampling with an integer function.
bytes(n) draws in 32-byte chunks. u256_range draws twice, and permutation(n) draws repeatedly.
Their gas use therefore grows with the requested byte count or permutation size; bound values that
come from a transaction argument.
Other public functions
randomness.move also exposes the following public functions. They are not all application-facing
randomness wrappers.
| Function | Purpose |
|---|---|
initialize(framework) | creates PerBlockRandomness; restricted to the Supra framework signer and used by genesis |
initialize_for_testing(framework) | test-only initialization with a 32-byte zero seed |
set_seed(seed) | replaces the seed in Move unit tests; test-only and requires exactly 32 bytes |
u64_range_internal(min_incl, max_excl) | performs the same range calculation used by u64_range, but does not emit RandomnessGeneratedEvent |
Applications should call u64_range, not u64_range_internal, when they want the event behavior of
the other public randomness wrappers.
Each draw is fresh
The module derives each 32-byte draw with SHA3-256 over a domain separator, the current block seed,
the transaction hash, and a transaction-local counter. The native
fetch_and_increment_txn_counter advances the counter for every draw. Repeated calls in one
transaction therefore use different inputs, as do calls in different transactions in one block.
One public call can consume several draws, as bytes, u256_range, and permutation do. Freshness
applies to each draw rather than only to each public wrapper call.
The transaction boundary
The VM serves randomness to a transaction it has marked unbiasable. A user transaction is marked
when its payload is a private or friend entry function annotated with #[randomness], and the entry
function of an automated task is marked on the same condition. The entry function can call private
helpers; the check applies to the payload entry function at the outer transaction boundary. While
SUPRA_DKG is enabled, the VM also marks two system transactions: a governance-approved script and
the block-metadata transaction.
This requirement prevents a test-and-abort attack. Without it, another module could call a public randomness-dependent function, inspect the result, and abort whenever the result was unfavorable. Repeating until a transaction succeeds would change the distribution of committed outcomes.
The Move compiler enforces the same boundary when a package is built:
#[randomness]on a public entry function is an error;- a private or friend entry function that reaches a randomness API without
#[randomness]is an error; - a public function that reaches a randomness API is an error unless it carries
#[lint::allow_unsafe_randomness].
All randomness-dependent helpers must therefore remain private or friend. Suppressing the lint with
#[lint::allow_unsafe_randomness] lets another module place the helper behind its own annotated
private entry function, observe the result, and abort selectively.
The runtime check applies to the published bytecode whatever compiler produced it. Every underlying
random draw checks is_unbiasable(), and the first draw attempted by a transaction that is not
marked aborts in supra_framework::randomness with E_API_USE_IS_BIASIBLE, code 1. The shapes
that reach this abort are:
- a user’s Move script;
- a public entry function carrying
#[lint::allow_unsafe_randomness]; - a
#[view]function, which is public and so needs the same lint suppression to compile; - bytecode that was not built with the checks above.
When a draw aborts
Each draw checks the first four rows in order, and the first one that fails aborts the transaction. A range function computes its range before drawing, so a reversed range aborts before any of those checks, and an empty range aborts after its draw.
| Abort | What happened | What to do |
|---|---|---|
E_API_USE_IS_BIASIBLE, code 1 in supra_framework::randomness | the transaction is not marked unbiasable | submit a private or friend #[randomness] entry function as the payload; see The transaction boundary |
VM status MISSING_DATA | PerBlockRandomness has not been published on this network | Supra mainnet and Supra testnet publish it; on a local network, generate genesis in testnet mode |
EOPTION_NOT_SET, code 0x40001 in std::option | the network has no seed for this block, because SUPRA_DKG is disabled | enable SUPRA_DKG on the network; it has no randomness until then |
code 2 in supra_framework::randomness | this was the transaction’s first draw, and less gas remained than the entry function’s #[randomness(min_remaining_gas = N)] | raise the transaction’s max_gas_amount, and do not use a simulation’s gas estimate as the limit; see Minimum gas |
VM status ARITHMETIC_ERROR | a range function received an empty or reversed range | pass min_incl < max_excl |
bytes(0), permutation(0), and permutation(1) make no draw, so they succeed without checking
the first four rows.
Outcome-dependent aborts
The private-entry rule prevents an external wrapper from testing a result and aborting. The module that reads the value can still abort after the read. Its code must not make an unfavorable result selectively reversible.
After a draw, any call into code the sender can influence can abort the transaction and undo the outcome. In Move this includes:
- a transfer of a fungible asset the sender created, whose dispatch functions run on withdraw and deposit;
- a call that is generic over a type the sender chose in the transaction’s type arguments, or that operates on an object the sender passed in;
- a payout to the sender that can fail because of state the sender controls, such as a store that is frozen.
Writing the outcome to storage before such a call does not protect it. An abort unwinds the whole transaction, storage writes included.
Keep any operation that can abort independently of the random value before the draw. After the draw, avoid calls and assertions whose failure depends on the outcome. A two-transaction design records the result in the first transaction and applies it in a later one, after the result has committed. The first transaction must store the drawn value itself: Move has no function that returns a past block’s seed, so a later transaction cannot recompute it.
Outcome-dependent gas
The sender chooses the transaction’s gas limit, and running out of gas aborts the transaction and rolls back the draw exactly as an explicit abort does. If the work after a draw costs more on an outcome the sender would reject, the sender can choose a limit that completes the outcomes they want and exhausts the others. Every resubmission is served a fresh value, because the transaction hash feeds each draw, so repeated attempts skew the outcomes that commit.
Make the gas consumed after a draw independent of the result. A fixed-shape write of the result, followed by a separate settlement transaction, removes outcome-dependent work from the randomness transaction. If the paths cannot be equal, an unfavorable outcome must not cost more than the favorable outcome a sender is trying to retain. This inequality depends on the gas schedule and is less durable than equal paths. Restricting the randomness entry function to a trusted signer also removes an untrusted sender’s control over retries and gas limits.
Minimum gas
From gas feature version 26, an entry function annotated #[randomness(min_remaining_gas = N)]
requires N gas to remain when the transaction’s first draw is requested. A first draw requested
with less aborts with code 2, before any value is produced. Later draws in the same transaction
are not checked: a check there would be an abort that depends on the earlier results, which is
what the minimum exists to prevent.
The check measures the gas actually left at the first draw, so gas spent before it, including work
that depends on arguments the sender chooses, comes out of the sender’s limit and not out of N.
A transaction therefore needs a max_gas_amount of at least N plus the gas it spends before its
first draw. A sender who declares less pays for the gas used up to the aborted draw.
An entry function annotated #[randomness] without min_remaining_gas has no minimum. The
attribute accepts one property. #[randomness(max_gas = N)] is also accepted, for compatibility
with modules written for Aptos, and is not enforced. Building a package that uses
min_remaining_gas needs a Supra CLI whose compiler accepts it; an older compiler rejects it as an
unknown key.
Choose N to cover the most expensive outcome from the first draw to the end of the transaction,
including any later draws and the work between them:
- Include the storage fees of that outcome’s writes. They are charged after the entry function returns, from the same remaining gas.
- Measure at the network’s minimum gas unit price. Storage fees are fixed amounts of SUPRA, converted to gas units by dividing by the transaction’s gas unit price, so the same write costs more gas units at a lower price.
When N covers every outcome, no gas limit the sender chooses can complete one outcome and exhaust
another.
A simulation’s gas estimate covers only the outcome the simulation happened to draw, and does not
include N. Without --max-gas, the Supra CLI offers that estimate as the transaction’s
max_gas_amount, which is below N plus the gas before the first draw, so the draw aborts with
code 2. Pass --max-gas explicitly for these entry functions.
Before gas feature version 26, an entry function can check its own limit with
transaction_context::max_gas_amount(), which requires the TRANSACTION_CONTEXT_EXTENSION
feature, enabled on Supra mainnet:
assert!(transaction_context::max_gas_amount() >= MIN_GAS, E_GAS_LIMIT_TOO_LOW);This checks the transaction’s limit, not the gas remaining at the draw, so MIN_GAS must also
cover everything the transaction spends before the draw. It cannot protect an entry function whose
work before the draw depends on arguments the sender chooses.
Automated tasks
An automated task whose entry function is a private or friend #[randomness] function draws
randomness each time it runs. The registrant chooses the task’s max_gas_amount at registration,
so the rule in Outcome-dependent gas applies to that limit in the same
way as to a user transaction’s gas limit.
A minimum gas amount applies to tasks as well. A registration transaction whose task
max_gas_amount is below its entry function’s min_remaining_gas is discarded and pays nothing. A
multisig registration below it is not registered, but the executing owner’s transaction is kept and
charged, the proposal is consumed and has to be proposed again with a larger max_gas_amount, and
status 39 (REQUIRED_DEPOSIT_INCONSISTENT_WITH_TXN_MAX_GAS) is the error_code of the multisig
account’s execution-failure event. The comparison is a lower bound: each run also spends intrinsic
and dependency gas before its first draw, so register the task with N plus that gas. Each run is
then subject to the first-draw check. Registration from Move code is not checked against the
minimum, so a task registered that way below the minimum aborts with code 2 on every run that
draws.
Cost
Each 32-byte draw computes one SHA3-256 over the domain separator, the seed, the transaction hash
and the counter, about 150 bytes with a threshold-signature seed. At the current gas schedule that
hash costs about 0.04 gas units (a base of 14,704 internal gas plus 165 per byte, at 1,000,000
internal gas per gas unit). Each completed wrapper call also emits one RandomnessGeneratedEvent,
with a base cost of 20,006 internal gas, and runs the wrapper’s own Move instructions. The gas
that matters is the work your code does after a draw, and bytes(n), u256_range, and
permutation(n) multiply the draw count as described in Randomness API.
RandomnessGeneratedEvent
Each successful call to bytes, a public integer wrapper, a public range wrapper, or permutation
emits one zero-field RandomnessGeneratedEvent. This includes bytes(0) and permutation(0),
which emit despite consuming no underlying draw. The event records that one of those wrappers
completed. It does not identify the function, arguments, returned value, or number of underlying
32-byte draws. Like the rest of a transaction’s effects, the event is removed if the transaction
aborts.
A direct call to the public u64_range_internal function produces randomness without emitting the
event. An indexer cannot treat the event count as a complete count of every possible direct call to
the module’s public functions.
Unit tests
Move unit tests can initialize the resource and choose a deterministic seed with the test-only
functions. The seed passed to set_seed must contain 32 bytes. Add the following test to the
example::dice module from the quick start:
#[test(account = @0xcafe, fx = @supra_framework)]
fun test_roll(account: signer, fx: signer) acquires LastRoll {
randomness::initialize_for_testing(&fx);
randomness::set_seed(
x"0101010101010101010101010101010101010101010101010101010101010101"
);
roll(&account);
let value = last_roll(signer::address_of(&account));
assert!(1 <= value && value < 7, 0);
}Move unit tests treat randomness calls as unbiasable, so this test exercises the module logic and
deterministic seed. It does not exercise transaction-payload admission. Exercise the annotated
entry function end to end by publishing the module to a local Supra network whose genesis enables
SUPRA_DKG, and submitting example::dice::roll directly as the transaction payload. On a network
without a seed every draw aborts; see When a draw aborts.
To check the runtime refusal, submit a user script that calls a randomness API; it aborts with code
1. A public or unannotated entry function that reaches randomness does not compile, so it cannot
be submitted without suppressing the checks described in
The transaction boundary.
Simulation
Simulating a #[randomness] transaction runs the same admission check as execution, so it is admitted
or refused with code 1 as the real transaction would be. The value it draws is not the value the
submitted transaction receives: simulation takes a transaction without a valid signature, and every
draw depends on the transaction hash, which changes once the transaction is signed. Simulation also
reads the seed of the latest committed block, not the block that will include the transaction.
The gas a simulation reports therefore covers only the outcome its draw happened to produce. Set the gas limit for the most expensive outcome rather than from a single estimate.