A blockchain is a machine that cannot surprise itself. Every node must reach the same state from the same inputs. That property is exactly what makes onchain randomness hard: nothing inside a deterministic system can produce an output that isn't, in principle, predictable.
This is the second milestone in our Stylus production series. Part 1 asked how to coordinate multiple liquidity sources under real market conditions. This time the question is different: how do you build an onchain application where the outcome must be provably random, verifiable by anyone, and fully determined by infrastructure the user doesn't have to trust?
Watch the pattern, not the product
The surface is a game of chance. The engineering problem is an asynchronous oracle that must not break solvency, strand funds, or let anyone influence the result. This walkthrough is the live flow: request, wait, resolve, or recover.
Open-source reference
Stylus contracts, VRF integration, Permit2 paths, and the deploy runbook. Study the pattern, fork it, extend it.
- Rust
- Arbitrum Stylus
- Chainlink VRF v2.5
- Open source
Live walkthrough
End-to-end against the real Sepolia deployment: placement, VRF callback, and an actually induced refund. Not a mock.
- Sepolia
- VRF v2.5
- E2E
Why this problem, after DEX aggregation
A DEX aggregator fails loudly. Quotes miss, routes revert, the user tries again. An application that depends on an external random answer cannot tolerate an outcome that is ambiguous, delayed forever, or influenceable by any single party, including us.
We published this as a reference architecture, then a separate team shipped a live product on top of it. That deployment isn't ours, so we are not naming or linking it. What matters here is stronger than a testnet demo: the pattern has processed real requests and real VRF callbacks under live conditions.
Reference implementation, not a product. This post describes an open-source architecture WakeUp Labs published for the Stylus ecosystem. WakeUp Labs is not the operator of any consumer application built on it.
Architecture: four contracts, one place money can sit
If you remember one diagram, remember this: everything that can hold tokens lives in a single contract. Everything else is replaceable.
User / Mini App
|
v
[ periphery ] -- stateless router, Permit2, read views
|
v
[ core ] -- ONLY custody. Lifecycle. VRF. Payouts. Refunds.
| \
| \--> Chainlink VRF v2.5 (via RandomnessSource trait)
v
[ play token ] + [ faucet ] -- no redemption to anything of valuecoreThe monolith. It custodies every token that enters the system and owns the full lifecycle: placement, VRF request, callback resolution, payout, refund, fee accounting. Chainlink VRF v2.5 sits behind a RandomnessSource trait, so swapping the provider later touches one impl block, never the logic that consumes randomness.
peripheryStateless on purpose. No counters, no owner-tracked state, never holds a token. It pulls funds in via Permit2, forwards them to core as a trusted router, and recomputes read-only views (available pool, max stake, refund eligibility) so the frontend has a single ABI. Solvency is enforced in core on every entry path. The periphery is not a security boundary. It can be rotated or killed without touching custody.
play tokenA minimal hand-rolled ERC-20. All stakes are denominated in it. It has no redemption path to anything of real value.
faucetThe onramp: a fixed mint per wallet per cooldown. It stays enabled on every network, mainnet included, because the asset is play money by design.
Design rule: if a contract can hold funds, it is core. If it cannot, it is allowed to be convenient, dumb, and disposable.
The hard part is the gap
A synchronous random number is a contradiction onchain. Core has to accept a stake, ask an external process for entropy, and only resolve when that process calls back. That can take multiple blocks. In the worst case, it never happens.
The interesting software is not the oracle call. It is everything that must remain true in the silence between request and answer.
t0 place stake
lock worst-case payout into the pool [solvency is decided here]
request VRF
status = Pending
... blocks pass. maybe many. maybe never. ...
t1a callback arrives --> resolve, release liability, payout or not
t1b deadline + buffer --> anyone refunds, full stake back, no fee
t1c late / unknown --> no-op + event, never revertLock the nightmare before the answer exists
When a stake is placed, core immediately does locked_liability += win_payout(amount). That single reservation is what keeps the system solvent under any interleaving of in-flight requests: the pool can never promise more than it can pay, no matter how many requests are pending or how long VRF takes.
If you wait to lock until the callback, you are betting that the future is kind. Production systems do not get to assume that.
Pick a failure. See what the contract does.
Click through the cases we actually designed for. This is the interactive part of the architecture: the state machine has to be boringly correct in every one of them.
1. The oracle never answers
Every request gets a fulfill_deadline at placement. If VRF never calls back, the request becomes refundable once now >= fulfill_deadline + DEAD_BUFFER. The buffer is a fixed five minutes, and it is not owner-tunable. That is deliberate: a knob an admin could shorten or extend would let someone race the refund window against a late callback.
Anyone, not just the original user, can trigger the refund. The full stake returns with no fee, because the game never resolved.
2. The callback arrives after the request already died
A callback for a request that is expired, already resolved, or unknown is a no-op plus an event. It does not revert.
A revert here would burn the Chainlink request without settling the user, leaving the position stranded between "not refundable yet" and "will never resolve." Treating adversarial or late input as a no-op is how you keep the state machine un-stickable.
3. The payout transfer fails
Resolution follows strict checks-effects-interactions: every storage change (status, liability release, fee accounting) happens before any external token call.
A winning payout is sent via direct transfer. If that transfer fails, for example a paused or misbehaving token on the receiving end, the payout falls back to a claimable pull-payment credit instead of reverting the whole resolution. The request is settled. The user can claim. The oracle is not griefed into a retry loop.
4. Many requests are in flight at once
This is why liability is locked at placement, not at callback. Ten pending requests are just ten reserved worst-case payouts. The available pool shrinks in real time. New stakes that would over-promise are rejected. Timing cannot create insolvency, because insolvency was made unrepresentable.
5. Someone tries to treat the callback as a trusted friend
rawFulfillRandomWords is called by an external contract. The wrapper is trusted; the input is still treated as adversarial. Ordering, no-ops, and pull-payment fallback exist because "the oracle is honest" is not a complete threat model. Honest oracles still arrive late, duplicate, or hit a token that refuses to receive.
Getting funds in: two Permit2 paths
Periphery supports two ways to move tokens into a request, plus a classic fallback.
| Path | How it feels | Why it exists |
|---|---|---|
Mini App SignatureTransfer | One single-use EIP-712 permit per request. No prior approval. | Signatures pass through Permit2 unmodified, so ERC-1271 smart accounts work as well as EOAs. |
Standalone AllowanceTransfer | One lifetime approve(Permit2, MAX), then a weekly permit. | Every request inside that window needs zero further signatures. |
Classic approve + call | The boring path. | Fallback when neither permit flow is available. |
Fitting inside the WASM gate
Arbitrum enforces a 24KB compressed WASM size limit per contract fragment. It is not a soft constraint. The core needed a pinned nightly Rust build with -Zbuild-std and -Cpanic=immediate-abort to compile down to 23.7KB. Close enough to the ceiling that the stock stable toolchain was not an option. The periphery, at 18.9KB, fits comfortably on the stable image.
24.0 KB ceiling, hard
23.7 KB core on pinned nightly + immediate-abort
18.9 KB periphery on stable
||||||||||||||||||||||||| core
|||||||||||||||| peripheryThis has no direct equivalent in Solidity development, and it shapes real decisions: which panics you can afford, how much logic lives in one contract versus split across several, and which build recipe you commit to shipping and re-verifying.
What you can actually observe
All four contracts are deployed and Arbiscan-verified on Arbitrum Sepolia through cargo-stylus's reproducible Docker build, not a --no-verify shortcut. The core's custom nightly build is verified against a published image pinned by digest, so anyone can confirm the deployed bytecode matches the source.
A live end-to-end suite exercises the real deployment: the standalone path, the Mini App Permit2 path, the AllowanceTransfer path, and an actually induced refund. Everything is open source.
The three properties that generalize
1. Lock the worst-case outcome before the external answer exists, so solvency never depends on timing.
2. Ship a deterministic, permissionless recovery path for when the external process never responds.
3. Handle the callback so it cannot be griefed, double-spent, or stranded, regardless of the state it finds.
The specific surface here is a game of chance. The pattern is not. A randomness oracle, a bridge message, an offchain price feed, a cross-chain settlement callback: any of those is the same shape of problem. Building it in Stylus meant we could express the state machine, the locked-liability accounting, and the trait-based oracle abstraction in Rust, with the WASM size limit forcing real tradeoffs instead of theoretical ones.
What's next
This is the second milestone in an open-source series validating Stylus through production reference implementations. Part 1 covered DEX aggregation. Each milestone answers a different engineering question, and every one of them ships fully open source so other teams building on Stylus can study, fork, and extend the pattern.
Full source, architecture notes, and the deploy runbook: github.com/wakeuplabs/ArbitrumMiniApp-2-verifiable-randomness.
If you are building something on Stylus that depends on an external process resolving correctly, we would like to compare notes.
About WakeUp Labs
At WakeUp Labs, we build production-grade blockchain infrastructure and digital products for leading ecosystems, startups, and enterprises.
Our engineering philosophy is simple: new infrastructure only proves its value when it runs in production. Rather than evaluating emerging technologies through isolated prototypes, we validate them by building real systems that interact with real users and real operational constraints.
If you are building on Arbitrum or exploring Stylus for production systems, we would love to connect.