Skip to Content
Supra Layer 1SupraMVMMove Native Randomness

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 certificate carries one, which requires the committee that certified the block to hold 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 certificate carries no threshold signature. 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 draws from either seed, and no Move function reports which one the current block has. The EVM precompile refuses the block-hash seed instead. Mainnet and 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.

Building and running it

The commands below create a package named dice whose example address is the account that publishes it. <PROFILE> is a funded CLI profile, <ACCOUNT> is its address and <NETWORK> is mainnet, testnet or devnet, the network the package targets. Save the module above as dice/sources/dice.move after the first command:

supra move tool init --name dice --package-dir dice --framework-network <NETWORK> \ --named-addresses example=<ACCOUNT> --assume-yes supra move tool publish --package-dir dice --profile <PROFILE> --accept-defaults supra move tool run --function-id <ACCOUNT>::dice::roll --profile <PROFILE> --accept-defaults supra move tool view --function-id <ACCOUNT>::dice::last_roll --args address:<ACCOUNT>

--framework-network makes init write a dependency on the branch named <NETWORK> of https://github.com/Entropy-Foundation/supra-framework.git, which holds the framework deployed on that network. CLIs before v12.0.0, including the supra_node_v11.7.1 CLI that Mainnet and Testnet run, have no such option and write a dependency on the dev branch of Entropy-Foundation/aptos-core, which is out of sync with the Mainnet and Testnet frameworks. After init, replace the [dependencies.SupraFramework] table in dice/Move.toml with:

[dependencies.SupraFramework] git = "https://github.com/Entropy-Foundation/supra-framework.git" rev = "<NETWORK>" subdir = "supra-framework"

The roll transaction emits a RandomnessGeneratedEvent, and last_roll returns the roll as a one-element array holding a string, for example ["4"]. The network must have a seed, as When a draw aborts describes.

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).

FunctionResult
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.

FunctionPurpose
initialize(framework)creates PerBlockRandomness; restricted to the Supra framework signer, and called by the genesis of a network generated in testnet mode
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’s session hash, and a transaction-local counter. The session hash is the hash of the transaction’s sender, sequence number and script hash, the last of which is empty for an entry function; it is not the hash of the signed transaction. 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. A private one compiles as it is, and a public one 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 six 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. Codes 2, 3 and 4 occur only on a network at gas feature version 26 or later; see Minimum gas.

AbortWhat happenedWhat to do
E_API_USE_IS_BIASIBLE, code 1 in supra_framework::randomnessthe transaction is not marked unbiasablesubmit a private or friend #[randomness] entry function as the payload; see The transaction boundary
VM status MISSING_DATAPerBlockRandomness has not been published on this networkMainnet and Testnet publish it; on a local network, generate genesis in testnet mode
EOPTION_NOT_SET, code 0x40001 in std::optionthe network has no seed for this block, because SUPRA_DKG is disabledenable SUPRA_DKG on the network; it has no randomness until then
code 3 in supra_framework::randomnessthe entry function sets #[randomness(min_remaining_gas = N)], and a fungible asset’s dispatch function requested a draw before the entry function’s first drawwithdraw, deposit or read the balance of that asset in a separate transaction; drawing first instead lets a sender who created the asset abort after the draw, see Outcome-dependent aborts
code 2 in supra_framework::randomnessthis was the transaction’s first draw, and less gas remained from the transaction’s max_gas_amount 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 alone as the limit; see Minimum gas
code 4 in supra_framework::randomnessthis was the transaction’s first draw, and less gas remained under the per-transaction execution or IO limit than the entry function’s min_remaining_execution_gassend arguments that make the work before the first draw smaller; raising max_gas_amount does not help; see Minimum gas
VM status ARITHMETIC_ERRORa range function received an empty or reversed rangepass min_incl < max_excl

bytes(0), permutation(0), and permutation(1) make no draw, so they succeed without checking the first six 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.

