关于 compose-vrf
Compose VRF 是用于搭建示例应用的技能:监听 EVM 合约的随机数请求,并用 drand 信标的可验证随机数完成请求。它支持 Base Sepolia 共享演示合约或用户自有合约,并提供 Compose 部署指引。
主要功能
- 01监听链上的随机数请求事件
- 02从 drand 获取可验证随机数
- 03向 EVM 合约提交随机数结果
- 04支持共享合约或用户自有合约
- 05提供应用源码与部署步骤
适用场景
- 01在 Base Sepolia 搭建可验证随机数演示
- 02将兼容的 EVM 合约连接到 drand 随机数
- 03配置用于随机数请求的 Compose 应用
- 04将示例适配到用户自有合约
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ goldsky-io/ goldsky-agent --skill compose-vrf安装后在 Claude Code 中输入/compose-vrf确认已加载。
详细介绍
构建:Compose VRF
在用户自己的 Goldsky 账户下搭建 VRF 示例。该应用会监听 EVM 合约上的 RandomnessRequested(uint256,address) 事件,从 drand 信标获取可验证的随机数(BLS12-381 阈值签名,任何人都可以验证),并通过 fulfillRandomness(requestId, randomness, round, signature) 将其写回链上。信任来自存储的 drand round 和 signature,而非调用方,因此共享示例合约可以安全地保持开放。
此模板仅提供 VRF 应用特有的内容——其工作原理和完整源代码(见下文)。推荐方案使用 Base Sepolia 上共享且完全无需许可的 RandomnessConsumer,地址为 0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d,因此用户无需部署任何内容,Compose 智能钱包会自动创建并由赞助方支付 Gas。
步骤 0a — 先加载基础技能
在进行任何其他操作之前——在回复、提问、搭建文件或调用 deployComposeApp 之前——先加载此模板所依赖的两项基础技能:
Skill(compose)— 常驻 Compose 指南:黄金法则(绝不替用户擅自假设应用的任何情况;不确定时应提问)和通用构建指南。Skill(compose-reference)— 清单 / 字段 / API 参考;编写任何compose.yaml或任务文件前都应查阅。
此模板有意省略了这些规则和参考内容——它们是正确构建的必需内容,且不会在此重复。两项技能都加载完毕后再继续。
模式检测
根据可用工具选择模式:
-
可使用
deployComposeApp工具(Goldsky Web 应用聊天机器人)——这是首选的应用内流程。 不要输出goldsky终端命令或cliCommand卡片,也不要使用步骤 0b /degit/forge/goldsky compose deploy。应先用 2-3 句话作简要说明,然后通过askUser提出以下问题(使用recommendedIndex标记推荐选项):- 应用名称 — 首先询问,其他问题都不要先问:"应用应该叫什么?(建议使用
vrf-app)"。名称之后很难更改——它限定了具名钱包的作用域,并会参与每次deployContract的 CREATE2 盐值,因此必须在任何钱包或合约步骤之前确定。用户不置可否时接受默认值vrf-app,并将选定名称设为生成的compose.yaml顶层name:。 - 合约 — 明确询问,不要自行假设:"你有自己的
RandomnessConsumer风格合约吗?还是要使用 Base Sepolia 上的共享演示合约,以便快速运行?" 选项:“使用 Base Sepolia 上的共享演示合约(推荐——无需部署)”和“我会使用自己的合约。” 选择共享合约时,CONTRACT_ADDRESS是baseSepolia上的硬编码地址0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d——请逐字符照抄,不要凭记忆修改或重新输入;并在说明中提及它仅用于演示,不适用于生产环境。选择自有合约时,询问用户提供合约地址和链(其合约必须发出RandomnessRequested(uint256,address),并接受fulfillRandomness),并准确使用用户提供的内容。
Compose 智能钱包会在运行时自动创建,并在 Base Sepolia 上获得 Gas 赞助——绝不要告诉用户创建或充值钱包。选择共享合约时,无需设置 fulfiller(该合约无需许可),因此钱包可直接使用。提出问题后,根据下方的应用(完整源代码)在内存中生成这些文件,并传给
deployComposeApp(不要使用degit):compose.yaml、src/contracts/RandomnessConsumer.json(原样 ABI,代码生成必需)、src/lib/drand.ts以及三个任务文件。将顶层name:设为选定的应用名称(默认vrf-app);在两个任务文件的CONTRACT_ADDRESS和compose.yaml的contract:字段中设置共享地址(或用户提供的地址),并将evm.chains.*引用和触发器network:设为选定的链。生成文件前,请遵循/compose-reference中的清单格式和沙盒导入规则(根据/compose中的黄金规则——不要凭记忆构造清单)。然后在同一轮调用deployComposeApp;不要先让用户确认,也不要输出任何goldsky命令。在此模式下,忽略下方的步骤 0–8——它们是 CLI / 本地流程。 - 应用名称 — 首先询问,其他问题都不要先问:"应用应该叫什么?(建议使用
-
可使用
Bash(本地 CLI / 编码 Agent): 直接执行以下步骤,并解析输出以用于后续命令。 -
两者均不可用(纯参考问答): 说明应用的功能;如需分步指导,请引导用户使用
npx skills add goldsky-io/goldsky-agent,通过 Bash 在本地运行。
不可妥协事项
- Base Sepolia 上位于
0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d的共享合约完全不受权限限制——任何人都可以调用fulfillRandomness。 这在此处是安全的:随机性之所以可信,仅仅是因为链上存储了 drandround和 BLSsignature,任何人都可以通过 drand quicknet 公钥在链下验证它们。该合约仅用于入门和演示。请用文字告知用户:绝对不得在生产环境中使用,而且它仅存在于 Base Sepolia 上。 - 已部署的共享合约
0x6273AB…并未提供可用的fulfiller()/setFulfiller。 尽管上方的参考源码包含这些函数,但调用它们会在链上回滚,因为已部署的字节码与源码不同。这对应用没有影响(关键在于fulfillRandomness,而它不受权限限制),所以不要通过调用fulfiller()/setFulfiller来“验证”共享合约,也不要将这些调用回滚视为故障。 - 不要让应用指向
0x53...或0xE05Ceb3E269029E3bab46E35515e8987060D1027。 较早的演示合约受到权限限制(其fulfiller是固定地址,不是用户的 Compose 钱包),因此所有未经特殊配置的fulfillRandomness调用都会因OnlyFulfiller而回滚。共享的免部署合约是0x6273AB...,除此之外没有其他合约。 - 三个位置共用合约地址:
compose.yaml中的contract:字段,以及src/tasks/fulfill-randomness.ts和src/tasks/request-randomness.ts中的CONTRACT_ADDRESS。如果用户更改地址,必须同时修改这三个位置。 - 仅适用于自行部署的路径: 参考合约中的 drand 履约不受权限限制,但如果用户自己的合约限制了履约权限,授权的履约者必须是 Compose 钱包,否则每次
fulfillRandomness调用都会回滚。共享合约没有此类限制。 - 未经事先展示确切命令并获得明确确认,绝不要运行
goldsky compose deployContract、goldsky compose deploy、git push或gh repo create。 - 绝不要在命令行、聊天中展示的文件或任何文字记录输出中放置明文私钥或 API 密钥。 密钥应存放在权限为 chmod-600 的文件(
.env、.eoa.env)中,并且只能在单次 Bash 调用中通过 shell 展开(source+"$VAR")引用。当本技能写出$SOME_KEY时,它表示应在 shell 内执行 shell 展开,而不是捕获其值并替换进去。
应用(完整源码)
这是完整的 VRF 应用。在应用内流程中,在内存中逐字创建这些文件,并根据访谈结果设置地址和链。在 CLI 流程中,步骤 0b 会从 goldsky-io/documentation-examples 获取相同文件——但 脚手架中预置的默认值已经过时,部署前必须重新配置(步骤 4 是必需步骤,不可跳过)。具体而言,脚手架中的 compose.yaml 和两个任务文件都包含 name: "compose-vrf"(已过时——请将其替换为步骤 1 中选定的应用名称,默认值为 vrf-app),并且 CONTRACT_ADDRESS = 0xE05Ceb3E269029E3bab46E35515e8987060D1027——这是本技能禁止使用的权限受限合约。因此不要信任脚手架中的值;请在步骤 4 中重新配置所有这些值。推荐路径中,步骤 4 会填入上方源码中反复展示的共享免部署合约 0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d。
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 — codegen 所需)
请逐字写入此内容;合约 codegen 会读取它,以构建 evm.contracts.RandomnessConsumer。绝不要自行编造 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(仅用于自行部署路径)
参考合约。按设计,任何人都可以调用履行函数——请参阅 fulfillRandomness 上的 NatSpec。fulfiller 字段是部署时用于说明的标签,不是访问门槛。
// 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;
}
}
以下步骤 0–8 是 Bash / 本地 CLI 流程。如果有可用的
deployComposeApp工具(Web 应用聊天机器人),请勿执行这些步骤——请改用上文“模式检测”中的部署工具流程。
步骤 0b — 搭建示例项目
将 VRF 示例单独拉取到一个新目录(不含 Git 历史):
npx -y degit goldsky-io/documentation-examples/compose/VRF#6abe62878cb92f2569538ba9572b049ed5949a01 compose-vrf
cd compose-vrf
如果无法使用 npx -y degit,则改用稀疏克隆:
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
如果用户已克隆该示例,请跳过此步骤并进入该目录。无论哪种情况,除非用户要自行部署(步骤 3,分支 B),否则都应将两个任务文件中的 CONTRACT_ADDRESS 和 compose.yaml 中的 contract: 字段设为共享的免部署地址 0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d。
预检
如果 goldsky compose --version 输出 compose CLI is not installed,请运行 goldsky compose install(在 stable 通道上可幂等执行),然后重新检查。goldsky CLI、身份验证和 deno 检查是标准的 Compose 预检;请参阅 /compose 和 /auth-setup。身份验证:在 Shell 中导出一次 GOLDSKY_API_TOKEN=<project API key>(首选:避免密钥出现在 argv 和 Shell 历史记录中),或者在每条命令中传入 -t "$GOLDSKY_PROJECT_KEY",也可以使用已登录的 goldsky login 会话。优先级:--token > GOLDSKY_API_TOKEN > ~/.goldsky/auth_token。**版本检查(请先执行):**运行 goldsky compose --version;它会输出 goldsky compose 0.8.1(或更高版本)。如果版本低于 0.8.1,或 goldsky compose deployContract / goldsky compose writeContract 被报告为未知命令,说明 CLI 版本过旧,不支持此技能:请提出运行 goldsky compose update(或指定版本时运行 goldsky compose update 0.8.1),完成后重新检查 --version,在确认版本为 0.8.1 或更高版本之前不要继续。
VRF 专项说明,Foundry:在推荐路径中,不需要 Foundry(forge / cast)。deployContract 通过云端的 Alchemy bundler 执行,支持 Ethereum、Sepolia、Polygon、Polygon Amoy、Arbitrum、Arbitrum Sepolia、Optimism、Optimism Sepolia、Base 和 Base Sepolia(链 ID 为 1、11155111、137、80002、42161、421614、10、11155420、8453、84532)。如果链不在此集合中,部署会失败并显示 No Alchemy bundler URL for chain <id>,此时请使用 forge create 备用方案。多提供商支持情况由 FOU-991 跟踪。分支 B 中的 goldsky compose deployContract 自带编译器,步骤 8 的冒烟测试使用由 Gas 赞助的 goldsky compose writeContract。此 forge 备用方案或步骤 8 中基于 cast 的冒烟测试替代方案需要 Foundry,因此在推荐路径中,Foundry 是可选项。
步骤 1 — 配置访谈
一次只问一个问题;根据每个回答确定下一个问题。使用易读的标签,并自行将其转换为机器值。
- 应用名称 — 必须先询问此项,再进行任何钱包或合约步骤:"应用应该叫什么?(建议使用
vrf-app)"。名称之后很难更改:它决定命名钱包的作用域,并参与每次deployContract的 CREATE2 盐值。用户没有特别偏好时,使用默认值vrf-app,并将其设为compose.yaml顶层的name:(degit 脚手架中附带的是过时的name: "compose-vrf",请覆盖它)。 - “使用哪条链?” — Base Sepolia(推荐),因为它有现成的、完全无权限限制的共享合约(无需部署任何内容)。其他链则需要部署自己的合约。在 TS 中使用 camelCase 格式(
baseSepolia),在compose.yaml中使用 snake_case 格式(base_sepolia)。 - “RandomnessConsumer 合约?”(紧接着询问链)— 有两个选项:
- 在 Base Sepolia 上复用共享合约(推荐) — 无需部署任何内容。使用
0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d(在说明文字中提及此地址,不要放在任何选项标签中)。仅用于演示和入门,不适用于生产环境。 - 部署我自己的合约 — 参见步骤 3,分支 B。(在 Base Sepolia 以外的任何链上都必须这样做。)
- 在 Base Sepolia 上复用共享合约(推荐) — 无需部署任何内容。使用
步骤 2 — 钱包
- 共享合约路径(推荐): 无需操作。Compose 智能钱包会在运行时自动创建,并在 Base Sepolia 上获得全额 Gas 赞助;共享合约不受权限限制,因此无需授权任何履约方。不要告诉用户创建或为钱包充值。
- 部署自有合约路径(分支 B): 参考合约中的
fulfiller只是部署时用于说明的标签(履约不受权限限制),但构造函数仍需要将 Compose 钱包地址作为此标签的值。必须先创建钱包,再进行任何部署,作为步骤 3 中分支 B 顺序的第 (1) 步;将其地址保存为$COMPOSE_WALLET。命名钱包randomness-fulfiller与src/tasks/fulfill-randomness.ts中的evm.wallet({ name: "randomness-fulfiller" })对应。
步骤 3 — 合约
分支 A — 复用共享合约(推荐)。 $CONTRACT_ADDRESS = 0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d,位于 Base Sepolia 上。无需部署,也无需授权履约方。跳至步骤 4。
分支 B — 部署自己的合约。 分支 B 的顺序固定:创建钱包 → 部署合约 → 生成代码 → 接入(步骤 4)→ 部署应用(步骤 7)。 请按顺序完成以下步骤 (1)–(5)。参考合约位于上文的 应用(完整源代码) 部分(contracts/RandomnessConsumer.sol);deployContract 会自动将编译后的 ABI 写入 src/contracts/RandomnessConsumer.json,因此无需手动确认。
(1) 创建钱包(可在任何部署之前执行),并将其地址保存为 $COMPOSE_WALLET。该地址与 src/tasks/fulfill-randomness.ts 中的 evm.wallet({ name: "randomness-fulfiller" }) 对应:
goldsky compose wallet create randomness-fulfiller
⚠ 不要使用
wallet create --env local:那是本地 tevm 钱包,云端运行时不会使用它签名,最终会导致应用无法运行,而下文的“故障排除”部分也无法诊断此问题。请使用默认的(云端)钱包。命名钱包的作用域由compose.yaml顶层的name:限定,因此此时应用名称(步骤 1 中选择的名称,例如vrf-app)已确定;之后重命名会生成一个不同的钱包,与合约中记录的钱包不一致。
(2) 部署合约。 deployContract 会在 CLI 中编译,并通过由获得 Gas 赞助的 Compose 钱包签名的 CREATE2 代理进行部署,因此在 Base / Base Sepolia 上无需为 EOA 充值,也无需 RPC URL。构造函数参数会将 Compose 钱包地址记录为(仅用于说明的)履约方标签:
ADDR=$(goldsky compose deployContract contracts/RandomnessConsumer.sol \
--chain-id <CHAIN_ID> \
--constructor-args $COMPOSE_WALLET \
--wallet randomness-fulfiller \
--json | jq -r .address)
**身份验证:**在 shell 中导出一次 GOLDSKY_API_TOKEN=<project API key>(推荐:可避免密钥出现在 argv 和 shell 历史记录中),或在每条命令中传入 -t "$GOLDSKY_PROJECT_KEY",或使用有效的 goldsky login 会话。优先级:--token > GOLDSKY_API_TOKEN > ~/.goldsky/auth_token。遇到 401 时,会打印“Generate an API key from Settings > API Keys in the Goldsky dashboard”;如果没有 API 密钥,请在那里生成;向用户提议使用 -t 运行部署。
链 ID 和路由。deployContract 通过云端的 Alchemy bundler 进行路由,支持 Ethereum、Sepolia、Polygon、Polygon Amoy、Arbitrum、Arbitrum Sepolia、Optimism、Optimism Sepolia、Base 和 Base Sepolia(链 ID 为 1、11155111、137、80002、42161、421614、10、11155420、8453、84532)。如果链不在此集合中,部署会失败并显示 No Alchemy bundler URL for chain <id>,因此请在此类链上使用 forge create 备用方案。多提供商支持情况由 FOU-991 跟踪。
- 由部署赞助 → 使用
deployContract:ethereum→1,sepolia→11155111,polygon→137,polygonAmoy→80002,arbitrum→42161,arbitrumSepolia→421614,optimism→10,optimismSepolia→11155420,base→8453,baseSepolia→84532。
deployContract 会自动将 ABI 保存到 src/contracts/RandomnessConsumer.json。--json 会隐藏 ASCII 图案;如果失败,命令会以 1 退出,并在 stderr 中输出 {"error":true,"code":"...","message":"..."},因此如果捕获结果为空,请检查 stderr。设置 CONTRACT_ADDRESS=$ADDR。
非 Base 链(forge 备用方案)。deployContract 通过云端的 Alchemy bundler 进行路由,支持 Ethereum、Sepolia、Polygon、Polygon Amoy、Arbitrum、Arbitrum Sepolia、Optimism、Optimism Sepolia、Base 和 Base Sepolia(链 ID 为 1、11155111、137、80002、42161、421614、10、11155420、8453、84532)。如果链不在此集合中,部署会失败并显示 No Alchemy bundler URL for chain <id>,因此请在此类链上使用 forge create 备用方案。多提供商支持情况由 FOU-991 跟踪。在 forge 路径中,fulfiller 标签仍为 $COMPOSE_WALLET,因此任务代码无需更改。
**已注资的 EOA(零摩擦生成选项)。**不要向用户索要私钥——向用户提议生成一个临时的已注资 EOA:直接将其生成到一个权限为 chmod-600、模型无法读取的文件中,只显示地址:
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
为用户提供所选链对应的水龙头,然后提议在部署前使用 cast balance <address> --rpc-url <RPC> 检查资金。如果用户拒绝,请明确告知他们,另一种方式是使用自己的已注资钱包进行部署。
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(forge ≥1.0 默认会进行试运行,虽然看起来成功,但实际上不会部署任何内容),并且 --constructor-args 必须是最后一个标志——它是贪婪的可变参数,因此如果将它放在 --rpc-url / --private-key 之前,forge 会将这些参数吞掉并连接到 localhost。已部署的地址位于 Deployed to: 行中——将其捕获为 $CONTRACT_ADDRESS。
提取纯 ABI(必需)。forge build 会将完整构件 {abi, bytecode, ...} 写入 out/RandomnessConsumer.sol/RandomnessConsumer.json;compose codegen 需要纯 ABI 数组,如果将整个构件传入,就会失败并显示警告,而 codegen 仍会打印“✓ complete”(静默故障——运行时会遇到“not a constructor”)。提取 ABI:
jq .abi out/RandomnessConsumer.sol/RandomnessConsumer.json > src/contracts/RandomnessConsumer.json
(3) 运行 codegen。deployContract 已保存上述 ABI(或在 forge 路径中由 jq .abi 提取保存);codegen 会生成任务所导入的类型化 evm.contracts.RandomnessConsumer 类(CLI 自身的成功输出也会这样提示):
goldsky compose codegen
(4) 接线:将地址和链写入代码(步骤 4)。(5) 部署应用,使接线后的任务上线(步骤 7)。只需部署一次,无需重新部署:钱包和合约已事先创建并部署,因此这一步会使接线后的任务上线。
第 4 步 — 将合约地址和链写入代码
以下三个位置必须保持一致。请使用 grep 锚点——行号会随时间变化。
compose.yaml:
- 顶层的
name:→"<app name>"。 - 在
onchain_event触发器中:network:→"<chosen chain in snake_case>",contract:→"<CONTRACT_ADDRESS>"。
src/tasks/fulfill-randomness.ts 和 src/tasks/request-randomness.ts(两者均需修改):
const CONTRACT_ADDRESS = "0x..."→<CONTRACT_ADDRESS>。new evm.contracts.RandomnessConsumer(...)中的evm.chains.baseSepolia引用 →evm.chains.<chosen chain in camelCase>。
应用前先显示差异,然后使用 Edit 应用更改。
第 5 步 — Gas(自行部署)
Compose 管理的钱包默认启用 sponsorGas: true,运行时的 bundler 联合赞助涵盖了此技能列出的所有链——Base / Base Sepolia 以及 forge 回退链(arbitrumSepolia、arbitrum、optimismSepolia、optimism)。因此,$COMPOSE_WALLET 在运行时无需在任何链上充值——完全跳过钱包充值。此流程唯一需要充值的 EOA,是在非 Base 链上运行 forge create 部署的账户(第 3 步,分支 B →“非 Base 链”),该部分已在相应步骤中说明。
回退链的公共 RPC 端点(使用这些地址代替 forge create / cast 中的 <RPC_URL>):
arbitrumSepolia→https://sepolia-rollup.arbitrum.io/rpcarbitrum→https://arb1.arbitrum.io/rpcoptimismSepolia→https://sepolia.optimism.iooptimism→https://mainnet.optimism.io
第 6 步 — 可选:发布到新的 GitHub 仓库
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
第 7 步 — 部署到 Goldsky
goldsky compose deploy
首次部署可能需要 1–2 分钟。请留意输出中的 Deployed compose app: <app_name> 和 HTTP 任务 URL。(分支 A 在此处首次部署;分支 B 也在此处首次部署:钱包已创建,合约已在第 3 步部署,因此这次部署会使已接入的任务上线。)
第 8 步 — 冒烟测试
向已部署的应用发起请求。首选触发方式是由燃料费赞助的 goldsky compose writeContract——它会通过应用自身的 Compose 智能钱包调用 requestRandomness()(无需费力寻找代币,也无需为 EOA 充值):
goldsky compose writeContract \
--chain-id 84532 \
--to $CONTRACT_ADDRESS \
--function "requestRandomness()"
(writeContract 会通过云端的 Alchemy bundler 路由,支持 Ethereum、Sepolia、Polygon、Polygon Amoy、Arbitrum、Arbitrum Sepolia、Optimism、Optimism Sepolia、Base 和 Base Sepolia(链 ID 为 1、11155111、137、80002、42161、421614、10、11155420、8453、84532)。如果在不受支持的链上调用,将会失败并提示 No Alchemy bundler URL for chain <id>,此时请使用下方的 cast send 备选方式。使用 --chain-id 84532(Base Sepolia)或任意受支持的链 ID。该命令会通过指定的或默认的燃料费赞助钱包发送请求。身份验证:在 shell 中导出一次 GOLDSKY_API_TOKEN=<project API key>(首选:密钥不会出现在 argv 或 shell 历史记录中),或在每条命令中传入 -t "$GOLDSKY_PROJECT_KEY",或使用有效的 goldsky login 会话。优先级:--token > GOLDSKY_API_TOKEN > ~/.goldsky/auth_token。遇到 401 时,会提示你前往 Settings > API Keys。要获取请求 ID,请在链上读取 nextRequestId,然后减去 1。)
备选方式(请保留标签——在推荐流程中,首选上方由赞助方式调用的 writeContract):
- HTTP 任务(curl)——
request_randomness任务会完整执行请求 → 事件 → fulfill 流程: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 '{}' 会直接调用已部署的应用,并使用你的 goldsky login 会话或 GOLDSKY_API_TOKEN 处理身份验证。上方的 curl 命令是等效的原始调用方式,需要时可使用。添加 --env local 可调用 compose start 启动的服务器。)
- Cast(使用你自己的已充值 EOA)——直接调用合约:
cast send $CONTRACT_ADDRESS "requestRandomness()" \ --rpc-url <RPC_URL> \ --private-key "$PRIVATE_KEY" # from your own .env; never a pasted literal
等待 10–30 秒,让 Compose 获取事件,然后流式查看日志(-f 会持续跟踪;不加该参数时,logs 只会输出一次日志,通常会错过触发记录):
goldsky compose logs -f
另外,检查运行记录(Agent 以非交互方式运行,因此 runs 比查看实时流更可靠):
goldsky compose runs --task fulfill_randomness --since 5m --json
你应该会看到 fetched drand round <N> 和 fulfilled request <requestId> in tx <hash>。在链上验证:
cast call $CONTRACT_ADDRESS "isFulfilled(uint256)(bool)" <requestId> --rpc-url <RPC_URL>
# → true
故障排除
- 重新部署后,修改
compose.yaml或源文件没有生效。 本地.compose/资源包缓存已过期。运行rm -rf .compose/,然后重新部署。 - 调用
fulfillRandomness时出现OnlyFulfiller()回滚。 你将应用指向了一个有权限限制的合约(例如旧的0xE05Ceb…演示合约),而不是开放的0x6273AB…合约;或者你自己的合约将履约权限限制给了非 Compose 钱包的地址。请使用共享开放合约,或将合约的 fulfiller 设置为$COMPOSE_WALLET。 - 事件触发后,任务没有运行。 确认
compose.yaml中的contract:地址完全匹配且network:正确、部署成功,并且触发器处于活动状态(goldsky compose status)。 - 出现
insufficient funds for gas。 这只可能发生在非赞助链上。为$COMPOSE_WALLET充值。 - 获取 drand 失败。 默认 drand 端点是公开的。
compose.yaml中的重试配置(最多 3 次,带退避)可处理暂时性故障。如果持续失败,请检查 https://api.drand.sh/chains。 - 出现
error: Unknown command "deployContract". Did you mean command "deploy"?(或writeContract变体)。 Compose CLI 版本过旧。运行goldsky compose update(或goldsky compose update 0.8.1),然后再次确认goldsky compose --version显示0.8.1或更高版本,再重试。 deployContract因“This contract has already been deployed with this app…”而拒绝执行。 使用相同的合约、构造函数参数、应用和项目重新运行deployContract,会重新推导出相同的 CREATE2 地址,因此交易发送前就会被拒绝(不会打印Deploy Block:)。若要进行全新部署,请更改一个条件:使用不同的构造函数参数(RandomnessConsumer接受_fulfiller)、修改合约源代码,或更换应用名称。新名称不能只在大小写或-/_上有所不同:名称会经过规范化(转为小写,[-_]+->-),因此my-app、My_App和my__app会发生冲突,部署会返回 409。优先更改构造函数参数或源代码;重命名应用会改变所有后续部署地址,并且与compose deploy的应用名称冲突,因此这是影响最大的选项。
不要做的事
- 不要将应用指向有权限限制的
0xE05Ceb3E269029E3bab46E35515e8987060D1027演示合约——直接使用现成的履约方式会在该合约上回滚。共享的免部署合约地址为0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d。 - 除非用户明确要求切换 drand 网络,否则不要修改
src/lib/drand.ts中的 drand 常量。它们是链特定的 BLS 参数;参数错误会导致签名验证静默失败。 - 不要更改
compose.yaml中的事件签名RandomnessRequested(uint256,address)——它必须与合约匹配。 - 不要将共享的 Base Sepolia 合约用作生产目标——任何人都可以调用它进行履约。
- 不要向此应用添加
PRIVATE_KEY密钥。Compose 钱包会在运行时签名;在 Base / Base Sepolia 上,Branch B 部署也不需要 EOA(deployContract由 Gas 赞助)。上文的 forge 回退方案仅在非 Base 链上使用已充值的 EOA 进行部署——运行时绝不使用。
相关内容
/compose— 从头开始构建新的/自定义 Compose 应用,或说明 Compose 是什么。/compose-reference— 清单、CLI、TaskContext API、钱包、Gas 赞助、代码生成。/compose-doctor— 诊断并修复有问题的 Compose 应用。/auth-setup—goldsky login操作指南。