Quickstart
Inherit EntropyConsumer, request with _roll, and implement _onRandomness. The user signs only play(); a keeper (or anyone) settles it and your callback fires.
import "./EntropyConsumer.sol"; import "./EntropyLib.sol"; // optional: many values from one reveal contract MyGame is EntropyConsumer { using EntropyLib for uint256; uint32 constant CB_GAS = 150_000; mapping(uint256 => address) public player; constructor(address entropyV2) EntropyConsumer(entropyV2) {} // user signs ONCE here; pass the protocol fee through function play() external payable { require(msg.value >= entropy.fee(CB_GAS), "fee"); uint256 id = _roll(keccak256(abi.encodePacked(msg.sender, block.number)), CB_GAS); player[id] = msg.sender; } // fires later when revealed (by a keeper / the next user / anyone) function _onRandomness(uint256 id, uint256 rng) internal override { uint256 pocket = rng.pick(0, 37); // 0..36, domain-separated // ... settle / pay out / mint to player[id] ... } }
Addresses & config
| Item | Value |
|---|---|
| Chain | KUB Chain (Bitkub) · chainId 96 (0x60) |
| RPC | https://rpc.bitkubchain.io |
| DurianEntropyV2 | 0xE2BACB42Ce5C5C1FB32335ac06B79bE28fb54caB |
| Keeper API | https://durian-entropy-keeper.pupkaikub.workers.dev/reveal |
| Explorer | https://www.kubscan.com |
| Compiler | solc 0.8.24 · optimizer 200 · evm paris |
V2 is the commercial service (gas-scaled fee, sign-once UX), built on the same blockhash commit–reveal primitive.
Solidity SDK — the base handles the dangerous parts
Inherit EntropyConsumer and you get all of this for free:
Pays entropy.fee(cbGas), requests a callback, and records the id as yours (known[id]) so a spoofed callback for a foreign id can never trigger your _onRandomness.
Implemented for you: requires msg.sender == entropy AND known[id], single-delivery (delivered[id]), then calls your _onRandomness.
If the auto-callback ever failed (out of gas, transient revert), anyone may poke this once the request is FULFILLED to deliver the stored result exactly once.
Settle/capture a previous pending request inside a new action, so users almost never sign a second tx.
You implement exactly one function: _onRandomness(uint256 id, uint256 rng).
Contract functions (DurianEntropyV2)
Pay the fee, get a callback to consumer on reveal. (What _roll calls.)
No callback, no fee — settle it yourself with seal/reveal.
The fee to send at the current gas price: markupBps/1e4 × (baseGas + cbGas) × max(tx.gasprice, minGasPrice).
Capture blockhash(revealBlock) within the 256-block window so the result can be revealed later with no deadline.
Compute the result + fire the callback. Works anytime once sealed; else within the window.
0 NONE · 1 WAITING · 2 READY · 3 EXPIRED · 4 FULFILLED.
Stored randomness (0 until revealed).
Frontend SDK (JS) — request, then settle with a user fallback
The one rule: never depend solely on the keeper. settle() pings the keeper, watches for the result, and if the keeper is silent it falls back to a user-signed seal+reveal.
import { DurianEntropy } from "./durian-entropy.js"; const entropy = new DurianEntropy({ address: "0xE2BACB42Ce5C5C1FB32335ac06B79bE28fb54caB", workerUrl: "https://durian-entropy-keeper.pupkaikub.workers.dev/reveal", provider, // ethers read provider }); // 1) user signs ONE tx — your dapp's play()/mint() const rc = await (await myGame.connect(signer).play({ value: await entropy.fee(150000) })).wait(); const id = entropy.idFromReceipt(rc); // 2) keeper-first, user-fallback — always settles within the window const rng = await entropy.settle(id, { signer, onState: (s) => setStatus(s), // 'settling'→('fallback'→'sealing'→'revealing'→)'done' });
| Method | Notes |
|---|---|
| fee(cbGas) | fee (wei) to send for a request |
| idFromReceipt(rc) | parse the Requested event id |
| settle(id, {signer, onState, timeoutMs, pollMs}) | the locked pattern — keeper-first, user-fallback. onState also gets {elapsedMs, remainingMs} |
| userSettle(id, signer, onState) | the fallback path alone (seal if needed → reveal) |
| status · results · sealedHash | reads |
e.code: EXPIRED · NO_SIGNER · NOT_READY · USER_REJECTED.Keeper API — POST /reveal
The Durian keeper is a Cloudflare Worker. Your frontend pings it right after the request tx; it settles on-ping as soon as the reveal block is mined (~6–9s). It is gated — it verifies the id on-chain before acting, so it can't be spammed into wasting gas. You normally never call it directly (settle() does).
POST https://durian-entropy-keeper.pupkaikub.workers.dev/reveal
Content-Type: application/json
{ "id": "<request id as decimal string>" }| Status | Body | Meaning |
|---|---|---|
| 200 | { ok: true, queued } | accepted; settling on-ping / next sweep |
| 200 | { ok: true, note } | already fulfilled |
| 404 | { error } | id doesn't exist on-chain (rejected, no gas spent) |
| 400 / 503 | { error } | malformed / keeper not configured |
Fees & cbGas
fee(cbGas) ≈ one reveal's gas × markup (currently 2×), tracked to the live gas price with a floor. Read it live and send it with the request. Overpay is safe — refund the remainder in your consumer.
| Your callback does | Suggested cbGas |
|---|---|
| record / read a value (roulette pocket) | 80k – 120k |
| coinflip payout via pull ledger | 120k – 180k |
NFT _safeMint + storage | 180k – 250k |
Too low → the on-reveal callback fails (caught), and settle(id) recovers it in one extra tx (no fund loss). Ceiling 8,000,000. The fee funds the keeper + the Durian treasury (the fee recipient is an Ownable2Step owner — the only privileged role; it cannot touch randomness, escrows, or pause).
Seal → claim anytime
The EVM keeps only the last 256 blockhashes, so the entropy must be captured within 256 blocks — a hard limit, not a choice. seal(id) stores blockhash(revealBlock) in one cheap SSTORE inside that window; once sealed, reveal(id) (compute + callback) has no deadline — a winner claims whenever. The keeper's only time-critical job is the trivial, batchable seal.
If nobody seals within 256 blocks the request expires and is forfeit (the requester already escrowed). Extending the window is impossible and would be a re-roll exploit, so it is deliberately not done.
Rules every MONEY consumer MUST follow
_roll. A loser who refuses to settle gains nothing — the stake is already yours; unsettled → expiry forfeits it.committedAtBlock[block.number+1] += stake; require(≤ perBlockCap) and size perBlockCap below KUB block_reward / 10. See examples/CoinFlipHouse.sol.results(id). Show a cosmetic spin, ease to the real result on the event..call inside the callback (a reverting receiver would strand your state). Credit winnings[player] + add claim().Lifecycle & errors
request ──(block N+1 mined)──> WAITING ─> READY ──(seal <=256 blk)──> READY (forever) ─> FULFILLED
└─(no seal, 256 blk pass)──> EXPIRED (forfeit)| Revert | When |
|---|---|
| too early | before the reveal block is mined (also blocks atomic grinding) |
| expired | 256-block window passed unsealed |
| no request | unknown id |
| fee | underpaid |
| cbGas too high | > 8,000,000 |
Reference consumers to copy: CoinFlipHouse.sol (money game), RandomTierNFT.sol, RouletteConsumer.sol. Full guide: download the SDK (.md).