Before the first draw, code the sender can influence matters in one respect. When the entry function sets min_remaining_gas, a draw requested by a fungible asset’s dispatch function before the entry function’s own first draw aborts the transaction with code 3 (see Minimum gas). An entry function that transfers a fungible asset the sender chose before its first draw can therefore be made to abort with code 3, with no draw served. Moving the transfer after the draw is not a fix, because a transfer after the draw can abort and undo the outcome, as above. Such an entry function withdraws, deposits or reads the balance of a fungible asset the sender chose in a separate transaction: for example, a deposit into the module’s escrow before the transaction that draws, and a claim after it.

An escrow protects the outcome only if nothing but the module can move what it holds. The creator of a fungible asset can hold a TransferRef, which withdraws from and freezes any store of that asset, the escrow’s included, so a stake in an asset the sender created can be taken out of the escrow or frozen after the outcome is recorded, and the winner’s claim then aborts. Accept stakes only in assets whose creator you trust, such as SUPRA.

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. The design holds when:

  • the first transaction draws once for each stake and refuses a second draw for a stake that already has an outcome, so the sender cannot draw again until the outcome suits them;
  • the stake is held where only the module can move it, as above;
  • the second transaction can be sent by the party the outcome pays, or by anyone, so the party it does not pay cannot withhold 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 it uses a new sequence number and executes against a later block’s seed, 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.

On a network below gas feature version 26, the minimum in Minimum gas is not checked. These designs, and the max_gas_amount() check described there, are then the protection against outcome-dependent gas.

Minimum gas

From gas feature version 26, an entry function annotated #[randomness(min_remaining_gas = N)] requires N gas to remain from the transaction’s max_gas_amount 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.

Not every network runs gas feature version 26 yet; the command below reads a network’s version. On a network below version 26, a module that carries the attribute can be published, and it runs as if it declared no minimum: codes 2, 3 and 4 do not occur. Write a module for such a network as Outcome-dependent gas describes, and add the attribute as well. The minimum then takes effect when the network reaches version 26, without publishing the module again.

A network’s gas feature version is the feature_version field of the 0x1::gas_schedule::GasScheduleV2 resource. For Mainnet:

curl -s https://rpc-mainnet.supra.com/rpc/v3/accounts/0x1/resources/0x1::gas_schedule::GasScheduleV2 \ | jq -r .data.feature_version

For Testnet and Devnet, use https://rpc-testnet.supra.com and https://rpc-devnet.supra.com.

The attribute can add a second minimum, min_remaining_execution_gas = M:

#[randomness(min_remaining_gas = 3000, min_remaining_execution_gas = 200)] entry fun play(player: &signer, picks: vector<u8>) { // ... }

M requires M gas to remain under each of two per-transaction limits at the first draw: the execution limit (max_execution_gas in the gas schedule) and the IO limit (max_io_gas). A first draw requested with less under either aborts with code 4. These limits do not depend on the transaction’s max_gas_amount, so raising it does not change the result; the transaction has to spend less before its first draw. M must not exceed N. Without M, neither limit is checked at the draw.

The minimums are checked at a draw that the entry function’s own code requests. A draw requested by a fungible asset’s dispatch function, the code that the asset’s creator registers to run on withdraw, deposit and balance reads, before the first draw aborts with code 3. A dispatch function’s draws after the first draw are served as any later draw is. A function the entry function calls by name, such as a library function that draws, is part of the entry function’s own code: its draw is the first draw when it comes first.

Gas spent before the first draw comes out of the sender’s limit and not out of N, so a transaction 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 no property, max_gas alone, or min_remaining_gas with or without min_remaining_execution_gas. #[randomness(max_gas = N)] is accepted for compatibility with modules written for Aptos, and is not enforced. Building a package that uses min_remaining_gas or min_remaining_execution_gas needs a Supra CLI whose compiler records them, which is the CLI of a release after supra_node_v11.7.0. An older compiler either rejects them, or compiles the module without recording the #[randomness] annotation at all, and every draw in such a module then aborts with code 1. The supra_node_v11.7.0 CLI rejects them: min_remaining_gas alone fails with Unknown key for randomness attribute: min_remaining_gas, and both properties together fail with Randomness attribute should only have one `max_gas` property. Run the module’s unit tests with the same CLI, or call it once on a test network, before relying on a build.

What the minimums cover

The check runs once, at the first draw. It compares N with the gas remaining from the max_gas_amount at that moment, and M with the gas remaining under the execution and IO limits. It covers what is charged against those budgets after that moment. It does not cover charges deferred from work done before the draw, or limits that are not measured in gas.

