mcpField
C
compose-compliance-oracle

compose-compliance-oracle

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

关于 compose-compliance-oracle

Goldsky Compose 合规支付应用模板,面向需要在自己的账户中部署受合规审查保护的链上转账流程的开发者。它将托管合约、钱包筛查、批准或拒绝回调、审计记录和待处理转账核对组合起来,并支持单收款方或 P2P 模式。

主要功能

  1. 01托管转账并发出请求事件
  2. 02通过合规 API 筛查发送方钱包
  3. 03批准付款或退回资金
  4. 04保存转账审计记录
  5. 05定时核对待处理转账
  6. 06支持单收款方与 P2P 模式

适用场景

  1. 01搭建合规审查支付网关
  2. 02配置单一收款方的存款流程
  3. 03实现发送方指定收款人的 P2P 转账
  4. 04在测试网验证审批与退款流程

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/goldsky-io/goldsky-agent --skill compose-compliance-oracle

安装后在 Claude Code 中输入/compose-compliance-oracle确认已加载。

详细介绍

构建:Compose 合规性预言机

在用户自己的 Goldsky 账户下搭建一个由合规审核把关的支付系统。ComplianceGatedTransfer 合约接受任意发送者的付款,并将资金托管起来,同时发出 TransferRequested 事件。Compose 会响应该事件,通过合规 API 筛查发送者的钱包,然后签署回调:approveTransfer(资金转给指定收款人)或 rejectTransfer(资金退还给发送者)。每项决定都会写入持久化的 transfer-audits 集合,而 reconcile 定时任务会检查仍处于 Pending 状态的卡住转账。Gas 费用由系统赞助,预言机钱包无需持有原生代币。

两种支付模式(配置时选择):

  • 单一收款方(支付网关/存款流程)——所有获批资金都转入一个可配置的 recipient 地址。该地址在部署时设置,可通过 setRecipient() 更改。收款方与预言机签名者相互独立。
  • 多收款方(P2P 转账)——发送者在每次转账时通过 requestTransfer(amount, recipient) 指定收款人。

三种合规服务提供方选项:

  • Webacy(推荐)——简单的 REST API,只需一个 API 密钥。请访问 https://developers.webacy.co/ 注册以获取免费演示密钥。
  • 模拟模式——使用硬编码的允许/拒绝列表,无需 API 密钥。适合在确定服务提供方之前测试整个流程。
  • 自选服务提供方——为你搭建 screenWallet() 接口,你可以用任何服务提供方实现该接口。

此模板仅提供合规应用特有的内容——其工作方式以及完整源代码(见下文)。与 VRF 或 bitcoin-oracle 示例不同,这里没有可复用的无部署合约:approveTransfer/rejectTransfer 受 onlyOracle 限制(这是安全模型——只有预言机才能释放托管资金),因此每个实例都在部署时绑定到一个预言机地址。每位用户都要自行部署合约,并将自己的预言机钱包设为 oracle。推荐使用 Base Sepolia,并部署自己的 MockUSDC;这样既免费,也能完全赞助 Gas 费用。投入生产前,可再迁移到主网。Compose 支持的任何 EVM 链都能使用——推荐 Base Sepolia,因为它免费且由系统赞助 Gas 费用。

步骤 0a — 先加载基础技能

在进行任何其他操作之前——在回答、提问、搭建文件或运行任何命令之前——加载此模板所依赖的两项基础技能:

  1. Skill(compose) ——常驻的 Compose 指南:黄金规则(绝不擅自假设用户的应用需求;不确定时先提问)以及通用构建指南。
  2. Skill(compose-reference) ——清单/字段/API 参考;在编写任何 compose.yaml 或任务文件之前,先查阅此参考。

此模板特意省略了这些规则和参考内容——它们是正确构建所必需的,且不会在此处重复。两项技能都加载完成后再继续。

模式检测

根据你可用的工具选择模式:

  • 可使用 deployComposeApp 工具(Goldsky Web 应用聊天机器人)。 Compliance 现在完全在应用内完成部署。应用内流程:运行第 1 步访谈(先确定应用名称),调用 walletCreate({ appName: "<chosen app name>", walletName: "compliance-oracle-wallet" }) 获取预言机钱包地址(将 appName 设为选定的应用名称,并显式传入 walletName;将钱包名称作为位置参数传给 appName 会创建错误的应用作用域,生成的地址也永远无法与运行时的 evm.wallet({ name: "compliance-oracle-wallet" }) 匹配),部署 MockUSDC 的 deployContract,然后部署 ComplianceGatedTransfer 的 deployContract:单收款方使用 constructorArgs: ["<token_addr>", "<oracle-wallet-addr>", "<recipient_addr>"],P2P 使用 constructorArgs: ["<token_addr>", "<oracle-wallet-addr>"](这些是工具输入:sources 和 constructorArgs,不是 CLI 标志)。将已部署合约的地址和链写入内联源代码(第 3 步),然后调用 deployComposeApp。设置 WEBACY_API_KEY 密钥是用户的最后一步:应用内部署会跳过密钥验证,因此没有该密钥时 deployComposeApp 也会成功,但用户必须先在 Compose 应用的仪表板中添加密钥,再从仪表板重新部署,应用才能运行并让 Pod 获取密钥(密钥会在部署时写入 Pod,不会热重载)。绝不要尝试在聊天中设置密钥;按设计,这里没有相应工具。在内存中根据下方的**应用(完整源代码)**搭建内联源代码。
    • Web 应用模式下的测试: 部署成功后,务必主动引导用户完成冒烟测试(第 7 步)。冒烟测试需要使用 goldsky compose writeContract(CLI 命令)。提供命令前,先询问用户是否已安装 Goldsky CLI。如果尚未安装,先指导其安装:
      curl https://goldsky.com | sh
      goldsky compose install
      goldsky login
      
      然后继续执行第 7 步的冒烟测试命令。不要跳过冒烟测试,也不要等用户提出要求——不测试就部署会让用户无法确定应用是否真的能正常工作。
  • 可使用 Bash(本地 CLI / 编码 Agent): 直接执行以下步骤,解析输出,并将捕获的值代入后续命令。
  • 两者都不可用(纯参考问答): 说明应用的作用以及托管 → 筛查 → 批准/拒绝的生命周期;只有在用户要求分步指导时,才一次输出一条命令,并让用户粘贴输出结果。提示用户使用 npx skills add goldsky-io/goldsky-agent,以便在本地通过 Bash 运行。

Agent 的变量处理

本技能提到 $FOO 时,应从上一条命令的输出中获取字面值,并将其直接代入下一条命令。不要依赖 Shell 变量在不同的 Bash 工具调用之间持续存在——每次调用都会启动一个新的 Shell。例外:密钥值。 绝不要将 WEBACY_API_KEY、ORACLE_PRIVATE_KEY(自带密钥路径)或任何 *_KEY 值捕获到上下文中,也不要将其字面值代入命令。让用户在自己的 Shell 中导出该值(或自行将其写入权限设为 600 的 .env),并在单次 Bash 调用中通过 "$VAR" 展开引用——绝不要将其粘贴到聊天中,也不要回显给用户。

