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 compliance-oracle

ユーザー自身のGoldskyアカウント上に、コンプライアンス審査付きの決済システムを構築します。ComplianceGatedTransferコントラクトは、任意の送信者から支払いを受け付けてエスクローに保管し、TransferRequestedを発行します。Composeはこのイベントに反応して、コンプライアンスAPIを通じて送信者のウォレットを審査し、コールバックに署名します。approveTransferの場合は資金を指定された受取人に送り、rejectTransferの場合は送信者に返金します。すべての判定は永続的なtransfer-auditsコレクションに記録され、Pendingのまま滞留している送金はreconcileのcronで検出されます。ガス代はスポンサーされるため、オラクルウォレットにネイティブトークンは必要ありません。

2つの決済モデル(設定時に選択):

  • 単一受取人(決済ゲートウェイ/入金フロー) — 承認されたすべての資金は、デプロイ時に設定し、setRecipient()で変更できる、設定可能な単一のrecipientアドレスに送られます。受取人はオラクル署名者とは別です。
  • 複数受取人(P2P送金) — 送信者は、requestTransfer(amount, recipient)を通じて送金ごとに受取人を指定します。

3つのコンプライアンスプロバイダー विकल्प:

  • Webacy(推奨) — シンプルなREST APIと1つのAPIキーで利用できます。無料のデモキーは https://developers.webacy.co/ で登録してください。
  • モックモード — 許可/拒否リストをハードコードして使用します。APIキーは不要です。プロバイダーを決める前にフローをテストするのに適しています。
  • 独自プロバイダーを使用 — 任意のプロバイダーを使って実装できるよう、screenWallet()インターフェースを用意します。

このテンプレートが提供するのは、コンプライアンスアプリ固有の内容、つまりその仕組みと完全なソースコード(以下)のみです。VRFやbitcoin-oracleの例とは異なり、共通のデプロイ不要コントラクトはありません。approveTransfer/rejectTransferはonlyOracleで制限されています(これはセキュリティモデルであり、エスクローを解放できるのはオラクルだけです)。そのため、各インスタンスはデプロイ時に1つのオラクルアドレスに紐付けられます。各ユーザーは、自分のオラクルウォレットをoracleとして、自分専用のコントラクトをデプロイします。推奨手順は、デプロイするMockUSDCを使ったBase Sepoliaです。費用がかからず、ガス代も完全にスポンサーされます。本番環境ではメインネットに移行してください。ComposeがサポートするEVMチェーンであればどれでも動作しますが、無料でガス代がスポンサーされるBase Sepoliaを推奨します。

ステップ0a — まず基本スキルを読み込む

最初に行うことです。回答、質問、ファイルの雛形作成、コマンドの実行より前に、このテンプレートが依存する2つの基本スキルを読み込んでください。

  1. Skill(compose) — Composeの常時参照ガイドです。基本ルール(アプリについてユーザーの代わりに決めつけない。不明な点は質問する)と、一般的なビルドのガイダンスが記載されています。
  2. Skill(compose-reference) — マニフェスト/フィールド/APIのリファレンスです。compose.yamlやタスクファイルを作成する前に参照してください。

このテンプレートでは、これらのルールとリファレンスを意図的に省略しています。正しく構築するために必須の内容であり、ここでは再掲していません。両方を読み込むまで先に進まないでください。

モードの判定

