Documentation Index
Fetch the complete documentation index at: https://docs.goldsky.com/llms.txt
Use this file to discover all available pages before exploring further.
This guide walks you through building a randomness delivery system using Compose and drand, a distributed randomness beacon. The system listens for on-chain randomness requests and fulfills them with drand values that anyone can verify off-chain against drand’s BLS public key.
This example stores the drand signature on-chain but does not verify the BLS signature inside the Solidity contract (on-chain BLS12-381 verification is expensive and out of scope for this guide). Consumers who need trustless randomness should either verify the signature off-chain before using the result, or add on-chain BLS verification to the contract.
How it works
- Source contract emits a
RandomnessRequested event
- Compose task is triggered by the on-chain event
- drand API provides verifiable randomness with BLS signatures
- Target contract records the randomness along with the drand round and signature so consumers can verify it off-chain
Prerequisites
Project structure
VRF/
├── compose.yaml # Compose configuration
├── contracts/
│ └── RandomnessConsumer.sol # Example contract
├── src/
│ ├── contracts/
│ │ └── RandomnessConsumer.json # Contract ABI (for codegen)
│ ├── lib/
│ │ └── drand.ts # drand API utilities
│ └── tasks/
│ ├── generate-wallet.ts # Wallet generation utility
│ ├── request-randomness.ts # HTTP endpoint for requests
│ └── fulfill-randomness.ts # Main fulfillment task
Step 1: Set up the project
Clone the example repository:
git clone https://github.com/goldsky-io/documentation-examples.git
cd documentation-examples/compose/VRF
Step 2: Generate contract types
The project includes a RandomnessConsumer.json ABI in src/contracts/. Generate the typed contract class:
This creates a typed RandomnessConsumer class that provides type-safe contract interaction instead of raw function signature strings.
Step 3: Generate your Compose wallet
Start Compose locally in one terminal:
goldsky compose start --fork-chains
--fork-chains lets you run a smart wallet locally by forking supported chains with TEVM. You can also use a private key wallet for local development — see Secrets for how to store the key.
In another terminal, get your wallet address:
goldsky compose callTask generate_wallet '{}'
Save the wallet address — this will be the authorized fulfiller for your contract.
Step 4: Deploy the smart contract
The RandomnessConsumer.sol contract handles randomness requests and fulfillment:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract RandomnessConsumer {
struct RandomnessRequest {
address requester;
bool fulfilled;
bytes32 randomness;
uint64 round;
bytes signature;
}
address public fulfiller;
uint256 public nextRequestId;
mapping(uint256 => RandomnessRequest) public requests;
event RandomnessRequested(uint256 indexed requestId, address indexed requester);
event RandomnessFulfilled(uint256 indexed requestId, bytes32 randomness, uint64 round, bytes signature);
constructor(address _fulfiller) {
fulfiller = _fulfiller;
}
function requestRandomness() external returns (uint256 requestId) {
requestId = nextRequestId++;
requests[requestId] = RandomnessRequest({
requester: msg.sender,
fulfilled: false,
randomness: bytes32(0),
round: 0,
signature: ""
});
emit RandomnessRequested(requestId, msg.sender);
}
function fulfillRandomness(
uint256 requestId,
bytes32 randomness,
uint64 round,
bytes calldata signature
) external {
require(msg.sender == fulfiller, "OnlyFulfiller");
RandomnessRequest storage request = requests[requestId];
require(request.requester != address(0), "RequestNotFound");
require(!request.fulfilled, "AlreadyFulfilled");
request.fulfilled = true;
request.randomness = randomness;
request.round = round;
request.signature = signature;
emit RandomnessFulfilled(requestId, randomness, round, signature);
}
}
Deploy to Base Sepolia:
forge create contracts/RandomnessConsumer.sol:RandomnessConsumer \
--rpc-url https://sepolia.base.org \
--private-key $PRIVATE_KEY \
--broadcast \
--constructor-args 0xYOUR_COMPOSE_WALLET_ADDRESS
Save the deployed contract address.
Update compose.yaml with your contract address:
name: "compose-vrf"
api_version: "stable"
tasks:
- path: "./src/tasks/generate-wallet.ts"
name: "generate_wallet"
triggers:
- type: "http"
authentication: "auth_token"
- path: "./src/tasks/request-randomness.ts"
name: "request_randomness"
triggers:
- type: "http"
authentication: "auth_token"
- path: "./src/tasks/fulfill-randomness.ts"
name: "fulfill_randomness"
triggers:
- type: "onchain_event"
network: "base_sepolia"
contract: "0xYOUR_DEPLOYED_CONTRACT_ADDRESS"
events:
- "RandomnessRequested(uint256,address)"
retry_config:
max_attempts: 3
initial_interval_ms: 1000
backoff_factor: 2
Update the contract address in the task files:
src/tasks/fulfill-randomness.ts — TARGET_CONTRACT
src/tasks/request-randomness.ts — CONTRACT_ADDRESS
Step 6: Understand the fulfillment task
The fulfill-randomness.ts task handles the core logic:
import { TaskContext, OnchainEvent } from "compose";
import {
fetchLatestRandomness,
toBytes32,
toBytes,
DRAND_CHAIN_INFO,
} from "../lib/drand.ts";
const CONTRACT_ADDRESS = "0xYOUR_DEPLOYED_CONTRACT_ADDRESS";
export async function main(context: TaskContext, event?: OnchainEvent) {
const { fetch, evm } = context;
// Extract request ID from the event
const requestId = event?.topics[1] ? BigInt(event.topics[1]) : 0n;
// Fetch randomness from drand
const drandResponse = await fetchLatestRandomness(fetch);
console.log(`Fetched drand round ${drandResponse.round}`);
// Get wallet and instantiate typed contract (generated from src/contracts/RandomnessConsumer.json)
const wallet = await evm.wallet({
name: "randomness-fulfiller",
});
const contract = new evm.contracts.RandomnessConsumer(
CONTRACT_ADDRESS,
evm.chains.baseSepolia,
wallet
);
// Fulfill the randomness request on-chain
const { hash } = await contract.fulfillRandomness(
requestId.toString(),
toBytes32(drandResponse.randomness),
drandResponse.round,
toBytes(drandResponse.signature)
);
console.log(`Fulfilled request ${requestId} in tx ${hash}`);
return {
success: true,
requestId: requestId.toString(),
transactionHash: hash,
drand: {
round: String(drandResponse.round),
randomness: toBytes32(drandResponse.randomness),
chainHash: DRAND_CHAIN_INFO.hash,
},
};
}
Key points:
- Contract codegen —
evm.contracts.RandomnessConsumer is generated from the ABI JSON, providing type-safe method calls like contract.fulfillRandomness(...) instead of raw function signature strings
- Uses
evm.chains.baseSepolia — a built-in chain, no custom configuration needed
- Gas is sponsored by default for smart wallets on supported chains. On chains where sponsorship isn’t available, fund the wallet directly or pass
sponsorGas: false and cover gas from the wallet
fulfillRandomness does seven or more SSTOREs (the signature is a 96-byte bytes field). Gas estimation handles this on Base Sepolia, but if you port this to a chain where you set the gas limit manually, budget ~250k. On Monad, where the full gas limit is charged regardless of gas used, keep the limit as tight as possible
Step 7: Understand the drand integration
The drand.ts library fetches verifiable randomness:
export type DrandResponse = {
round: number;
randomness: string; // hex - sha256(signature)
signature: string; // hex - BLS12-381 signature (96 bytes)
previous_signature: string;
};
export const DRAND_CHAIN_INFO = {
hash: "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971",
publicKey: "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a",
genesisTime: 1692803367,
period: 3, // seconds between rounds
};
export async function fetchLatestRandomness(
fetchFn: <T>(url: string) => Promise<T | undefined>
): Promise<DrandResponse> {
const response = await fetchFn<DrandResponse>(
`https://api.drand.sh/${DRAND_CHAIN_INFO.hash}/public/latest`
);
if (!response) {
throw new Error("Failed to fetch randomness from drand");
}
return response;
}
The randomness is verifiable off-chain using drand’s BLS12-381 signatures. Anyone can check that the signature is valid against drand’s public key, and that sha256(signature) == randomness, using a drand client library.
Step 8: Run locally
Start the Compose app:
goldsky compose start --fork-chains
Step 9: Test the system
Request randomness by calling the contract:
cast send 0xYOUR_CONTRACT_ADDRESS "requestRandomness()" \
--rpc-url https://sepolia.base.org \
--private-key $PRIVATE_KEY
Watch the Compose logs. You should see:
- The
RandomnessRequested event being detected
- Randomness fetched from drand
- The fulfillment transaction submitted
Step 10: Deploy to Goldsky
Once tested locally, deploy to Goldsky’s cloud:
Customization
Different chains
Use any supported chain — no custom configuration needed:
const wallet = await evm.wallet({ name: "my-wallet" });
const result = await wallet.writeContract(
evm.chains.arbitrumSepolia, // or baseSepolia, polygonAmoy, etc.
CONTRACT_ADDRESS,
"myFunction(uint256)",
[arg1]
);
Different events
Modify the compose.yaml to listen for different events:
triggers:
- type: "onchain_event"
network: "base_sepolia"
contract: "0xYOUR_CONTRACT"
events:
- "YourCustomEvent(uint256,address,bytes32)"
Resources