不可妥协的要求

  • 没有可共享、可复用的合约。 approveTransfer、rejectTransfer 和 setOracle 都是 onlyOracle(require(msg.sender == oracle)),且 oracle 在构造时固定。用户无法将自己的应用指向其他人部署的实例——只有该实例的 oracle 密钥才能签署有效回调。每位用户都应按照步骤 2 自行部署。(Base 主网上有一个较早的演示实例,地址为 0x39efE8A851A4Da22fa40828F6D4b3DC6b54545Aa,但它的 oracle 是固定密钥,其他人都不持有,因此仅供参考,不能复用。)
  • oracle 是 Compose 智能钱包 compliance-oracle-wallet。 其地址就是合约的 oracle 构造参数。通过 walletCreate({ appName: "<chosen app name>", walletName: "compliance-oracle-wallet" })(应用内;appName 是所选应用名称,walletName 为显式指定)或 goldsky compose wallet create compliance-oracle-wallet(CLI)获取地址,并将其作为第二个构造参数传给 ComplianceGatedTransfer。Compose 任务通过 evm.wallet({ name: "compliance-oracle-wallet", sponsorGas: true }) 加载同一个钱包。如果两者不匹配,每次调用 approveTransfer/rejectTransfer 都会因 not oracle 而回滚。Gas 由赞助方支付,无需管理私钥。
  • Gas 赞助: oracle 钱包使用 sponsorGas: true,因此在受支持的链(Base、Base Sepolia)上,无需持有原生代币支付 Gas。goldsky compose deployContract 通过 Gas 赞助的 Compose 钱包进行部署,因此部署和运行时回调都不会让 oracle 产生费用。
  • WEBACY_API_KEY 是真实密钥。 切勿打印、提交或记录它——只能使用 Compose secrets,绝不能将其回显或以字面值代入命令或对话记录。在高级 BYO 密钥路径中,对待 ORACLE_PRIVATE_KEY 也必须遵守同样的要求。
  • 任务代码中不要导入外部包。 evm、fetch、collection、env 和 logger 都来自注入的 context 参数。任务中唯一允许导入的是 compose(用于类型)和同级项目文件。
  • 运行 goldsky compose deployContract、goldsky compose deploy、goldsky compose secret set、git push 或 gh repo create 前,必须先展示完整命令并获得明确确认。

高级:自带 oracle 密钥(生产环境)

默认路径使用 Gas 由赞助方支付的 Compose 智能钱包(compliance-oracle-wallet),无需管理密钥。如果你需要自行保管密钥(例如,使用由你控制的生产签名者),可以改为自带 EOA 私钥:

  • 将 ORACLE_PRIVATE_KEY 设置为 Compose secret:goldsky compose secret set ORACLE_PRIVATE_KEY --value "$ORACLE_PRIVATE_KEY"(由用户在自己的 shell 中导出——切勿粘贴私钥字面值)。切勿打印、提交或记录该密钥。
  • 在 compose.yaml 中,将 ORACLE_PRIVATE_KEY 添加到 secrets: 块,并设置 api_version: "internal-pk-sponsored-otel"(BYO 私钥与 sponsorGas 组合使用时必须采用 internal 通道)。
  • 在任务源码中,使用 evm.wallet({ privateKey: env.ORACLE_PRIVATE_KEY, sponsorGas: true }) 加载钱包,而不是具名的 compliance-oracle-wallet。
  • 合约的 oracle 构造参数为 cast wallet address $ORACLE_PRIVATE_KEY。

这不是默认方案;大多数用户应使用 Compose 智能钱包。

应用(完整源码)

这是完整的合规应用。请按原样搭建这些文件(步骤 0b 会将它们写入磁盘;应用内流程会在内存中搭建它们)——无需克隆任何内容。⚠ 下方 compose.yaml 和 src/lib/constants.ts 中提供的地址(contract: 和 contractAddress: 默认均为主网演示托管合约 0x39efE8A851A4Da22fa40828F6D4b3DC6b54545Aa)是占位符——部署前必须完成步骤 3 的接线;若未接线,每个回调都会因 not oracle 而回退。只能编辑这两个文件,以接入你已部署的合约地址和链。下方源码指向使用原生 USDC 的 Base 主网——推荐的入门路径会在步骤 3 中将其切换为 Base Sepolia + MockUSDC。

compose.yaml
name: "compliance-oracle"
api_version: "stable"

secrets:
  - WEBACY_API_KEY

tasks:
  - name: "on_transfer_requested"
    path: "./src/tasks/on-transfer-requested.ts"
    retry_config:
      max_attempts: 3
      initial_interval_ms: 1000
      backoff_factor: 2
    triggers:
      - type: onchain_event
        network: "base"
        contract: "0x39efE8A851A4Da22fa40828F6D4b3DC6b54545Aa"
        events:
          - "TransferRequested(uint256,address,uint256)"

  - name: "reconcile"
    path: "./src/tasks/reconcile.ts"
    triggers:
      - type: cron
        expression: "*/5 * * * *"
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist",
    "baseUrl": ".",
    "paths": {
      "compose": [".compose/types.d.ts"]
    }
  },
  "include": ["src/**/*.ts"]
}
contracts/ComplianceGatedTransfer.sol

**根据用户在步骤 1 中的选择,使用下方的单收款方或 P2P 变体。**只搭建其中一个。

单收款方变体

批准后,资金会转给可配置的 recipient 地址(与预言机签名者分开);拒绝后,资金退回发送方。IERC20 接口直接内联(包含两个函数),因此合约是自包含的——无需安装 OpenZeppelin,并且可在 CLI 和 Web 应用的应用内编译器中编译。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

contract ComplianceGatedTransfer {
    enum Status { Pending, Approved, Rejected }

    struct Transfer {
        address sender;
        uint256 amount;
        Status status;
    }

    IERC20 public immutable token;
    address public oracle;
    address public recipient;
    uint256 public nextTransferId;

    mapping(uint256 => Transfer) public transfers;

    event TransferRequested(
        uint256 indexed id,
        address indexed sender,
        uint256 amount
    );
    event TransferApproved(uint256 indexed id);
    event TransferRejected(uint256 indexed id);
    event RecipientUpdated(address indexed oldRecipient, address indexed newRecipient);
    event OracleUpdated(address indexed oldOracle, address indexed newOracle);

    modifier onlyOracle() {
        require(msg.sender == oracle, "not oracle");
        _;
    }

    constructor(address _token, address _oracle, address _recipient) {
        token = IERC20(_token);
        oracle = _oracle;
        recipient = _recipient;
    }

    function requestTransfer(uint256 amount) external {
        require(amount > 0, "zero amount");
        token.transferFrom(msg.sender, address(this), amount);

        uint256 id = nextTransferId++;
        transfers[id] = Transfer({
            sender: msg.sender,
            amount: amount,
            status: Status.Pending
        });

        emit TransferRequested(id, msg.sender, amount);
    }

    function approveTransfer(uint256 id) external onlyOracle {
        Transfer storage t = transfers[id];
        require(t.status == Status.Pending, "not pending");
        t.status = Status.Approved;
        token.transfer(recipient, t.amount);
        emit TransferApproved(id);
    }

    function rejectTransfer(uint256 id) external onlyOracle {
        Transfer storage t = transfers[id];
        require(t.status == Status.Pending, "not pending");
        t.status = Status.Rejected;
        token.transfer(t.sender, t.amount);
        emit TransferRejected(id);
    }

    function setRecipient(address _recipient) external onlyOracle {
        emit RecipientUpdated(recipient, _recipient);
        recipient = _recipient;
    }

    function setOracle(address _oracle) external onlyOracle {
        emit OracleUpdated(oracle, _oracle);
        oracle = _oracle;
    }
}
多收款方(P2P)变体