利用可能なツールに基づいてモードを選択します。

  • deployComposeApp ツールを利用できる(Goldsky Web アプリのチャットボット)場合。 コンプライアンス機能は、アプリ内で完全にデプロイできるようになりました。アプリ内のフロー:Step 1 のインタビューを実行します(最初にアプリ名を確認)。walletCreate({ appName: "<chosen app name>", walletName: "compliance-oracle-wallet" }) を実行してオラクルウォレットのアドレスを取得します(appName には選択したアプリ名を渡し、walletName は明示的に指定します。ウォレット名を位置引数として appName に渡すと、アプリのスコープが誤って設定され、生成されたアドレスが実行時の evm.wallet({ name: "compliance-oracle-wallet" }) と一致しなくなります)。deployContract で MockUSDC をデプロイし、次に deployContract で ComplianceGatedTransfer をデプロイします。単一の支払先の場合は constructorArgs: ["<token_addr>", "<oracle-wallet-addr>", "<recipient_addr>"]、P2P の場合は constructorArgs: ["<token_addr>", "<oracle-wallet-addr>"] を指定します(これらはツールへの入力であり、CLI フラグではありません。sources と constructorArgs を指定します)。デプロイしたコントラクトのアドレスとチェーンをインラインのソースコードに設定し(Step 3)、deployComposeApp を実行します。WEBACY_API_KEY シークレットの設定は、ユーザーが行う最後の手順です。アプリ内デプロイではシークレットの検証が省略されるため、シークレットがなくても deployComposeApp は成功しますが、ユーザーが Compose アプリのダッシュボードでシークレットを追加し、ダッシュボードから再デプロイしてポッドに反映させるまで、アプリは動作しません(シークレットはデプロイ時にポッドに組み込まれ、ホットリロードされません)。チャットからシークレットを設定しようとしてはいけません。設計上、そのためのツールはありません。以下の**アプリ(完全なソース)**をもとに、インラインのソースコードをメモリー上で作成してください。
    • Web アプリモードでのテスト: デプロイが成功したら、必ずユーザーを積極的に案内してスモークテストを実施してください(Step 7)。スモークテストには goldsky compose writeContract(CLI コマンド)が必要です。コマンドを案内する前に、Goldsky CLI がインストール済みかユーザーに確認してください。未インストールの場合は、まずインストール手順を案内します。
      curl https://goldsky.com | sh
      goldsky compose install
      goldsky login
      
      その後、Step 7 のスモークテストのコマンドに進みます。スモークテストを省略したり、ユーザーから依頼されるまで待ったりしてはいけません。テストせずにデプロイすると、アプリが実際に動作するかユーザーにはわかりません。
  • Bash を利用できる(ローカル CLI/コーディングエージェント)場合。 以下の手順を直接実行し、出力を解析して、取得した値を後続のコマンドに設定します。
  • どちらも利用できない(参照情報のみの Q&A)の場合。 アプリの機能と、エスクロー → 審査 → 承認/却下のライフサイクルを説明します。手順を追ったサポートを求められた場合に限り、コマンドを一度に1つだけ出力し、ユーザーに実行結果を貼り付けてもらいます。ローカルで Bash を使って実行するには、npx skills add goldsky-io/goldsky-agent を案内してください。

エージェントによる変数の扱い

このスキルで $FOO とある場合は、直前のコマンド出力からその値をそのまま取得し、次のコマンドに直接設定します。別々の Bash ツール呼び出しの間でシェル変数が保持されることを前提にしてはいけません。呼び出しごとに新しいシェルが起動します。ただし、シークレット値は例外です。 WEBACY_API_KEY、ORACLE_PRIVATE_KEY(BYO キーの手順)、または *_KEY の値をコンテキストに取り込んだり、コマンドにリテラル値として設定したりしてはいけません。ユーザー自身に、シェルで値をエクスポートしてもらうか、アクセス権を chmod-600 に設定した .env に書き込んでもらい、1回の 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 の 2 番目のコンストラクター引数に渡してください。Compose タスクは evm.wallet({ name: "compliance-oracle-wallet", sponsorGas: true }) を使って同じウォレットを読み込みます。一致しない場合、すべての approveTransfer/rejectTransfer が not oracle でリバートします。ガスはスポンサーされるため、秘密鍵を管理する必要はありません。
  • ガススポンサー: oracle ウォレットは sponsorGas: true を使用するため、ガスがスポンサーされるチェーン(Base、Base Sepolia)では、ガス代としてネイティブトークンを必要としません。goldsky compose deployContract はガススポンサー付きの Compose ウォレット経由でデプロイするため、デプロイにも実行時のコールバックにも oracle の費用はかかりません。
  • WEBACY_API_KEY は実際の秘密情報です。 絶対に表示、コミット、ログ出力しないでください。Compose シークレットのみを使用し、コマンドやトランスクリプトにそのまま出力したり、文字列として置き換えたりしないでください。Advanced BYO-key の方法では、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 を絶対に実行しないでください。

Advanced:独自の oracle キーを使用する(本番環境)

