mcpField
C
compose-vrf

compose-vrf

github.com/goldsky-io开发1247 次安装今天更新
代码仓库

关于 compose-vrf

Compose VRF 是用于搭建示例应用的技能:监听 EVM 合约的随机数请求,并用 drand 信标的可验证随机数完成请求。它支持 Base Sepolia 共享演示合约或用户自有合约,并提供 Compose 部署指引。

主要功能

  1. 01监听链上的随机数请求事件
  2. 02从 drand 获取可验证随机数
  3. 03向 EVM 合约提交随机数结果
  4. 04支持共享合约或用户自有合约
  5. 05提供应用源码与部署步骤

适用场景

  1. 01在 Base Sepolia 搭建可验证随机数演示
  2. 02将兼容的 EVM 合约连接到 drand 随机数
  3. 03配置用于随机数请求的 Compose 应用
  4. 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 之前——先加载此模板所依赖的两项基础技能:

  1. Skill(compose) — 常驻 Compose 指南:黄金法则(绝不替用户擅自假设应用的任何情况;不确定时应提问)和通用构建指南。
  2. 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。 这在此处是安全的:随机性之所以可信,仅仅是因为链上存储了 drand round 和 BLS signature,任何人都可以通过 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 — 配置访谈

一次只问一个问题;根据每个回答确定下一个问题。使用易读的标签,并自行将其转换为机器值。

  1. 应用名称 — 必须先询问此项,再进行任何钱包或合约步骤:"应用应该叫什么?(建议使用 vrf-app)"。名称之后很难更改:它决定命名钱包的作用域,并参与每次 deployContract 的 CREATE2 盐值。用户没有特别偏好时,使用默认值 vrf-app,并将其设为 compose.yaml 顶层的 name:(degit 脚手架中附带的是过时的 name: "compose-vrf",请覆盖它)。
  2. “使用哪条链?” — Base Sepolia(推荐),因为它有现成的、完全无权限限制的共享合约(无需部署任何内容)。其他链则需要部署自己的合约。在 TS 中使用 camelCase 格式(baseSepolia),在 compose.yaml 中使用 snake_case 格式(base_sepolia)。
  3. “RandomnessConsumer 合约?”(紧接着询问链)— 有两个选项:
    • 在 Base Sepolia 上复用共享合约(推荐) — 无需部署任何内容。使用 0x6273AB73C95Ba2233281F1eb8aa3b21D9352AD6d(在说明文字中提及此地址,不要放在任何选项标签中)。仅用于演示和入门,不适用于生产环境。
    • 部署我自己的合约 — 参见步骤 3,分支 B。(在 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/rpc
  • arbitrum → https://arb1.arbitrum.io/rpc
  • optimismSepolia → https://sepolia.optimism.io
  • optimism → 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 操作指南。

评论

支持 Markdown