发送方为每笔转账指定收款方。批准后,资金会转给该笔转账的 recipient;拒绝后,资金退回发送方。没有 setRecipient——每笔转账都包含自己的收款方。与单收款方变体一样,IERC20 接口直接内联。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
}

contract ComplianceGatedTransfer {
    enum Status { Pending, Approved, Rejected }

    struct Transfer {
        address sender;
        address recipient;
        uint256 amount;
        Status status;
    }

    IERC20 public immutable token;
    address public oracle;
    uint256 public nextTransferId;

    mapping(uint256 => Transfer) public transfers;

    event TransferRequested(
        uint256 indexed id,
        address indexed sender,
        address indexed recipient,
        uint256 amount
    );
    event TransferApproved(uint256 indexed id);
    event TransferRejected(uint256 indexed id);
    event OracleUpdated(address indexed oldOracle, address indexed newOracle);

    modifier onlyOracle() {
        require(msg.sender == oracle, "not oracle");
        _;
    }

    constructor(address _token, address _oracle) {
        token = IERC20(_token);
        oracle = _oracle;
    }

    function requestTransfer(uint256 amount, address _recipient) external {
        require(amount > 0, "zero amount");
        require(_recipient != address(0), "zero recipient");
        token.transferFrom(msg.sender, address(this), amount);

        uint256 id = nextTransferId++;
        transfers[id] = Transfer({
            sender: msg.sender,
            recipient: _recipient,
            amount: amount,
            status: Status.Pending
        });

        emit TransferRequested(id, msg.sender, _recipient, amount);
    }

    function approveTransfer(uint256 id) external onlyOracle {
        Transfer storage t = transfers[id];
        require(t.status == Status.Pending, "not pending");
        t.status = Status.Approved;
        token.transfer(t.recipient, t.amount);
        emit TransferApproved(id);
    }

    function rejectTransfer(uint256 id) external onlyOracle {
        Transfer storage t = transfers[id];
        require(t.status == Status.Pending, "not pending");
        t.status = Status.Rejected;
        token.transfer(t.sender, t.amount);
        emit TransferRejected(id);
    }

    function setOracle(address _oracle) external onlyOracle {
        emit OracleUpdated(oracle, _oracle);
        oracle = _oracle;
    }
}
contracts/MockUSDC.sol(仅限测试网/推荐路径)

原生 USDC 仅在主网上存在。在测试网上,先部署这个可铸造的 6 位小数 ERC-20,并将其地址用作托管合约的 _token 构造函数参数。开放 mint,以便在测试网上自由为发送方钱包注资。该合约是自包含的——无需导入 OpenZeppelin,因此可在 CLI 和 Web 应用的应用内编译器中编译。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract MockUSDC {
    string public name = "Mock USDC";
    string public symbol = "USDC";
    uint8 public constant decimals = 6;
    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    /// @notice Open mint — testnet only.
    function mint(address to, uint256 amount) external {
        totalSupply += amount;
        balanceOf[to] += amount;
        emit Transfer(address(0), to, amount);
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        _transfer(msg.sender, to, amount);
        return true;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        require(allowed >= amount, "insufficient allowance");
        if (allowed != type(uint256).max) {
            allowance[from][msg.sender] = allowed - amount;
        }
        _transfer(from, to, amount);
        return true;
    }

    function _transfer(address from, address to, uint256 amount) internal {
        require(balanceOf[from] >= amount, "insufficient balance");
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
    }
}
src/lib/constants.ts
import type { Hex } from "compose";

export const CONFIG = {
  chain: "base" as const,

  // PLACEHOLDER — mainnet demo escrow (reference-only). REPLACE with your
  // $CONTRACT_ADDRESS in Step 3, or every approveTransfer/rejectTransfer reverts `not oracle`.
  contractAddress: "0x39efE8A851A4Da22fa40828F6D4b3DC6b54545Aa" as Hex,

  // Native USDC on Base
  // https://basescan.org/token/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913
  usdcAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as Hex,

  usdcDecimals: 6,

};

// Webacy risk score threshold (0-100 scale)
// Transfers from wallets scoring above this are rejected
export const RISK_THRESHOLD = 50;

// OFAC/sanctions screening (separate Webacy endpoint)
// When enabled, sanctioned wallets are rejected immediately before the risk score check
export const SANCTIONS_CHECK_ENABLED = false;
src/lib/webacy.ts

筛查客户端。它会 GET Webacy 的地址风险报告并返回规范化结果;任务会拒绝任何 overallRisk 大于或等于 RISK_THRESHOLD 的发送方。当 SANCTIONS_CHECK_ENABLED 为 true 时,还会先检查 Webacy 制裁端点——若钱包受到制裁,则会立即拒绝,不评估风险评分。

import { TaskContext } from "compose";
import { SANCTIONS_CHECK_ENABLED } from "./constants";

export type WalletScreeningResult = {
  address: string;
  riskScore: number | null;
  passed: boolean;
  sanctioned: boolean;
  triggeredRules: string[];
};

type WebacyIssueTag = {
  name: string;
  description: string;
  severity: number;
  key: string;
};

type WebacyIssue = {
  score: number;
  tags: WebacyIssueTag[];
};

type WebacyResponse = {
  count: number;
  medium: number;
  high: number;
  overallRisk: number;
  addressType: string;
  issues: WebacyIssue[];
};

type WebacySanctionsResponse = {
  sanctioned: boolean;
};

const WEBACY_API_BASE = "https://api.webacy.com";

async function checkSanctions(
  address: string,
  apiKey: string,
  fetchFn: TaskContext["fetch"],
): Promise<boolean> {
  const data = await fetchFn<WebacySanctionsResponse>(
    `${WEBACY_API_BASE}/addresses/sanctioned/${address}`,
    { method: "GET", headers: { "x-api-key": apiKey } },
  );
  return data?.sanctioned ?? false;
}

export async function screenWallet(
  address: string,
  apiKey: string,
  riskThreshold: number,
  fetchFn: TaskContext["fetch"],
): Promise<WalletScreeningResult> {
  // --- Sanctions gate (hard reject, no threshold) ---
  if (SANCTIONS_CHECK_ENABLED) {
    const sanctioned = await checkSanctions(address, apiKey, fetchFn);
    if (sanctioned) {
      return {
        address,
        riskScore: null,
        passed: false,
        sanctioned: true,
        triggeredRules: ["OFAC/sanctions match"],
      };
    }
  }

  // --- Risk score check ---
  const url = `${WEBACY_API_BASE}/addresses/${address}?chain=base`;

  const data = await fetchFn<WebacyResponse>(url, {
    method: "GET",
    headers: { "x-api-key": apiKey },
  });

  if (!data) {
    throw new Error(`Webacy API returned empty response for ${address}`);
  }

  const riskScore = data.overallRisk ?? null;

  const triggeredRules: string[] = (data.issues ?? [])
    .flatMap((issue) => issue.tags ?? [])
    .filter((tag) => tag.severity >= 2)
    .map((tag) => tag.key);

  return {
    address,
    riskScore,
    passed: riskScore === null || riskScore < riskThreshold,
    sanctioned: false,
    triggeredRules,
  };
}
src/lib/mock-screener.ts(仅限模拟模式)