デフォルトの方法では、管理が必要なキーのない、ガススポンサー付きの Compose スマートウォレット(compliance-oracle-wallet)を使用します。独自のキーを管理する必要がある場合(たとえば、自分で管理する本番用署名者など)は、代わりに独自の EOA 秘密鍵を使用できます。

  • ORACLE_PRIVATE_KEY を Compose シークレットとして設定します。goldsky compose secret set ORACLE_PRIVATE_KEY --value "$ORACLE_PRIVATE_KEY"(ユーザーが自身のシェルでエクスポートしてください。秘密鍵をそのまま貼り付けないでください)。このキーを表示、コミット、ログ出力しないでください。
  • compose.yaml で、ORACLE_PRIVATE_KEY を secrets: ブロックに追加し、api_version: "internal-pk-sponsored-otel" を設定してください(BYO 秘密鍵と sponsorGas を併用するには、内部チャネルが必要です)。
  • タスクのソースでは、名前指定の compliance-oracle-wallet の代わりに、evm.wallet({ privateKey: env.ORACLE_PRIVATE_KEY, sponsorGas: true }) を使ってウォレットを読み込みます。
  • コントラクトの oracle コンストラクター引数は cast wallet address $ORACLE_PRIVATE_KEY です。

これはデフォルトの方法ではありません。ほとんどのユーザーは Compose スマートウォレットを使用してください。

アプリ(ソース全体)

これがコンプライアンスアプリの完全なソースです。以下のファイルをそのまま作成してください(Step 0b ではディスクに書き込み、アプリ内フローではメモリ上に作成します)。クローンするものはありません。⚠ 以下の compose.yaml と src/lib/constants.ts に記載されているアドレス(contract: と contractAddress: はどちらもデフォルトでメインネットのデモ用エスクロー 0x39efE8A851A4Da22fa40828F6D4b3DC6b54545Aa を使用します)はプレースホルダーです。デプロイ前に Step 3 の接続設定が必須です。接続しない場合、すべてのコールバックが not oracle でリバートします。デプロイしたコントラクトのアドレスとチェーンを設定するために編集するのは、この2つのファイルだけにしてください。以下のソースはネイティブ USDC を使用する Base メインネット向けです。推奨の導入手順では、Step 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

Step 1 でのユーザーの選択に応じて、以下の単一受取人版または P2P 版を使用してください。 作成するのはどちらか一方だけです。

単一受取人版

承認されると、資金は設定可能な recipient アドレス(オラクル署名者とは別)に送られ、拒否されると送信者に返金されます。IERC20 インターフェースは2つの関数をインラインで定義しているため、コントラクトは自己完結しています。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

スクリーニングクライアントです。Webacy のアドレスリスクレポートを GET し、正規化された結果を返します。タスクでは、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");
}

プロバイダーの切り替え: 3つのプロバイダーはすべて、同じ screenWallet 関数シグネチャーと WalletScreeningResult 型をエクスポートします。タスクでは、選択されたプロバイダーからインポートします。切り替えるには、on-transfer-requested.ts のインポートを "../lib/webacy"、"../lib/mock-screener"、または "../lib/screener" に変更してください。

src/tasks/on-transfer-requested.ts

メインタスクです。イベントをデコードし、送信者をスクリーニングして、秘密鍵オラクルウォレットで approveTransfer/rejectTransfer に署名し(ガス代はスポンサーが負担)、監査記録を書き込みます。スクリーナーのインポートパスは、選択したプロバイダーによって異なります。 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 分ごとに実行される安全網となる cron です。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 を実行して(stable チャンネルでは冪等です)、再確認してください。コンプライアンス固有の確認事項:

  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 を代替として使う場合にのみ必要です(deployContract が CLI 内でコンパイルするため、forge は不要になりました)。
  3. OpenZeppelin のインストールは不要です。 すべてのコントラクトは自己完結型です。IERC20 はエスクローコントラクト内に 2 つの関数を持つインターフェースとしてインライン化されており、MockUSDC は単独で動作する ERC20 実装です。そのため、npm install を行わなくても、CLI の deployContract と Web アプリのアプリ内コンパイラーの両方でコントラクトをコンパイルできます。

ステップ 1 — 設定に関するヒアリング

