Build: Compose VRF
Stand up the VRF example under the user's own Goldsky account. The app listens for a RandomnessRequested(uint256,address) event on an EVM contract, fetches verifiable randomness from the drand beacon (BLS12-381 threshold signatures, verifiable by anyone), and writes it back on-chain via fulfillRandomness(requestId, randomness, round, signature). Trust comes from the stored drand round + signature, not from the caller, so the shared example contract is safe to leave open.
This template supplies only what's specific to the VRF app — how it works and its full source (below). The recommended path uses a shared, fully-unpermissioned RandomnessConsumer on Base Sepolia at 0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d, so the user deploys nothing and the Compose smart wallet is auto-created and gas-sponsored.
Step 0a — Load the base skills first
Before anything else — before you answer, ask a question, scaffold a file, or call deployComposeApp — load the two base skills this template depends on:
Skill(compose)— the always-on Compose guide: the golden rules (never assume anything about the app on the user's behalf; ask when unsure) and general build guidance.Skill(compose-reference)— the manifest / field / API reference; consult before writing anycompose.yamlor task file.
This template deliberately omits those rules and that reference — they are required to build correctly and are not repeated here. Do not proceed until both are loaded.
Mode Detection
Pick the mode from the tools available to you:
A
deployComposeApptool is available (Goldsky webapp chatbot) — this is the preferred in-app flow. Do NOT emitgoldskyterminal commands orcliCommandcards, and do NOT use Step 0b /degit/forge/goldsky compose deploy. Instead: give a 2-3 sentence plain explanation, then ask the questions below withaskUser(tag the recommended option withrecommendedIndex):- App name — ask first, before anything else: "What should the app be called? (suggest
vrf-app)". The name is hard to change later — it scopes named wallets and participates in the CREATE2 salt for everydeployContract— so it must be settled before any wallet or contract step. Accept the defaultvrf-appon a shrug, and set the chosen name as the top-levelname:in the scaffoldedcompose.yaml. - Contract — ask this explicitly, do not assume: "Do you have your own
RandomnessConsumer-style contract, or should we use a shared demo contract on Base Sepolia to get running quickly?" Options: "Use the shared demo contract on Base Sepolia (recommended — nothing to deploy)" and "I'll use my own contract." On the shared path,CONTRACT_ADDRESSis the HARDCODED address0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6donbaseSepolia— copy it character-for-character, do NOT alter or retype it from memory; mention in prose that it's demos-only, not production. On the own path, ask the user to paste their contract address and chain (their contract must emitRandomnessRequested(uint256,address)and acceptfulfillRandomness), and use exactly what they paste.
The Compose smart wallet is auto-created at runtime and gas-sponsored on Base Sepolia — never tell the user to create or fund a wallet. On the shared path there is no fulfiller to set (the contract is permissionless), so the wallet just works. After the questions, scaffold these files in-memory from The app (full source) below and pass them to
deployComposeApp(do NOT degit):compose.yaml,src/contracts/RandomnessConsumer.json(verbatim ABI, required for codegen),src/lib/drand.ts, and the three task files. Set the top-levelname:to the chosen app name (defaultvrf-app); setCONTRACT_ADDRESSin both task files and thecontract:field incompose.yamlto the shared address (or the user's, on the own path), and theevm.chains.*reference + triggernetwork:to the chosen chain. Follow/compose-referencefor the manifest shape and the sandbox import rule before emitting the files (per the golden rules in/compose— don't synthesize the manifest from memory). Then calldeployComposeAppin the SAME turn; don't ask the user to confirm first or emit anygoldskycommand. In this mode, ignore Steps 0–8 below — they are the CLI/local procedure.- App name — ask first, before anything else: "What should the app be called? (suggest
Bashis available (local CLI / coding agent): execute the steps below directly, parsing output into later commands.Neither (pure reference Q&A): explain what the app does; for step-by-step help point them at
npx skills add goldsky-io/goldsky-agentto run it locally with Bash.
Non-negotiables
- The shared contract at
0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6don Base Sepolia is fully unpermissioned — anyone can callfulfillRandomness. That is safe here: the randomness is only trusted because of the drandround+ BLSsignaturestored on-chain, which anyone can verify off-chain against the drand quicknet public key. It exists for getting started and demos only. Tell the user, in prose, that it must NOT be used in production, and it only exists on Base Sepolia. - The deployed shared contract
0x6273AB…does NOT expose workingfulfiller()/setFulfiller. Those calls revert on-chain even though the reference source above includes them — the deployed bytecode differs. This is harmless to the app (fulfillRandomnessis what matters, and it's permissionless), so do NOT "verify" the shared contract by callingfulfiller()/setFulfiller, and do not treat a revert there as a fault. - Do NOT point the app at
0x53...or0xE05Ceb3E269029E3bab46E35515e8987060D1027. That older demo contract is permissioned (itsfulfilleris a fixed address, not the user's Compose wallet), so every off-the-shelffulfillRandomnessreverts withOnlyFulfiller. The shared no-deploy contract is0x6273AB...and nothing else. - Three places share the contract address: the
contract:field incompose.yaml, andCONTRACT_ADDRESSin bothsrc/tasks/fulfill-randomness.tsandsrc/tasks/request-randomness.ts. If the user changes it, change all three. - Deploy-your-own path only: the drand fulfillment is permissionless in the reference contract, but if the user's own contract restricts fulfillment, the authorized fulfiller must be the Compose wallet or every
fulfillRandomnessreverts. On the shared contract there is no such restriction. - Never run
goldsky compose deployContract,goldsky compose deploy,git push, orgh repo createwithout showing the exact command first and getting explicit confirmation. - Never place a literal private key or API key in a command line, a file shown in chat, or any transcript output. Keys live in chmod-600 files (
.env,.eoa.env) and are referenced only via shell expansion (source+"$VAR") inside a single Bash invocation. When this skill writes$SOME_KEY, that is a shell expansion to perform in-shell, not a value to capture and substitute.
The app (full source)
This is the complete VRF app. In the in-app flow, scaffold these files in-memory verbatim and set the address/chain per the interview. In the CLI flow, Step 0b pulls the same files from goldsky-io/documentation-examples — but the scaffold's baked-in defaults are STALE and MUST be rewired before you deploy (Step 4 is mandatory, not optional). Specifically the scaffolded compose.yaml and both task files ship name: "compose-vrf" (stale — overwrite with the chosen app name from Step 1, default vrf-app) and CONTRACT_ADDRESS = 0xE05Ceb3E269029E3bab46E35515e8987060D1027 — the permissioned contract this skill forbids — so do not trust the scaffolded values; rewire all of them in Step 4. The shared no-deploy contract 0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d (shown throughout the source above) is the address Step 4 wires in on the recommended path.
compose.yaml
name: "vrf-app"
api_version: "stable"
tasks:
# Utility task to get the Compose wallet address (only needed if deploying your own contract)
- path: "./src/tasks/generate-wallet.ts"
name: "generate_wallet"
triggers:
- type: "http"
authentication: "auth_token"
# HTTP endpoint to request randomness (no MetaMask needed)
- path: "./src/tasks/request-randomness.ts"
name: "request_randomness"
triggers:
- type: "http"
authentication: "auth_token"
# Main fulfillment task — triggered by the on-chain RandomnessRequested event
- path: "./src/tasks/fulfill-randomness.ts"
name: "fulfill_randomness"
triggers:
- type: "onchain_event"
network: "base_sepolia"
# Shared, fully-unpermissioned RandomnessConsumer (anyone can fulfill) — nothing to deploy.
# Keep in sync with CONTRACT_ADDRESS in the two task files.
contract: "0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d"
events:
- "RandomnessRequested(uint256,address)"
retry_config: # matches the CLI default; not an override
max_attempts: 3
initial_interval_ms: 1000
backoff_factor: 2
src/tasks/fulfill-randomness.ts
import { TaskContext, OnchainEvent } from "compose";
import {
fetchLatestRandomness,
toBytes32,
toBytes,
DRAND_CHAIN_INFO,
} from "../lib/drand.ts";
// Shared, fully-unpermissioned RandomnessConsumer on Base Sepolia — anyone can
// fulfill, so there's nothing to deploy and no wallet to whitelist. Swap for
// your own contract to go to production. Keep in sync with the CONTRACT_ADDRESS
// in request-randomness.ts and the `contract:` field in compose.yaml.
const CONTRACT_ADDRESS = "0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d";
/**
* Fulfill randomness requests using drand.
*
* Triggered by the on-chain RandomnessRequested event (configured in compose.yaml).
* Fetches verifiable randomness from drand and writes it back to the target contract.
*/
export async function main(context: TaskContext, event?: OnchainEvent) {
const { fetch, evm } = context;
// Parse request ID from event topics
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
);
// Prepare the fulfillment arguments
const randomnessBytes32 = toBytes32(drandResponse.randomness);
const signatureBytes = toBytes(drandResponse.signature);
// Fulfill the randomness request on-chain
const { hash } = await contract.fulfillRandomness(
requestId.toString(),
randomnessBytes32,
drandResponse.round,
signatureBytes
);
console.log(`fulfilled request ${requestId} in tx ${hash}`);
return {
success: true,
requestId: requestId.toString(),
transactionHash: hash,
drand: {
round: String(drandResponse.round),
randomness: randomnessBytes32,
chainHash: DRAND_CHAIN_INFO.hash,
},
};
}
src/tasks/request-randomness.ts
import { TaskContext } from "compose";
// Shared, fully-unpermissioned RandomnessConsumer on Base Sepolia — anyone can
// request. Keep in sync with the CONTRACT_ADDRESS in fulfill-randomness.ts and
// the `contract:` field in compose.yaml.
const CONTRACT_ADDRESS = "0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d";
export async function main(context: TaskContext): Promise<{
requestId: string;
txHash: string;
}> {
const { evm } = context;
const wallet = await evm.wallet({
name: "randomness-requester",
});
// Instantiate typed contract (generated from src/contracts/RandomnessConsumer.json)
const contract = new evm.contracts.RandomnessConsumer(
CONTRACT_ADDRESS,
evm.chains.baseSepolia,
wallet
);
// Send the request transaction
const { hash } = await contract.requestRandomness();
// Read nextRequestId after tx — subtract 1 to get our requestId
const nextId = await contract.nextRequestId();
const requestId = String(BigInt(nextId) - 1n);
return {
requestId,
txHash: hash,
};
}
src/tasks/generate-wallet.ts
import { TaskContext } from "compose";
/**
* Generate the Compose wallet and output its address.
*
* Only needed on the deploy-your-own path: the fulfiller address comes from
* Step 3 Branch B's `goldsky compose wallet create randomness-fulfiller`.
* `callTask` targets the DEPLOYED app by default:
* goldsky compose callTask generate_wallet '{}' # deployed app
* goldsky compose callTask generate_wallet '{}' --env local # local `compose start` server
*
* (The upstream goldsky-io/documentation-examples repo ships the same stale
* "run this before deploying" comment — do NOT fix the upstream repo here.)
*/
export async function main(context: TaskContext) {
const { evm } = context;
const wallet = await evm.wallet({ name: "randomness-fulfiller" });
return {
address: wallet.address,
name: wallet.name,
message: "Use this address as the fulfiller when deploying your contract",
};
}
src/lib/drand.ts
/**
* drand API utilities for fetching verifiable randomness.
*
* drand produces BLS12-381 threshold signatures that anyone can verify.
* The randomness is sha256(signature), making it deterministic and verifiable.
*/
// ============ Types ============
export type DrandResponse = {
round: number;
randomness: string; // hex - sha256(signature)
signature: string; // hex - BLS12-381 signature (96 bytes)
previous_signature: string;
};
export type DrandChainInfo = {
hash: string;
publicKey: string;
genesisTime: number;
period: number;
};
// ============ Constants ============
/**
* drand quicknet chain info (3 second rounds).
* Use these values to verify randomness off-chain.
*
* Note: Quicknet uses "unchained" randomness (no previous_signature linking)
* and BLS signatures on G1 curve instead of G2.
*/
export const DRAND_CHAIN_INFO: DrandChainInfo = {
hash: "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971",
publicKey:
"83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a",
genesisTime: 1692803367,
period: 3, // seconds between rounds
};
export const DRAND_API_URL =
"https://api.drand.sh/52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971";
// ============ Functions ============
/**
* Fetch the latest randomness from drand.
*/
export async function fetchLatestRandomness(
fetchFn: <T>(url: string) => Promise<T | undefined>
): Promise<DrandResponse> {
const response = await fetchFn<DrandResponse>(
`${DRAND_API_URL}/public/latest`
);
if (!response) {
throw new Error("Failed to fetch randomness from drand");
}
return response;
}
/**
* Fetch randomness for a specific round.
*/
export async function fetchRandomnessForRound(
fetchFn: <T>(url: string) => Promise<T | undefined>,
round: number
): Promise<DrandResponse> {
const response = await fetchFn<DrandResponse>(
`${DRAND_API_URL}/public/${round}`
);
if (!response) {
throw new Error(`Failed to fetch randomness for round ${round}`);
}
return response;
}
/**
* Convert hex string to bytes32 format (with 0x prefix, 64 chars).
*/
export function toBytes32(hex: string): `0x${string}` {
// Remove 0x prefix if present
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
// Pad to 64 characters (32 bytes)
const padded = clean.padStart(64, "0");
return `0x${padded}` as `0x${string}`;
}
/**
* Convert hex string to bytes (with 0x prefix).
*/
export function toBytes(hex: string): `0x${string}` {
const clean = hex.startsWith("0x") ? hex : `0x${hex}`;
return clean as `0x${string}`;
}
src/contracts/RandomnessConsumer.json (ABI — required for codegen)
Write this verbatim; contract codegen reads it to build evm.contracts.RandomnessConsumer. Never invent the ABI.
[
{ "type": "constructor", "inputs": [{ "name": "_fulfiller", "type": "address", "internalType": "address" }], "stateMutability": "nonpayable" },
{ "type": "function", "name": "fulfillRandomness", "inputs": [{ "name": "requestId", "type": "uint256", "internalType": "uint256" }, { "name": "randomness", "type": "bytes32", "internalType": "bytes32" }, { "name": "round", "type": "uint64", "internalType": "uint64" }, { "name": "signature", "type": "bytes", "internalType": "bytes" }], "outputs": [], "stateMutability": "nonpayable" },
{ "type": "function", "name": "fulfiller", "inputs": [], "outputs": [{ "name": "", "type": "address", "internalType": "address" }], "stateMutability": "view" },
{ "type": "function", "name": "getRandomness", "inputs": [{ "name": "requestId", "type": "uint256", "internalType": "uint256" }], "outputs": [{ "name": "randomness", "type": "bytes32", "internalType": "bytes32" }, { "name": "round", "type": "uint64", "internalType": "uint64" }, { "name": "signature", "type": "bytes", "internalType": "bytes" }], "stateMutability": "view" },
{ "type": "function", "name": "isFulfilled", "inputs": [{ "name": "requestId", "type": "uint256", "internalType": "uint256" }], "outputs": [{ "name": "", "type": "bool", "internalType": "bool" }], "stateMutability": "view" },
{ "type": "function", "name": "nextRequestId", "inputs": [], "outputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "stateMutability": "view" },
{ "type": "function", "name": "requestRandomness", "inputs": [], "outputs": [{ "name": "requestId", "type": "uint256", "internalType": "uint256" }], "stateMutability": "nonpayable" },
{ "type": "function", "name": "requests", "inputs": [{ "name": "", "type": "uint256", "internalType": "uint256" }], "outputs": [{ "name": "requester", "type": "address", "internalType": "address" }, { "name": "fulfilled", "type": "bool", "internalType": "bool" }, { "name": "randomness", "type": "bytes32", "internalType": "bytes32" }, { "name": "round", "type": "uint64", "internalType": "uint64" }, { "name": "signature", "type": "bytes", "internalType": "bytes" }], "stateMutability": "view" },
{ "type": "function", "name": "setFulfiller", "inputs": [{ "name": "_fulfiller", "type": "address", "internalType": "address" }], "outputs": [], "stateMutability": "nonpayable" },
{ "type": "event", "name": "RandomnessFulfilled", "inputs": [{ "name": "requestId", "type": "uint256", "indexed": true, "internalType": "uint256" }, { "name": "randomness", "type": "bytes32", "indexed": false, "internalType": "bytes32" }, { "name": "round", "type": "uint64", "indexed": false, "internalType": "uint64" }, { "name": "signature", "type": "bytes", "indexed": false, "internalType": "bytes" }], "anonymous": false },
{ "type": "event", "name": "RandomnessRequested", "inputs": [{ "name": "requestId", "type": "uint256", "indexed": true, "internalType": "uint256" }, { "name": "requester", "type": "address", "indexed": true, "internalType": "address" }], "anonymous": false },
{ "type": "error", "name": "AlreadyFulfilled", "inputs": [] },
{ "type": "error", "name": "OnlyFulfiller", "inputs": [] },
{ "type": "error", "name": "RequestNotFound", "inputs": [] }
]
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "dom"],
"module": "esnext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"resolveJsonModule": true,
"baseUrl": ".",
"paths": {
"compose": [".compose/types.d.ts"]
}
},
"include": ["src/**/*"]
}
contracts/RandomnessConsumer.sol (only for the deploy-your-own path)
The reference contract. Fulfillment is permissionless by design — see the NatSpec on fulfillRandomness. The fulfiller field is an informational deploy-time label, not a gate.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/**
* @title RandomnessConsumer
* @notice Example contract demonstrating the drand randomness request/fulfill pattern.
* @dev Users can replace this with their own contract — just emit an event and implement fulfillment.
*
* Verification: the randomness is verifiable using drand's BLS12-381 signatures.
* Chain info for verification (drand quicknet):
* - Chain Hash: 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971
* - Public Key: 83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a
*/
contract RandomnessConsumer {
struct RandomnessRequest {
address requester;
bool fulfilled;
bytes32 randomness;
uint64 round;
bytes signature;
}
/// @notice The Compose wallet recorded at deploy time (informational label only).
address public fulfiller;
/// @notice Counter for generating request IDs.
uint256 public nextRequestId;
/// @notice Mapping of request ID to request data.
mapping(uint256 => RandomnessRequest) public requests;
/// @notice Emitted when randomness is requested — Compose listens for this.
event RandomnessRequested(uint256 indexed requestId, address indexed requester);
/// @notice Emitted when randomness is fulfilled with full proof data.
event RandomnessFulfilled(uint256 indexed requestId, bytes32 randomness, uint64 round, bytes signature);
error OnlyFulfiller();
error RequestNotFound();
error AlreadyFulfilled();
/// @param _fulfiller The Compose wallet address recorded as the deploy-time fulfiller label.
constructor(address _fulfiller) {
fulfiller = _fulfiller;
}
/**
* @notice Request randomness — emits the event that Compose listens to.
* @return requestId The ID of this request.
*/
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);
}
/**
* @notice Fulfill a randomness request with drand proof data.
* @dev Permissionless: any caller may fulfill so the shared example contract is
* reusable by anyone without being whitelisted. Trust does not come from the
* caller's identity, it comes from the stored drand `round` + `signature`,
* which anyone can verify off-chain against the drand quicknet BLS public key.
*/
function fulfillRandomness(
uint256 requestId,
bytes32 randomness,
uint64 round,
bytes calldata signature
) external {
RandomnessRequest storage request = requests[requestId];
if (request.requester == address(0)) revert RequestNotFound();
if (request.fulfilled) revert AlreadyFulfilled();
request.fulfilled = true;
request.randomness = randomness;
request.round = round;
request.signature = signature;
emit RandomnessFulfilled(requestId, randomness, round, signature);
}
function getRandomness(uint256 requestId)
external
view
returns (bytes32 randomness, uint64 round, bytes memory signature)
{
RandomnessRequest storage request = requests[requestId];
return (request.randomness, request.round, request.signature);
}
function isFulfilled(uint256 requestId) external view returns (bool) {
return requests[requestId].fulfilled;
}
/// @notice Update the fulfiller label (for key rotation); does not gate fulfillment.
function setFulfiller(address _fulfiller) external {
if (msg.sender != fulfiller) revert OnlyFulfiller();
fulfiller = _fulfiller;
}
}
Steps 0–8 below are the Bash / local-CLI procedure. If a
deployComposeApptool is available (webapp chatbot), do NOT follow them — use the deploy-tool flow in Mode Detection above.
Step 0b — Scaffold the example
Pull just the VRF example into a fresh directory (no git history):
npx -y degit goldsky-io/documentation-examples/compose/VRF#6abe62878cb92f2569538ba9572b049ed5949a01 compose-vrf
cd compose-vrf
If npx -y degit is unavailable, fall back to a sparse clone:
git clone --filter=blob:none --sparse https://github.com/goldsky-io/documentation-examples.git
cd documentation-examples && git checkout 6abe62878cb92f2569538ba9572b049ed5949a01 && git sparse-checkout set compose/VRF && cd compose/VRF
If the user already cloned the example, skip this step and cd into it. Either way, set the CONTRACT_ADDRESS in both task files and the contract: field in compose.yaml to the shared no-deploy address 0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d unless the user is deploying their own (Step 3, Branch B).
Preflight
If goldsky compose --version prints compose CLI is not installed, run goldsky compose install (idempotent on the stable channel), then re-check. The goldsky CLI, auth, and deno checks are the standard Compose preflight; see /compose and /auth-setup. Auth: export GOLDSKY_API_TOKEN=<project API key> once in the shell (preferred: keeps the key out of argv and shell history), or pass -t "$GOLDSKY_PROJECT_KEY" per command, or use an active goldsky login session. Precedence: --token > GOLDSKY_API_TOKEN > ~/.goldsky/auth_token. Version gate (do this first): run goldsky compose --version; it prints goldsky compose 0.8.1 (or newer). If the version is older than 0.8.1, OR goldsky compose deployContract / goldsky compose writeContract are reported as unknown commands, the CLI is too old for this skill: OFFER to run goldsky compose update (or goldsky compose update 0.8.1 for a specific version), re-check --version after it finishes, and do not proceed until 0.8.1+ is confirmed.
VRF-specific, Foundry: Foundry (forge / cast) is NOT required on the recommended path. deployContract routes through the cloud's Alchemy bundler, which covers Ethereum, Sepolia, Polygon, Polygon Amoy, Arbitrum, Arbitrum Sepolia, Optimism, Optimism Sepolia, Base and Base Sepolia (chain IDs 1, 11155111, 137, 80002, 42161, 421614, 10, 11155420, 8453, 84532). On a chain outside that set the deploy fails with No Alchemy bundler URL for chain <id>, so use the forge create fallback there. Multi-provider coverage is tracked as FOU-991. Branch B's goldsky compose deployContract bundles its own compiler, and the Step 8 smoke test uses the gas-sponsored goldsky compose writeContract. Foundry IS required for that forge fallback or the cast-based smoke-test alternative in Step 8, so on the recommended path, treat Foundry as optional.
Step 1 — Configuration interview
Ask one question at a time; let each answer inform the next. Use readable labels and translate to machine values yourself.
- App name — ask FIRST, before any wallet or contract step: "What should the app be called? (suggest
vrf-app)". The name is hard to change later: it scopes named wallets and participates in the CREATE2 salt for everydeployContract. Accept the defaultvrf-appon a shrug, and set it as the top-levelname:incompose.yaml(the degit scaffold ships a stalename: "compose-vrf"— overwrite it). - "Which chain?" — Base Sepolia (recommended) because it has the ready, fully-unpermissioned shared contract (nothing to deploy). Other chains require deploying your own. Use the camelCase form in TS (
baseSepolia) and snake_case incompose.yaml(base_sepolia). - "RandomnessConsumer contract?" (ask right after the chain) — two options:
- Reuse the shared contract on Base Sepolia (recommended) — nothing to deploy. Wire
0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d(mention the address in prose, not in any option label). Demos/getting-started only, not production. - Deploy my own — see Step 3, Branch B. (Required on any chain other than Base Sepolia.)
- Reuse the shared contract on Base Sepolia (recommended) — nothing to deploy. Wire
Step 2 — Wallet
- Shared-contract path (recommended): nothing to do. The Compose smart wallet is auto-created at runtime and fully gas-sponsored on Base Sepolia, and the shared contract is permissionless so there's no fulfiller to authorize. Do NOT tell the user to create or fund a wallet.
- Deploy-your-own path (Branch B): the reference contract's
fulfilleris just an informational deploy-time label (fulfillment is permissionless), but the constructor still needs the Compose wallet address as that label. The wallet is created first, before any deploy, as step (1) of the Branch B ordering in Step 3; capture its address as$COMPOSE_WALLET. The named walletrandomness-fulfillermatchesevm.wallet({ name: "randomness-fulfiller" })insrc/tasks/fulfill-randomness.ts.
Step 3 — Contract
Branch A — Reuse shared contract (recommended). $CONTRACT_ADDRESS = 0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d on Base Sepolia. No deploy, no fulfiller authorization. Skip to Step 4.
Branch B — Deploy your own. Branch B has a fixed ordering: create the wallet → deploy the contract → codegen → wire (Step 4) → deploy the app (Step 7). Follow steps (1)–(5) below in order. The reference contract is in The app (full source) above (contracts/RandomnessConsumer.sol); deployContract writes the compiled ABI to src/contracts/RandomnessConsumer.json for you, so there's nothing to confirm by hand.
(1) Create the wallet (works before any deploy) and capture its address as $COMPOSE_WALLET. It matches evm.wallet({ name: "randomness-fulfiller" }) in src/tasks/fulfill-randomness.ts:
goldsky compose wallet create randomness-fulfiller
⚠ Do NOT use
wallet create --env local: that's a LOCAL tevm wallet the cloud runtime never signs with, producing a bricked app the Troubleshooting section below will not diagnose. Use the default (cloud) wallet. Named wallets are app-scoped via the top-levelname:incompose.yaml, so the app name (the chosen name from Step 1, e.g.vrf-app) is already final by this point; renaming it later would derive a different wallet than the one baked into your contract.
(2) Deploy the contract. deployContract compiles in-CLI and deploys via a CREATE2 proxy signed by the gas-sponsored Compose wallet, so on Base / Base Sepolia it needs no funded EOA and no RPC URL. The constructor arg records the Compose wallet as the (informational) fulfiller label:
ADDR=$(goldsky compose deployContract contracts/RandomnessConsumer.sol \
--chain-id <CHAIN_ID> \
--constructor-args $COMPOSE_WALLET \
--wallet randomness-fulfiller \
--json | jq -r .address)
Auth: export GOLDSKY_API_TOKEN=<project API key> once in the shell (preferred: keeps the key out of argv and shell history), or pass -t "$GOLDSKY_PROJECT_KEY" per command, or use an active goldsky login session. Precedence: --token > GOLDSKY_API_TOKEN > ~/.goldsky/auth_token. On a 401 it prints "Generate an API key from Settings > API Keys in the Goldsky dashboard"; generate one there if you don't have it; OFFER to run the deploy with -t for the user.
Chain IDs and routing. deployContract routes through the cloud's Alchemy bundler, which covers Ethereum, Sepolia, Polygon, Polygon Amoy, Arbitrum, Arbitrum Sepolia, Optimism, Optimism Sepolia, Base and Base Sepolia (chain IDs 1, 11155111, 137, 80002, 42161, 421614, 10, 11155420, 8453, 84532). On a chain outside that set the deploy fails with No Alchemy bundler URL for chain <id>, so use the forge create fallback there. Multi-provider coverage is tracked as FOU-991.
- Deploy-sponsored → use
deployContract:ethereum→1,sepolia→11155111,polygon→137,polygonAmoy→80002,arbitrum→42161,arbitrumSepolia→421614,optimism→10,optimismSepolia→11155420,base→8453,baseSepolia→84532.
deployContract auto-saves the ABI to src/contracts/RandomnessConsumer.json. --json suppresses ascii art; on failure the command exits 1 with {"error":true,"code":"...","message":"..."} on stderr, so an empty capture means check stderr. Set CONTRACT_ADDRESS=$ADDR.
Non-Base chains (forge fallback). deployContract routes through the cloud's Alchemy bundler, which covers Ethereum, Sepolia, Polygon, Polygon Amoy, Arbitrum, Arbitrum Sepolia, Optimism, Optimism Sepolia, Base and Base Sepolia (chain IDs 1, 11155111, 137, 80002, 42161, 421614, 10, 11155420, 8453, 84532). On a chain outside that set the deploy fails with No Alchemy bundler URL for chain <id>, so use the forge create fallback there. Multi-provider coverage is tracked as FOU-991. On the forge path, the fulfiller label is still $COMPOSE_WALLET, so the task code is unchanged.
Funded EOA (zero-friction generate offer). Don't ask the user for a private key — OFFER to generate a throwaway funded EOA: generate it straight into a chmod-600 file the model never reads, showing only the address:
cast wallet new --json > /tmp/k.json
umask 077 && printf 'FUNDED_EOA_KEY=%s\n' "$(jq -r '.[0].private_key' /tmp/k.json)" > .eoa.env
cast wallet address "$(jq -r '.[0].private_key' /tmp/k.json)" # the ONLY thing ever printed
shred -u /tmp/k.json
Point the user at the right-chain faucet for the chosen chain, then OFFER to check funding with cast balance <address> --rpc-url <RPC> before deploying. If they decline, tell them plainly the alternative is deploying with their own funded wallet.
source .eoa.env # provides FUNDED_EOA_KEY; keep this in the SAME shell invocation as the command
forge create contracts/RandomnessConsumer.sol:RandomnessConsumer \
--rpc-url <RPC_URL> \
--private-key "$FUNDED_EOA_KEY" \
--broadcast \
--constructor-args $COMPOSE_WALLET
--broadcast is required (forge ≥1.0 dry-runs by default and deploys nothing while looking successful), and --constructor-args MUST be the last flag — it is greedy variadic, so putting it before --rpc-url / --private-key makes forge swallow them and dial localhost. The deployed address is on the Deployed to: line — capture it as $CONTRACT_ADDRESS.
Extract the bare ABI (required). forge build writes a full artifact {abi, bytecode, ...} to out/RandomnessConsumer.sol/RandomnessConsumer.json; compose codegen needs a bare ABI array, and feeding it the whole artifact fails with a warning while codegen still prints "✓ complete" (silent break — at runtime you hit "not a constructor"). Extract it:
jq .abi out/RandomnessConsumer.sol/RandomnessConsumer.json > src/contracts/RandomnessConsumer.json
(3) Run codegen. deployContract saved the ABI above (or the jq .abi extract did, on the forge path); codegen generates the typed evm.contracts.RandomnessConsumer class the task imports (the CLI's own success output says to):
goldsky compose codegen
(4) Wire the address and chain into code (Step 4). (5) Deploy the app so the wired task goes live (Step 7). A single deploy, not a redeploy: the wallet was created and contract deployed first, so this takes the wired task live.
Step 4 — Wire the contract address and chain into code
Three places must stay in sync. Use grep anchors — line numbers shift over time.
compose.yaml:
- Top-level
name:→"<app name>". - Inside the
onchain_eventtrigger:network:→"<chosen chain in snake_case>"andcontract:→"<CONTRACT_ADDRESS>".
src/tasks/fulfill-randomness.ts and src/tasks/request-randomness.ts (both):
const CONTRACT_ADDRESS = "0x..."→<CONTRACT_ADDRESS>.- The
evm.chains.baseSepoliareference insidenew evm.contracts.RandomnessConsumer(...)→evm.chains.<chosen chain in camelCase>.
Show a diff before applying, then apply with Edit.
Step 5 — Gas (deploy-your-own)
Compose-managed wallets default to sponsorGas: true, and the runtime bundler-union sponsorship covers all the chains this skill lists — Base / Base Sepolia and the forge-fallback chains (arbitrumSepolia, arbitrum, optimismSepolia, optimism). So $COMPOSE_WALLET needs no funding on any of them at runtime — skip wallet funding entirely. The only funded EOA this flow ever needs is the one that runs the forge create deploy on a non-Base chain (Step 3, Branch B → "Non-Base chains"), which is already covered there.
Public RPC endpoints for the fallback chains (use these in forge create / cast in place of <RPC_URL>):
arbitrumSepolia→https://sepolia-rollup.arbitrum.io/rpcarbitrum→https://arb1.arbitrum.io/rpcoptimismSepolia→https://sepolia.optimism.iooptimism→https://mainnet.optimism.io
Step 6 — Optional: publish to a new GitHub repo
git init
git add .
if git ls-files --cached | grep -qiE '(keypair\.json|\.env|private[._-]?key|\.pem|id_rsa)'; then
echo "⚠ Secret-shaped file is staged — remove it before committing. Aborting publish."
else
git commit -m "Initial commit: Compose VRF"
gh repo create <user's repo name> --<public|private> --source=. --push
fi
Step 7 — Deploy to Goldsky
goldsky compose deploy
First deploy may take 1–2 minutes. Watch for Deployed compose app: <app_name> and the HTTP task URLs in the output. (Branch A deploys for the first time here; Branch B also deploys for the first time here: the wallet was created and the contract deployed first in Step 3, so this single deploy takes the wired task live.)
Step 8 — Smoke test
Trigger a request against the deployed app. The primary trigger is the gas-sponsored goldsky compose writeContract — it calls requestRandomness() from the app's own Compose smart wallet (no token scavenger hunt, no funded EOA):
goldsky compose writeContract \
--chain-id 84532 \
--to $CONTRACT_ADDRESS \
--function "requestRandomness()"
(writeContract routes through the cloud's Alchemy bundler, which covers Ethereum, Sepolia, Polygon, Polygon Amoy, Arbitrum, Arbitrum Sepolia, Optimism, Optimism Sepolia, Base and Base Sepolia (chain IDs 1, 11155111, 137, 80002, 42161, 421614, 10, 11155420, 8453, 84532). On a chain outside that set the call fails with No Alchemy bundler URL for chain <id>, so use the cast send alternative below. Use --chain-id 84532 (Base Sepolia) or any of the supported chain IDs. It sends from the named/default gas-sponsored wallet. Auth: export GOLDSKY_API_TOKEN=<project API key> once in the shell (preferred: keeps the key out of argv and shell history), or pass -t "$GOLDSKY_PROJECT_KEY" per command, or use an active goldsky login session. Precedence: --token > GOLDSKY_API_TOKEN > ~/.goldsky/auth_token. On 401 it points you to Settings > API Keys. To get the request id, read nextRequestId on-chain and subtract 1.)
Alternatives (keep them labeled — the sponsored writeContract above is preferred on the recommended path):
- HTTP task (curl) — the
request_randomnesstask exercises the full request → event → fulfill path:curl -X POST \ -H "Authorization: Bearer $COMPOSE_TOKEN" \ "https://api.goldsky.com/api/admin/compose/v1/<app name>/tasks/request_randomness"
(goldsky compose callTask request_randomness '{}' hits the deployed app directly and handles auth from your goldsky login session or GOLDSKY_API_TOKEN. The cur
…(truncated)