用户选择模拟模式时,请使用此文件替代 webacy.ts。它使用相同的 WalletScreeningResult 接口,无需 API 密钥。

import { TaskContext } from "compose";
import { SANCTIONS_CHECK_ENABLED } from "./constants";

export type WalletScreeningResult = {
  address: string;
  riskScore: number | null;
  passed: boolean;
  sanctioned: boolean;
  triggeredRules: string[];
};

// Add addresses to this set to simulate flagged/risky wallets
const DENY_LIST = new Set<string>([
  "0x000000000000000000000000000000000000dead",
  // Add your test "risky" addresses here (lowercase)
]);

// Add addresses to simulate sanctioned wallets (only checked when SANCTIONS_CHECK_ENABLED)
const SANCTIONS_LIST = new Set<string>([
  "0x0000000000000000000000000000000000000bad",
  // Add your test "sanctioned" addresses here (lowercase)
]);

export async function screenWallet(
  address: string,
  _apiKey: string,
  riskThreshold: number,
  _fetchFn: TaskContext["fetch"],
): Promise<WalletScreeningResult> {
  if (SANCTIONS_CHECK_ENABLED && SANCTIONS_LIST.has(address.toLowerCase())) {
    return {
      address,
      riskScore: null,
      passed: false,
      sanctioned: true,
      triggeredRules: ["OFAC/sanctions match"],
    };
  }

  const isRisky = DENY_LIST.has(address.toLowerCase());

  return {
    address,
    riskScore: isRisky ? 85 : 5,
    passed: !isRisky,
    sanctioned: false,
    triggeredRules: isRisky ? ["Deny list match"] : [],
  };
}
src/lib/screener.ts(仅限自带模式)

搭建此接口,供用户使用自己的提供商实现。它使用相同的 WalletScreeningResult 接口。

import { TaskContext } from "compose";

export type WalletScreeningResult = {
  address: string;
  riskScore: number | null;
  passed: boolean;
  sanctioned: boolean;
  triggeredRules: string[];
};

export async function screenWallet(
  address: string,
  _apiKey: string,
  riskThreshold: number,
  fetchFn: TaskContext["fetch"],
): Promise<WalletScreeningResult> {
  // TODO: Implement your screening logic here.
  // Use fetchFn (not fetch/axios) for any HTTP calls — Compose sandboxes network access.
  //
  // Return { passed: true } to approve, { passed: false } to reject.
  // riskScore and triggeredRules are for audit logging.
  throw new Error("Not implemented — replace with your screening provider");
}

**切换提供商:**三个提供商都导出相同签名的 screenWallet 函数和 WalletScreeningResult 类型。任务会从所选提供商导入——要切换提供商,请更改 on-transfer-requested.ts 中的导入:“../lib/webacy”、“../lib/mock-screener”或“../lib/screener”。

src/tasks/on-transfer-requested.ts

主任务。解码事件、筛查发送方、通过私钥预言机钱包签署 approveTransfer/rejectTransfer(由他人支付 Gas),并写入审计记录。**筛查器的导入路径取决于所选的提供方。**P2P 变体仅在事件类型和解码方式上有所不同——请参阅源代码后的说明。

import { TaskContext, OnchainEvent } from "compose";
import { CONFIG, RISK_THRESHOLD } from "../lib/constants";
import { screenWallet, WalletScreeningResult } from "../lib/webacy";

type TransferAuditRecord = {
  transferId: string;
  sender: string;
  amount: string;
  screening: WalletScreeningResult;
  decision: "approved" | "rejected";
  reason: string;
  depositTxHash: string;
  oracleTxHash: string;
  timestamp: string;
};

type TransferRequestedEvent = {
  eventName: "TransferRequested";
  args: { id: bigint; sender: string; amount: bigint };
};

function formatUsdc(raw: bigint): string {
  return `${(Number(raw) / 10 ** CONFIG.usdcDecimals).toFixed(2)} USDC`;
}

export async function main(ctx: TaskContext, payload: OnchainEvent) {
  const { evm, collection, env, fetch: ctxFetch } = ctx;
  const log = ctx.logger;

  // --- Step 1: Decode the onchain event ---

  const decoded = await evm.decodeEventLog<TransferRequestedEvent>(
    [{
      type: "event",
      name: "TransferRequested",
      inputs: [
        { name: "id", type: "uint256", indexed: true },
        { name: "sender", type: "address", indexed: true },
        { name: "amount", type: "uint256", indexed: false },
      ],
    }],
    payload,
  );

  const { id, sender, amount } = decoded.args;
  const transferId = id.toString();
  const depositTxHash = (payload as any).transaction_hash;

  log.info(`deposit received: ${formatUsdc(amount)}, from ${sender}`);

  // --- Step 2: Screen the depositor via Webacy ---

  log.info(`screening depositor ${sender}`);

  const screenResult = await screenWallet(sender, env.WEBACY_API_KEY, RISK_THRESHOLD, ctxFetch);

  log.info(`screening complete for ${sender}, risk score: ${screenResult.riskScore}`);

  // --- Step 3: Call back to the escrow contract ---

  const wallet = await evm.wallet({ name: "compliance-oracle-wallet", sponsorGas: true });

  let txHash: string;
  let decision: "approved" | "rejected";
  let reason: string;

  if (screenResult.sanctioned) {
    decision = "rejected";
    reason = "Sender is on OFAC/sanctions list";

    log.warn(`rejecting transfer #${transferId} — sender ${sender} is sanctioned, returning ${formatUsdc(amount)}`);

    const result = await wallet.writeContract(
      evm.chains[CONFIG.chain],
      CONFIG.contractAddress,
      "rejectTransfer(uint256)",
      [id],
    );
    txHash = result.hash;
  } else if (screenResult.passed) {
    decision = "approved";
    reason = `Sender score: ${screenResult.riskScore}. Below threshold ${RISK_THRESHOLD}.`;

    log.info(`approving transfer #${transferId} — ${formatUsdc(amount)} to vault wallet`);

    const result = await wallet.writeContract(
      evm.chains[CONFIG.chain],
      CONFIG.contractAddress,
      "approveTransfer(uint256)",
      [id],
    );
    txHash = result.hash;

    log.info(`transfer #${transferId} APPROVED`, {
      oracleTxHash: txHash,
      amount: formatUsdc(amount),
      sender,
    });
  } else {
    decision = "rejected";
    reason = `Sender flagged (score: ${screenResult.riskScore}, rules: ${screenResult.triggeredRules.join(", ")})`;

    log.warn(`rejecting transfer #${transferId} — returning ${formatUsdc(amount)} to ${sender}`);

    const result = await wallet.writeContract(
      evm.chains[CONFIG.chain],
      CONFIG.contractAddress,
      "rejectTransfer(uint256)",
      [id],
    );
    txHash = result.hash;
  }

  // --- Step 4: Persist audit record ---

  const audits = await collection<TransferAuditRecord>("transfer-audits");

  await audits.setById(transferId, {
    transferId,
    sender,
    amount: amount.toString(),
    screening: screenResult,
    decision,
    reason,
    depositTxHash,
    oracleTxHash: txHash,
    timestamp: new Date().toISOString(),
  });

  log.info(`audit record saved for transfer #${transferId}`);

  return { transferId, decision, reason, depositTxHash, oracleTxHash: txHash };
}