/compose の基本ルールに従い、推測できないことだけを、一度に 1 つずつ質問します。まずアプリ名を尋ねます。

  1. 「アプリの名前は何にしますか?(候補:compliance-oracle)」 — ウォレットやコントラクトに関する手順より先に、必ず最初に尋ねます。名前は後から変更するのが難しいためです。名前はウォレット名のスコープとなり、すべての deployContract(ステップ 2)の CREATE2 ソルトにも使われるので、ここで確定する必要があります。迷っている場合はデフォルトの 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 キーは 1 つです。https://developers.webacy.co/ で登録すると、すぐにデモ用 API キーを作成できます。
    • モックモード — 許可/拒否リストをハードコードするため、API キーは不要です。プロバイダーを決める前にフローをテストするのに適しています。compose.yaml の secrets 配列から WEBACY_API_KEY を削除してください。
    • 独自の API を使用 — screenWallet() 関数のシグネチャを生成します。関数本体は自分で実装してください。
  4. 「どのチェーンを使用しますか?」 — Base Sepolia(おすすめ) — 無料で、ガス代がスポンサーされ、自分でテスト用 USDC を発行できます。Compose がサポートする EVM チェーンであれば、どれでも使用できます(テストネットは Ethereum Sepolia、Polygon Amoy、Arbitrum Sepolia など、メインネットは Base、Ethereum、Polygon、Arbitrum、Optimism など)。TS ではキャメルケース形式(baseSepolia)を、compose.yaml では compose のネットワーク名(base_sepolia)を使用します。
  5. 「リスクのしきい値はいくつにしますか?」 — リスクスコアは 0~100 です。スコアがしきい値以上の送信者は拒否されます。デフォルトは 50 です。src/lib/constants.ts で RISK_THRESHOLD を設定してください。
  6. 「OFAC/制裁対象のスクリーニングを有効にしますか?」 (Webacy のみ — モックモードと独自 API を使用する場合は省略してください。) Webacy は、ウォレットが OFAC やその他の制裁リストに掲載されているかを確認する、別の制裁対象チェックエンドポイントを提供しています。これは厳格な判定であり、制裁対象の場合はしきい値にかかわらず即座に拒否されます。規制対象のユースケースやフィンテックのユースケースでは有効にすることをおすすめします。デフォルトは false です。src/lib/constants.ts で SANCTIONS_CHECK_ENABLED を設定してください。同じ WEBACY_API_KEY を使用するため、追加の認証情報は不要です。
  7. 受取人(単一の受取人を指定する場合のみ) — 「承認された支払いの送金先アドレスは何ですか?」 今すぐアドレスを指定することも、テスト用にオラクルウォレットのアドレスを使用することもできます(最も簡単な方法は「ひとまずオラクルウォレットを使います」です)。この設定は、後からコントラクトの setRecipient() で変更できます。受取人はコンストラクターの 3 番目の引数になります。

ステップ 2 — Oracle ウォレットとコントラクトのデプロイ

オラクルは、compliance-oracle-wallet という名前付きの Compose スマートウォレットです。そのアドレスはコントラクトの oracle になり、Compose タスクは実行時に同じウォレットを使ってコールバックに署名します(ガス代はスポンサー提供)。まずウォレットを作成してアドレスを取得し、そのアドレスを oracle コンストラクター引数としてコントラクトをデプロイします。goldsky compose deployContract はガス代スポンサー付きの Compose ウォレット経由でデプロイするため、デプロイに資金を入れた EOA は必要ありません。

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

新しいシェルでウォレットを一覧表示し、アドレスを取得します(実行時にコントラクトの oracle と一致している必要があります)。

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