Each rule below applies only to work that can vary with the transaction’s arguments or with state the sender controls. An entry function whose work before its first draw is fixed needs no M and no additions for that work.

N must cover:

  • everything the transaction does after the first draw, at the largest input the entry function accepts, including later draws and the work between them. This includes work that does not depend on the drawn value: N is checked once, so work after the draw that the sender can enlarge must fit inside N at its maximum;
  • the IO gas and storage fees of the writes made after the draw. They are charged after the entry function returns, from the same remaining gas;
  • the IO gas and storage fees of the writes made before the first draw. They are also charged after the entry function returns, so the check at the draw does not see them. Bound the writes made before the draw and add their maximum fees to N, or move them after the draw.

Measure storage fees 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.

M must cover the execution and IO gas of the same work after the draw, at the largest input, and the IO gas of the writes made before the first draw, which counts against the IO limit after the entry function returns. Set M when the execution or IO gas spent before the first draw depends on the transaction’s arguments, for example a loop over a vector argument or up to a count argument. Without M, the arguments can leave less gas under the execution limit than the most expensive outcome needs, and that outcome then fails after the draw with EXECUTION_LIMIT_REACHED.

Neither minimum covers:

  • the memory quota. The VM limits the memory that a transaction’s values occupy. A value counts until it is popped or the function holding it returns, not after its last use, so a vector argument held by the entry function counts until the entry function returns. Before the first draw, drop values whose size the sender chooses, with let _ = v; or by passing them by value to a function that consumes them, or bound their length;
  • the dependency limits. The modules of the entry function and its type arguments are counted before it runs. A withdrawal or deposit of a fungible asset the sender chose loads that asset’s dispatch module when it runs, and the module counts against the dependency limits for the rest of the transaction;
  • the IO gas for the transaction’s size and for the events emitted before the draw. Both are charged after the entry function returns. The transaction’s size grows with its arguments, used or not, so give a randomness entry function only arguments of fixed size or bound the length of each, and include in N the IO gas of the largest transaction it accepts;
  • the write limits. The VM refuses a transaction whose writes or events exceed the limits on the number of writes, the bytes of one write, the bytes of all writes, the bytes of events, or the storage fee, with STORAGE_WRITE_LIMIT_REACHED. These are checked on the transaction’s whole output after it runs and are not measured in gas, so give every outcome writes and events of the same number and size.

When N and M cover every outcome, the sender cannot choose a gas limit or arguments that complete one outcome and exhaust the gas of another. Outcomes whose writes and events differ in number or size are not covered by N and M, as the last item above describes.

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 reads N from the entry function’s module and offers the simulated gas plus N as the transaction’s max_gas_amount, for an entry function payload and for a multisig payload that carries one. The simulated gas is at least the gas spent before the first draw, so that limit passes the first draw’s comparison with N. A client that sets the limit to the simulated gas alone sets it below N plus the gas before the first draw, and the draw aborts with code 2; with such a client, set the limit explicitly.

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 Mainnet, Testnet and Devnet:

use std::transaction_context; const MIN_GAS: u64 = 3000; const E_GAS_LIMIT_TOO_LOW: u64 = 1; // In the entry function, before its first draw: 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. Nor can it protect one that takes a vector or string argument, used or not, unless the function asserts a bound on that argument’s length before the check and MIN_GAS covers the largest transaction the bound allows: a longer argument raises both the gas spent before the check and the IO gas charged for the transaction’s size after the function returns.

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); }

Run it with supra move tool test --package-dir dice. 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. Save this script as draw.move:

script { use supra_framework::randomness; fun main(_account: &signer) { let _ = randomness::u8_integer(); } }

Run it with:

supra move tool run-script --script-path draw.move --framework-network <NETWORK> \ --profile <PROFILE> --accept-defaults

With a CLI before v12.0.0, replace --framework-network <NETWORK> with --framework-local-dir <DIR>, where <DIR> is the supra-framework directory of a clone made with git clone --branch <NETWORK> https://github.com/Entropy-Foundation/supra-framework.git.

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, because simulation 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.

Last updated on