**P2P 变体的改动:**对于多收款方合约,请在 on-transfer-requested.ts 中更改事件类型和解码方式:

  • 事件类型:args: { id: bigint; sender: string; recipient: string; amount: bigint }
  • 在 ABI 输入中添加 { name: "recipient", type: "address", indexed: true }(位于 sender 和 amount 之间)
  • 解构:const { id, sender, recipient, amount } = decoded.args;
  • 将 compose.yaml 中的事件签名更新为 TransferRequested(uint256,address,address,uint256)

任务的其余部分(筛查、批准/拒绝、审计记录)完全相同——合约会将资金路由至 transfer.recipient。

src/tasks/reconcile.ts

安全网定时任务(每 5 分钟运行一次)。扫描合约,查找卡在 Pending 状态的转账——可能是主任务遗漏的事件,也可能是失败的回调——并记录警报,确保没有资金悄无声息地滞留在托管中。

import { TaskContext } from "compose";
import { CONFIG } from "../lib/constants";

export async function main(ctx: TaskContext) {
  const { evm } = ctx;

  // Use the oracle wallet for read calls (address must match the contract's oracle)
  const wallet = await evm.wallet({ name: "compliance-oracle-wallet", sponsorGas: true });

  // Read how many transfers exist on the contract
  const totalTransfers = await wallet.readContract<bigint>(
    evm.chains[CONFIG.chain],
    CONFIG.contractAddress,
    "nextTransferId() returns (uint256)",
    [],
  );

  // Check each transfer's status onchain
  // In production you'd track a cursor; for the demo, scan all
  let pendingCount = 0;
  const staleTransfers: number[] = [];

  for (let i = 0; i < Number(totalTransfers); i++) {
    const transfer = await wallet.readContract<[string, bigint, number]>(
      evm.chains[CONFIG.chain],
      CONFIG.contractAddress,
      "transfers(uint256) returns (address,uint256,uint8)",
      [i],
    );

    const status = transfer[2]; // 0 = Pending, 1 = Approved, 2 = Rejected
    if (status === 0) {
      pendingCount++;
      staleTransfers.push(i);
    }
  }

  const report = {
    timestamp: new Date().toISOString(),
    totalTransfers: Number(totalTransfers),
    pendingCount,
    staleTransferIds: staleTransfers,
    healthy: pendingCount === 0,
  };

  if (pendingCount > 0) {
    ctx.logger.error("stale pending transfers detected", report);
  } else {
    ctx.logger.info("reconciliation passed", report);
  }

  return report;
}
foundry.toml
[profile.default]
src = "contracts"
out = "out"
libs = ["lib"]

以下步骤是 Bash / 本地 CLI 流程。如果有可用的 deployComposeApp 工具(Web 应用聊天机器人),请改为按照上方“模式检测”中的应用内流程操作。

步骤 0b — 搭建项目框架

创建目录结构,并根据上方的 应用(完整源代码) 写入各个文件——无需克隆任何内容。

mkdir -p compliance-oracle/src/tasks compliance-oracle/src/lib compliance-oracle/contracts
cd compliance-oracle

根据上方源代码原样写入这些文件——根据用户在步骤 1 中的选择,选用正确的合约变体(单收款方或 P2P)和筛查提供方(webacy.ts、mock-screener.ts 或 screener.ts)。更新 on-transfer-requested.ts 中的导入,使其与所选提供方匹配。需要写入的文件:compose.yaml、tsconfig.json、foundry.toml、contracts/ComplianceGatedTransfer.sol(所选变体)、contracts/MockUSDC.sol(仅限测试网)、src/lib/constants.ts、所选的筛查库、src/tasks/on-transfer-requested.ts 和 src/tasks/reconcile.ts。然后在步骤 3 中配置已部署的地址和链。

添加 .gitignore:

.env
.compose/
cache/
lib/
out/
node_modules/

部署前检查

goldsky CLI 和身份验证检查是标准的 Compose 部署前检查(请参阅 /compose 和 /auth-setup)。如果 goldsky compose --version 输出 compose CLI is not installed,请运行 goldsky compose install(在稳定通道上可重复运行),然后再次检查。合规性专属检查:

  1. Compose CLI 版本(首先检查)。goldsky compose --version 必须输出 0.8.1 或更高版本,并且必须识别 deployContract/writeContract 命令——以下所有步骤(部署、密钥、冒烟测试)都依赖这些命令。运行:
    goldsky compose --version          # expect: goldsky compose 0.8.1
    goldsky compose deployContract --help >/dev/null 2>&1 && echo "deployContract OK" || echo "deployContract UNKNOWN — CLI too old"
    
    如果版本低于 0.8.1,或无法识别任一命令,请先征求确认,再运行 goldsky compose update(或使用指定版本 goldsky compose update 0.8.1),然后重新检查,再继续操作。
  2. cast — cast --version。如果未安装,请运行 curl -L https://foundry.paradigm.xyz | bash && foundryup 安装。仅在步骤 7 中使用 cast 作为替代方案(不再需要 forge——deployContract 会在 CLI 中编译)。
  3. **无需安装 OpenZeppelin。**所有合约都是独立的——托管合约中内联了一个包含两个函数的 IERC20 接口,而 MockUSDC 是独立的 ERC20 实现。因此,这些合约既可通过 CLI 的 deployContract 编译,也可通过 Web 应用的应用内编译器编译,无需运行任何 npm install。

第 1 步 — 配置访谈