Base Sepolia(推奨):まず MockUSDC をデプロイし、そのアドレスをエスクローの _token 引数として使用します。両方のデプロイはガス代スポンサー付きの Compose ウォレット経由で行われます(Base Sepolia ではガス代がスポンサー提供されるため、資金を用意する必要はありません)。oracle には、上で取得した compliance-oracle-wallet のアドレスを指定します。(各 deployContract/writeContract にはプロジェクト API キーが必要です。認証方法:シェルで一度 GOLDSKY_API_TOKEN=<project API key> をエクスポートします(推奨:キーが argv やシェル履歴に残りません)。またはコマンドごとに -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 で終了し、stderr に {"error":true,"code":"...","message":"..."} を出力します。そのため、取得結果が空の場合は stderr を確認してください。

ユーザーが選択したチェーンのチェーン ID を使用してください(例:Base Sepolia は 84532、Base mainnet は 8453、Ethereum Sepolia は 11155111 など)。

各コントラクトの ABI は src/contracts/<Name>.json に自動保存されます。両方のデプロイで Address: が表示されたら、型付きコントラクトクラスを生成します(型チェックの前に必須です。tsconfig.json の paths エントリーは、codegen が生成する .compose/types.d.ts を参照しています)。

goldsky compose codegen

TypeScript のバージョン固定: インラインの tsconfig.json は TypeScript 5.x で型チェックできます。TS 6 では baseUrl が拒否されます。型チェックを実行する場合は、単独の tsc ではなく npx -y typescript@5 tsc にバージョンを固定してください。

Mainnet(本番環境): MockUSDC はスキップし、ユーザーが選択したチェーンのネイティブトークンのアドレスを使用します(例:Base の USDC は 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 です)。ユーザーにトークンコントラクトのアドレスを確認し、$TOKEN_ADDRESS としてそのまま使用してください。

次のステップで使う $CONTRACT_ADDRESS と $TOKEN_ADDRESS を取得し、ここでも goldsky compose codegen を実行してください。

ステップ 3 — コントラクトアドレスとチェーンをアプリに設定する

チェーン/アドレスを参照するファイルは 2 つあります。行番号は変わるため、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 が意図的にハードコードされています。テストネットのスクリーニングでは mainnet のレピュテーションデータを再利用するため、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 は念のための確認です。デフォルトのキー不要の方法では漏えいするオラクルキーはありません。上記の Advanced BYO-key の方法では、ORACLE_PRIVATE_KEY や秘密鍵を絶対にコミットしないでください。)

ステップ 5 — シークレットを設定する(デプロイ前)

Webacy モードのみ — モックモードと BYO モードでは WEBACY_API_KEY は不要です(ステップ 1 に従って、compose.yaml の secrets 配列から削除されていることを確認してください)。このステップはスキップしてください。実行中のタスクは WEBACY_API_KEY を使用して、Webacy AML API 経由で送信者のウォレットをスクリーニングします。まだ登録していない場合は、https://developers.webacy.co/ で登録し、API キーを作成してください。(デプロイ前にここでスタブキーを設定した状態で、フロー全体が動作することを確認済みなので、スモークテストまではプレースホルダーで問題ありません。)

  • CLI: goldsky compose deploy(ステップ 6)を実行する前にシークレットを設定してください。このコマンドはユーザー自身が実行する必要があります(エージェントはキーの値を決して取得したり、エコーバックしたりしてはいけません)。このコマンドをユーザーに示す際は、必ず -n $APP_NAME を含めてください — ユーザーがアプリのディレクトリにいるとは限りません:
    goldsky compose secret set WEBACY_API_KEY --value "$WEBACY_API_KEY" -n $APP_NAME
    
    ユーザーは自身のシェルで値をエクスポートするか、chmod-600 の .env に書き込んで source します。チャットに貼り付けたり、エコーバックしたりしてはいけません。compose deploy は、マニフェストで宣言されたすべてのシークレットが存在することを検証し、デプロイ時にポッドへ組み込むため、シークレットが未設定の場合は「The following secrets referenced in the manifest do not exist」というエラーでデプロイが 400 エラーになります。シークレットはホットリロードされません。デプロイ後にシークレットを設定または更新しても、ストレージに書き込まれるだけで、再デプロイしない限り実行中のポッドには反映されません。デプロイ後に設定する場合は、goldsky compose secret set WEBACY_API_KEY --value "$WEBACY_API_KEY" --redeploy を実行すると、シークレットの設定と再デプロイを一度に行えます。
  • アプリ内(チャットボット): アプリ内からのデプロイではシークレットの検証がスキップされるため、deployComposeApp(ステップ 6)はシークレットがなくても成功します。デプロイ後、Compose アプリのダッシュボードにあるアプリの Secrets ページで WEBACY_API_KEY を追加し、ポッドに反映させるためにダッシュボードから再デプロイしてください。設定するだけではアプリは動作しません。再デプロイが必要です。チャットからシークレットを設定しようとしないでください。設計上、そのためのツールはありません。