根据 /compose 中的黄金规则,只询问无法推断的信息,每次问一个问题,并首先询问应用名称:

  1. “应用应该叫什么?(建议使用 compliance-oracle)” — 必须最先询问,不能先进行任何钱包或合约步骤。名称之后很难更改:它会限定命名钱包的作用域,并参与每个 deployContract 的 CREATE2 盐值(第 2 步),因此现在就必须确定。对方不置可否时,采用默认名称 compliance-oracle,并将其设为 compose.yaml 顶层的 name:。
  2. “这是单收款方系统(如支付网关或存款流程),还是多收款方系统(如 P2P 转账)?”
    • 单收款方(推荐入门使用) — 所有已批准的付款都会转至一个配置好的 recipient 地址。收款方与预言机是分开的,可通过 setRecipient() 在合约上单独更新。使用单收款方合约变体和 TransferRequested(uint256,address,uint256) 事件。
    • 多收款方(P2P) — 发送方在每次转账时指定收款方。使用 P2P 合约变体和 TransferRequested(uint256,address,address,uint256) 事件。
  3. “你想使用哪种合规/钱包筛查 API?”
    • Webacy(推荐) — 简单的 REST API,只需一个 API 密钥。在 https://developers.webacy.co/ 注册 — 注册后即可创建演示 API 密钥。
    • 模拟模式 — 使用硬编码的允许/拒绝名单,无需 API 密钥。适合在选定服务提供商之前测试流程。从 compose.yaml 的 secrets 数组中移除 WEBACY_API_KEY。
    • 自带方案 — 搭建 screenWallet() 函数签名;由你填写函数体。
  4. “使用哪条链?” — Base Sepolia(推荐) — 免费、由 Gas 赞助,并且你可以自行铸造测试 USDC。Compose 支持的任何 EVM 链都可以使用(测试网:Ethereum Sepolia、Polygon Amoy、Arbitrum Sepolia 等;主网:Base、Ethereum、Polygon、Arbitrum、Optimism 等)。在 TS 中使用 camelCase 形式(baseSepolia),在 compose.yaml 中使用 compose 网络名称(base_sepolia)。
  5. “风险阈值是多少?” — 风险评分范围为 0-100;评分达到或超过阈值的发送方将被拒绝。默认值为 50。在 src/lib/constants.ts 中设置 RISK_THRESHOLD。
  6. “是否要启用 OFAC/制裁筛查?” (仅限 Webacy — 模拟模式和自带方案请跳过。) Webacy 提供单独的制裁端点,用于检查钱包是否出现在 OFAC 或其他制裁名单中。这是一项硬性门槛(已受制裁 = 立即拒绝,不受阈值影响)。推荐用于受监管或金融科技场景。默认值为 false。在 src/lib/constants.ts 中设置 SANCTIONS_CHECK_ENABLED。使用相同的 WEBACY_API_KEY — 无需额外凭据。
  7. 收款方(仅限单收款方) — “已批准的付款应发送到哪个地址?” 对方可以现在提供地址,也可以使用预言机钱包地址进行测试(最简单 — “我暂时使用预言机钱包”)。之后可通过合约上的 setRecipient() 更改。收款方将成为第三个构造函数参数。

第 2 步 — Oracle 钱包与合约部署

Oracle 是一个命名的 Compose 智能钱包:compliance-oracle-wallet。它的地址将成为合约的 oracle,Compose 任务也会在运行时通过同一个钱包签署回调(由 Gas 赞助)。请先创建该钱包并记录其地址,然后使用该地址作为 oracle 构造函数参数部署合约。goldsky compose deployContract 会通过由 Gas 赞助的 Compose 钱包进行部署,因此无需为 EOA 充值。

# Create the oracle smart wallet and capture its address
ORACLE_ADDRESS=$(goldsky compose wallet create compliance-oracle-wallet --json | jq -r .address)

在新的 Shell 中列出钱包以获取地址(运行时该地址必须与合约的 oracle 一致):

goldsky compose wallet list
# → copy the compliance-oracle-wallet address as $ORACLE_ADDRESS

Base Sepolia(推荐):先部署 MockUSDC,然后将其地址用作托管合约的 _token 参数。两次部署都通过由 Gas 赞助的 Compose 钱包进行(Base Sepolia 提供赞助,因此无需充值)。oracle 是上面记录的 compliance-oracle-wallet 地址。(每次 deployContract/writeContract 调用都需要项目 API 密钥。身份验证:在 Shell 中导出一次 GOLDSKY_API_TOKEN=<project API key>(推荐:避免密钥出现在 argv 和 Shell 历史记录中),或者在每条命令中传入 -t "$GOLDSKY_PROJECT_KEY",也可以使用有效的 goldsky login 会话。优先级:--token > GOLDSKY_API_TOKEN > ~/.goldsky/auth_token。请在 Goldsky 控制面板的 Settings > API Keys 中生成密钥。)

# 1) MockUSDC - no constructor args (testnet only)
TOKEN_ADDRESS=$(goldsky compose deployContract contracts/MockUSDC.sol --chain-id $CHAIN_ID --json | jq -r .address)

# 2) ComplianceGatedTransfer - oracle is the compliance-oracle-wallet address;
#    constructor args depend on the payment model:

# Single-payee (3 args: token, oracle, recipient):
CONTRACT_ADDRESS=$(goldsky compose deployContract contracts/ComplianceGatedTransfer.sol \
  --chain-id $CHAIN_ID \
  --constructor-args $TOKEN_ADDRESS $ORACLE_ADDRESS $RECIPIENT_ADDRESS --json | jq -r .address)

# P2P (2 args: token, oracle):
CONTRACT_ADDRESS=$(goldsky compose deployContract contracts/ComplianceGatedTransfer.sol \
  --chain-id $CHAIN_ID \
  --constructor-args $TOKEN_ADDRESS $ORACLE_ADDRESS --json | jq -r .address)

--json 会禁用 ASCII 艺术字;如果失败,命令会以退出码 1 结束,并将 {"error":true,"code":"...","message":"..."} 输出到 stderr,因此如果捕获结果为空,请检查 stderr。

请使用用户所选链的链 ID(例如,Base Sepolia 为 84532,Base 主网为 8453,Ethereum Sepolia 为 11155111,等等)。

每个合约的 ABI 都会自动保存到 src/contracts/<Name>.json。两次部署都输出 Address: 后,生成类型化合约类(任何类型检查之前都必须执行此步骤——tsconfig.json 中的 paths 条目指向 .compose/types.d.ts,该文件由代码生成工具生成):

goldsky compose codegen

**TypeScript 版本固定:**内联的 tsconfig.json 可通过 TypeScript 5.x 进行类型检查;TS 6 会因 baseUrl 报错。如果要运行类型检查,请固定使用 npx -y typescript@5 tsc,不要直接运行 tsc。

**主网(生产环境):**跳过 MockUSDC,使用用户所选链上的原生代币地址(例如,Base 上的 USDC 地址为 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)。请向用户索取代币合约地址,并直接将其用作 $TOKEN_ADDRESS。

记录 $CONTRACT_ADDRESS 和 $TOKEN_ADDRESS 供下一步使用,然后也在此处运行 goldsky compose codegen。

第 3 步 — 在应用中配置合约地址和链

有两个文件引用了链和地址。请使用 grep 锚点,因为行号会变化。

src/lib/constants.ts:

  • chain: → 用户所选链的 camelCase 名称(例如,"baseSepolia"、"base"、"polygonAmoy" 等)。
  • contractAddress: → $CONTRACT_ADDRESS。
  • usdcAddress: → $TOKEN_ADDRESS(测试网上的 MockUSDC;主网上的原生代币)。

compose.yaml(在 on_transfer_requested 触发器中):

  • network: → 用户所选链对应的 Compose 网络名称(例如,"base_sepolia"、"base"、"polygon_amoy" 等)。
  • contract: → $CONTRACT_ADDRESS。

事件签名应与所选合约变体匹配:

  • 单收款方:TransferRequested(uint256,address,uint256)(已是默认值)
  • P2P:TransferRequested(uint256,address,address,uint256)

应用前先显示差异,然后使用 Edit 应用更改。

注意:src/lib/webacy.ts 在 Webacy 请求中硬编码了 ?chain=base,这是有意为之——测试网筛查会复用主网声誉数据,因此即使在 Base Sepolia 上也要保留 base(源码注释未说明这一点)。

第 4 步 — 可选:发布到新的 GitHub 仓库

git init
git add .
if git ls-files --cached | grep -qiE '(\.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 compliance-oracle"
  gh repo create <user's repo name> --<public|private> --source=. --push
fi

(第 0b 步中的 .gitignore 会排除 .env;grep 是额外的检查。默认的无密钥路径不会泄露 Oracle 密钥;对于上面的高级自带密钥路径,切勿提交 ORACLE_PRIVATE_KEY 或任何私钥。)

第 5 步 — 设置密钥(部署前)

仅限 Webacy 模式 — 模拟模式和自带模式不需要 WEBACY_API_KEY(请按照第 1 步,从 compose.yaml 的 secrets 数组中移除它),并跳过此步骤。运行中的任务会使用 WEBACY_API_KEY,通过 Webacy AML API 筛查发送方钱包。如果你还没有注册,请访问 https://developers.webacy.co/ 注册并创建 API 密钥。(已验证整个流程可用:部署前在此设置一个占位密钥即可,因此在冒烟测试之前使用占位值没有问题。)

  • CLI: 在运行 goldsky compose deploy(第 6 步)之前设置密钥。用户必须自行运行此命令(Agent 绝不能获取或复述密钥值)。向用户展示此命令时,始终包含 -n $APP_NAME — 用户可能不在应用目录中:
    goldsky compose secret set WEBACY_API_KEY --value "$WEBACY_API_KEY" -n $APP_NAME
    
    用户应在自己的 shell 中导出该值(或将其写入权限为 chmod-600 的 .env 文件并执行 source)— 绝不要将其粘贴到聊天中或复述。compose deploy 会验证清单中声明的每个密钥是否存在,并在部署时将其打包到 Pod 中;因此,未设置密钥会导致部署返回 400 错误,并提示 "The following secrets referenced in the manifest do not exist"。密钥不会热重载:部署后设置或更新密钥只会写入存储,若不重新部署,就不会传递到运行中的 Pod。对于部署后的情况,运行 goldsky compose secret set WEBACY_API_KEY --value "$WEBACY_API_KEY" --redeploy 可在一个步骤中设置密钥并触发重新部署。
  • 应用内(聊天机器人): 应用内部署会跳过密钥验证,因此不设置密钥也能成功执行 deployComposeApp(第 6 步)。部署后,在 Compose 应用的控制面板中打开应用的 Secrets 页面,添加 WEBACY_API_KEY,然后从控制面板重新部署,让 Pod 获取该密钥。仅设置密钥并不会让应用开始工作;必须重新部署。绝不要尝试在聊天中设置密钥;这是有意设计的,不提供此工具。

第 6 步 — 部署到 Goldsky

goldsky compose deploy

首次部署可能需要 1–2 分钟。留意 Deployed compose app: <the chosen app name>(例如 compliance-oracle)。on_transfer_requested 事件监听器和 reconcile cron 都会启动。