ステップ 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 セッションが必要です。認証:シェルで GOLDSKY_API_TOKEN=<project API key> を一度エクスポートする方法(推奨)、またはコマンドごとに -t "$GOLDSKY_PROJECT_KEY" を指定する方法があります。優先順位:--token > GOLDSKY_API_TOKEN > ~/.goldsky/auth_token。

MockUSDC のフロー(テストネット — 初回テストにおすすめ)

MockUSDC では mint() 関数が公開されているため、writeContract でテスト用トークンを無制限に作成できます。資金の準備は不要で、すべてのガス代がスポンサー負担です。

# 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 は msg.sender から transferFrom でトークンを引き出すため、送信者がオラクルである必要はありません。オラクルウォレットが実行時に署名するのは、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

mint の手順はありません — そのまま approve に進んでください。外部から送金した USDC は、すでにウォレットに入っています。Base では引き続きガス代がスポンサー負担です。

結果の確認

ログをストリーミングして、判定が反映される様子を確認します(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、続いて oracleTxHash を含む transfer #<id> APPROVED(または却下の警告)が表示されます。オンチェーンで確認してください。transfers(<id>) のステータスは 0 ではなく、1(承認)または 2(却下)になっているはずです。

cast call $CONTRACT_ADDRESS "transfers(uint256)(address,uint256,uint8)" <id> --rpc-url $RPC_URL
代替方法 — 資金を入金した自分の EOA から cast を実行する

別の送信者キー(オラクルではない $SENDER_KEY)を使ってフローを実行する場合、ガス代はスポンサー負担ではありません。まず資金を入金してください。ファウセット(例:https://www.coinbase.com/faucets/base-sepolia)から $SENDER_ADDRESS に Base Sepolia ETH を取得し、その後、次を実行します。

# 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 の結果が、goldsky compose wallet list に表示される compliance-oracle-wallet のアドレスと一致する必要があります。
  • requestTransfer が "transfer amount exceeds allowance" でリバートします。 送信者が、エスクローによる USDC の使用を事前に approve していません。requestTransfer の前に approve 呼び出しを実行してください。
  • 送金をリクエストしてもタスクが実行されません。 compose.yaml の contract: と network: がデプロイ先と一致していること、デプロイが成功していること、トリガーが有効であること(goldsky compose status)を確認してください。chain(constants.ts)または network(compose.yaml)のどちらか一方しか設定されていないことが、よくある原因です。
  • Webacy が空のレスポンスを返す/タスクでエラーが発生します。 WEBACY_API_KEY がシークレットとして設定され、有効であることと、アドレスが正しい形式の 16 進数であることを確認してください。一時的な障害は retry_config(3 回の試行、バックオフ)によって処理されます。
  • 拒否されるはずのケースで承認されます。 Base Sepolia では、新しいテストウォレットにはオンチェーンの履歴がないため、Webacy のスコアが低くなり、承認されます。既知のフラグ付きアドレスを使用するか、実際のリスクデータがあるメインネットで拒否パスをテストしてください。
  • No Alchemy bundler URL for chain <id> でデプロイに失敗します。 選択したチェーンはクラウドデプロイパスのバンドラー対応範囲外です(Ethereum、Sepolia、Polygon、Polygon Amoy、Arbitrum、Arbitrum Sepolia、Optimism、Optimism Sepolia、Base、Base Sepolia)。対応チェーンを選ぶか、自分で資金を用意した EOA でそのコントラクトをデプロイしてください。Compose ウォレットに資金を入れても解決しません。デプロイは EOA トランザクションではなく、スポンサー付き UserOp です。プロバイダー対応範囲の拡大は 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 を使用してください。
  • ゲート付き送金コントラクトを、実際の USDC を使って Base メインネットに最初にデプロイしないでください。まず Base Sepolia で MockUSDC を使ってください。

関連

  • /compose — Compose アプリをゼロから新規作成またはカスタマイズする方法、または Compose の説明です。
  • /compose-reference — マニフェスト、CLI、TaskContext API、ウォレット、ガススポンサー、コード生成です。
  • /compose-doctor — 動作しない Compose アプリを診断して修正します。
  • /auth-setup — goldsky login の手順です。

コメント