⚠ 必须:部署成功后,始终立即继续第 7 步(冒烟测试)。 不要只停留在“已部署”,也不要只提及密钥 — 用户需要看到自己的预言机端到端地处理一次转账。如果用户正在使用 Web 应用(没有 Bash 工具),请确认他们是否已安装 Goldsky CLI;如果没有,请指导他们安装(curl https://goldsky.com | sh && goldsky compose install && goldsky login),然后再提供冒烟测试命令。

第 7 步 — 冒烟测试

此步骤在 compose deploy(第 6 步)之后运行,此时应用的钱包已经创建。始终引导用户完成此步骤 — 不要跳过,也不要等用户提出要求。具体流程取决于你使用 MockUSDC(测试网)还是真实 USDC(主网)进行部署。

首先,获取应用的默认钱包地址 — 这是冒烟测试中的发送方。每条 wallet 和 writeContract 命令都必须包含 -n $APP_NAME(第 1 步中的应用名称,例如 compliance-oracle)。如果不指定 -n,CLI 会尝试在当前目录中查找 compose.yaml — 如果用户不在应用目录中,就会失败(Web 应用用户总是不在该目录中,CLI 用户离开应用目录后也经常如此)。

# create (or fetch) the wallet the writeContract calls will send from, and print its address
COMPOSE_WALLET=$(goldsky compose wallet create default -n $APP_NAME --json | jq -r .address)

(POST /:appName/wallets 会按需配置托管的 Postgres,并创建或解析钱包,因此无论应用部署前后都可使用。)

以下每条 writeContract 命令都需要 -t <project API key> 或有效的 goldsky login 会话进行身份验证:在 shell 中导出一次 GOLDSKY_API_TOKEN=<project API key>(推荐),或在每条命令中传入 -t "$GOLDSKY_PROJECT_KEY"。优先级:--token > GOLDSKY_API_TOKEN > ~/.goldsky/auth_token。

MockUSDC 流程(测试网——推荐首次测试时使用)

MockUSDC 提供开放的 mint() 函数,因此 writeContract 可以凭空创建测试代币。无需充值——所有 Gas 费用均由赞助支付。

# 1) Mint 1.00 mUSDC to the app wallet
goldsky compose writeContract -n $APP_NAME --chain-id $CHAIN_ID --to $TOKEN_ADDRESS \
  --function "mint(address,uint256)" --args $COMPOSE_WALLET 1000000

# 2) Approve the escrow contract to pull the app wallet's USDC
goldsky compose writeContract -n $APP_NAME --chain-id $CHAIN_ID --to $TOKEN_ADDRESS \
  --function "approve(address,uint256)" --args $CONTRACT_ADDRESS 1000000

# 3) Request a 1.00 USDC transfer — emits TransferRequested, triggering the oracle
goldsky compose writeContract -n $APP_NAME --chain-id $CHAIN_ID --to $CONTRACT_ADDRESS \
  --function "requestTransfer(uint256)" --args 1000000

为何无需预言机钱包也能正常运行:MockUSDC.mint 是开放的;approve 和 requestTransfer 都由同一个应用钱包发送,而 requestTransfer 会通过 transferFrom 从 msg.sender 拉取代币,因此发送方无需是预言机。预言机钱包只会在运行时为 approveTransfer/rejectTransfer 回调签名。

真实 USDC 流程(主网)

你无法铸造真实 USDC——必须先为应用钱包充值。

  1. 从任意外部钱包向 $COMPOSE_WALLET 发送真实 USDC(MetaMask、Coinbase、交易所提现等)。即使是 1.00 USDC 这样的小额也足以用于测试。
  2. USDC 到达 $COMPOSE_WALLET 后,运行 approve 和 requestTransfer:
# 1) Approve the escrow contract to pull USDC from the app wallet
goldsky compose writeContract -n $APP_NAME --chain-id $CHAIN_ID --to $TOKEN_ADDRESS \
  --function "approve(address,uint256)" --args $CONTRACT_ADDRESS 1000000

# 2) Request a 1.00 USDC transfer — emits TransferRequested, triggering the oracle
goldsky compose writeContract -n $APP_NAME --chain-id $CHAIN_ID --to $CONTRACT_ADDRESS \
  --function "requestTransfer(uint256)" --args 1000000

无需铸造步骤——直接跳到 approve。USDC 已通过外部转账存入钱包。在 Base 上,Gas 费用仍由赞助支付。

验证结果

流式查看日志并观察决策结果(直接运行 goldsky compose logs 只会一次性输出日志;添加 -f/--follow 可持续查看下一次触发):

goldsky compose logs -f

更好的免 Foundry 检查方式(可与 logs -f 同时运行,或替代它):

goldsky compose runs --task on_transfer_requested --since 5m --json
goldsky compose collections query transfer-audits

需要关注的内容:deposit received、screening complete ... risk score N,接着是 transfer #<id> APPROVED(或拒绝警告),并带有 oracleTxHash。在链上验证——transfers(<id>) 的状态应为 1(Approved)或 2(Rejected),而不是 0:

cast call $CONTRACT_ADDRESS "transfers(uint256)(address,uint256,uint8)" <id> --rpc-url $RPC_URL
替代方案——使用你自己已充值的 EOA 运行 cast

如果你想使用单独的发送方密钥($SENDER_KEY,而非预言机)来驱动流程,则不会获得 Gas 费用赞助,因此请先为其充值:从水龙头(例如 https://www.coinbase.com/faucets/base-sepolia)获取 Base Sepolia ETH 并发送到 $SENDER_ADDRESS,然后:

# mint is open — any funded key mints to the sender (MockUSDC only)
cast send $TOKEN_ADDRESS "mint(address,uint256)" $SENDER_ADDRESS 1000000 \
  --rpc-url $RPC_URL --private-key $SENDER_KEY   # 1.00 mUSDC
# approve the escrow, then request (ERC-20 needs prior approval)
cast send $TOKEN_ADDRESS "approve(address,uint256)" $CONTRACT_ADDRESS 1000000 \
  --rpc-url $RPC_URL --private-key $SENDER_KEY
cast send $CONTRACT_ADDRESS "requestTransfer(uint256)" 1000000 \
  --rpc-url $RPC_URL --private-key $SENDER_KEY

生成 $SENDER_KEY 时不要将其打印出来(使用单独充值的 EOA,而非预言机钱包):cast wallet new --json > /tmp/s.json; SENDER_KEY=$(jq -r '.[0].private_key' /tmp/s.json); SENDER_ADDRESS=$(cast wallet address "$SENDER_KEY"); echo "$SENDER_ADDRESS"; shred -u /tmp/s.json。

故障排查

  • error: Unknown command "deployContract". Did you mean command "deploy"?(或 writeContract 变体)。 Compose CLI 版本低于 0.8.1。运行 goldsky compose update(或 goldsky compose update 0.8.1),确认 goldsky compose --version 输出为 0.8.1 或更高版本,然后重试。
  • 重新部署后,compose.yaml 或源文件的更改未生效。 .compose/ 构建缓存已过期。运行 rm -rf .compose/,然后重新部署。
  • approveTransfer/rejectTransfer 因 not oracle 而回滚。 compliance-oracle-wallet 地址与合约的 oracle 不匹配。两者必须是同一个钱包。检查:cast call $CONTRACT_ADDRESS "oracle()(address)" --rpc-url $RPC_URL 的结果应与 compliance-oracle-wallet 地址相同(该地址可通过 goldsky compose wallet list 获取)。
  • requestTransfer 因 “transfer amount exceeds allowance” 而回滚。 发送方尚未先调用 approve,授权托管合约使用其 USDC。请先运行 approve 调用,再运行 requestTransfer。
  • 请求转账时,任务始终未触发。 确认 compose.yaml 中的 contract: 和 network: 与部署位置一致、部署成功,且触发器处于启用状态(goldsky compose status)。通常是只配置了 chain(constants.ts)或 network(compose.yaml)其中之一。
  • Webacy 返回空响应 / 任务抛出异常。 检查 WEBACY_API_KEY 是否已设为密钥且有效,并确认地址是格式正确的十六进制。临时故障会由 retry_config 自动处理(重试 3 次,间隔递增)。
  • 拒绝场景却批准了转账。 在 Base Sepolia 上,新测试钱包没有链上历史记录,因此 Webacy 给它的评分较低(判定通过)。请使用已知的标记地址,或在具有真实风险数据的主网上测试拒绝路径。
  • 部署因 No Alchemy bundler URL for chain <id> 而失败。 所选链不在云端部署路径的 Bundler 覆盖范围内(Ethereum、Sepolia、Polygon、Polygon Amoy、Arbitrum、Arbitrum Sepolia、Optimism、Optimism Sepolia、Base、Base Sepolia)。请选择受支持的链,或使用你自己已注资的 EOA 部署该合约。为 Compose 钱包注资没有帮助,因为该部署是由赞助的 UserOp 发起,而非 EOA 交易。更广泛的提供商覆盖范围对应 FOU-991。
  • 使用相同的合约 + 参数 + 应用重新运行 deployContract 时遭到拒绝(“This contract has already been deployed with this app...”)。 这是 CREATE2 的交易前拒绝,不会输出 Deploy Block:。地址由合约代码 + 构造函数参数 + 应用名称 + 项目确定,因此相同组合会生成相同地址。要进行全新部署,可更改构造函数参数、更改源代码或使用不同的应用名称。新名称不能仅大小写不同,也不能仅 -/_ 不同:名称会规范化(转为小写,将 [-_]+ 替换为 -),因此 my-app、My_App 和 my__app 会冲突,导致部署返回 409。对于不带参数的 MockUSDC,唯一可调整的是应用名称;对于 ComplianceGatedTransfer(参数为 (token, oracle) 或 (token, oracle, recipient)),也可以更改构造函数参数。⚠ 重命名应用会更改所有后续部署地址,并与 compose deploy 使用的应用名称冲突,因此在可行时优先调整构造函数参数;对于不带参数的合约,只能调整应用名称。

不要做的事

  • 不要让应用指向主网演示合约 0x39efE8A851A4Da22fa40828F6D4b3DC6b54545Aa(或任何你没有部署的合约)。其 oracle 是你不持有的固定密钥,因此每次回调都会因 not oracle 而回滚。请部署自己的合约。
  • 不要为合约的 oracle 构造函数参数使用与 compliance-oracle-wallet 不同的地址。两者必须是同一个钱包。
  • 不要在 Compose 任务代码中导入 viem、ethers 或任何外部软件包——请使用上下文中的 evm.decodeEventLog、evm.wallet 和 evm.chains。
  • 首次测试时,不要将门控转账合约部署到 Base 主网并使用真实 USDC——请先在 Base Sepolia 上使用 MockUSDC。

相关

  • /compose — 从头构建新的/自定义 Compose 应用,或说明 Compose 是什么。
  • /compose-reference — 清单、CLI、TaskContext API、钱包、Gas 赞助、代码生成。
  • /compose-doctor — 诊断并修复损坏的 Compose 应用。
  • /auth-setup — goldsky login 操作指南。

评论

支持 Markdown