compose-compliance-oracle について
Goldsky Composeで、自分のアカウントにコンプライアンス審査付きのオンチェーン送金をデプロイする開発者向けテンプレートです。エスクロー、ウォレット審査、承認・拒否コールバック、監査記録、保留中送金の照合を組み合わせ、単一宛先とP2Pに対応します。
主な機能
- 01送金をエスクローし要求イベントを発行
- 02コンプライアンスAPIで送信者のウォレットを審査
- 03支払いの承認または返金
- 04送金監査記録の保存
- 05保留中送金の定期照合
- 06単一宛先とP2Pモデルに対応
活用シーン
- 01コンプライアンス審査付き決済ゲートウェイの構築
- 02単一宛先への入金フローの設定
- 03送信者が宛先を指定するP2P送金の実装
- 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つの基本スキルを読み込んでください。
Skill(compose)— Composeの常時参照ガイドです。基本ルール(アプリについてユーザーの代わりに決めつけない。不明な点は質問する)と、一般的なビルドのガイダンスが記載されています。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 がインストール済みかユーザーに確認してください。未インストールの場合は、まずインストール手順を案内します。
その後、Step 7 のスモークテストのコマンドに進みます。スモークテストを省略したり、ユーザーから依頼されるまで待ったりしてはいけません。テストせずにデプロイすると、アプリが実際に動作するかユーザーにはわかりません。curl https://goldsky.com | sh goldsky compose install goldsky login
- Web アプリモードでのテスト: デプロイが成功したら、必ずユーザーを積極的に案内してスモークテストを実施してください(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 チャンネルでは冪等です)、再確認してください。コンプライアンス固有の確認事項:
- 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)を提案し、確認を得てから実行します。その後、先に進む前に再確認してください。 cast—cast --versionを実行します。見つからない場合は、curl -L https://foundry.paradigm.xyz | bash && foundryupでインストールします。ステップ 7 でcastを代替として使う場合にのみ必要です(deployContractが CLI 内でコンパイルするため、forgeは不要になりました)。- OpenZeppelin のインストールは不要です。 すべてのコントラクトは自己完結型です。
IERC20はエスクローコントラクト内に 2 つの関数を持つインターフェースとしてインライン化されており、MockUSDCは単独で動作する ERC20 実装です。そのため、npm installを行わなくても、CLI のdeployContractと Web アプリのアプリ内コンパイラーの両方でコントラクトをコンパイルできます。
ステップ 1 — 設定に関するヒアリング
/compose の基本ルールに従い、推測できないことだけを、一度に 1 つずつ質問します。まずアプリ名を尋ねます。
- 「アプリの名前は何にしますか?(候補:
compliance-oracle)」 — ウォレットやコントラクトに関する手順より先に、必ず最初に尋ねます。名前は後から変更するのが難しいためです。名前はウォレット名のスコープとなり、すべてのdeployContract(ステップ 2)の CREATE2 ソルトにも使われるので、ここで確定する必要があります。迷っている場合はデフォルトのcompliance-oracleを採用し、compose.yamlのトップレベルのname:に設定します。 - 「これは単一の受取人を指定するシステム(決済ゲートウェイや入金フローなど)ですか? それとも複数の受取人を指定するシステム(P2P 送金など)ですか?」
- 単一の受取人(まず始める場合におすすめ) — 承認されたすべての支払いを、設定済みの
recipientアドレスに送金します。受取人はオラクルとは別で、コントラクト上のsetRecipient()から個別に更新できます。単一受取人向けのコントラクトバリアントと、TransferRequested(uint256,address,uint256)イベントを使用します。 - 複数の受取人(P2P) — 送信者が送金ごとに受取人を指定します。P2P 向けのコントラクトバリアントと、
TransferRequested(uint256,address,address,uint256)イベントを使用します。
- 単一の受取人(まず始める場合におすすめ) — 承認されたすべての支払いを、設定済みの
- 「どのコンプライアンス/ウォレットスクリーニング API を使用しますか?」
- Webacy(おすすめ) — シンプルな REST API で、API キーは 1 つです。https://developers.webacy.co/ で登録すると、すぐにデモ用 API キーを作成できます。
- モックモード — 許可/拒否リストをハードコードするため、API キーは不要です。プロバイダーを決める前にフローをテストするのに適しています。
compose.yamlのsecrets配列からWEBACY_API_KEYを削除してください。 - 独自の API を使用 —
screenWallet()関数のシグネチャを生成します。関数本体は自分で実装してください。
- 「どのチェーンを使用しますか?」 — Base Sepolia(おすすめ) — 無料で、ガス代がスポンサーされ、自分でテスト用 USDC を発行できます。Compose がサポートする EVM チェーンであれば、どれでも使用できます(テストネットは Ethereum Sepolia、Polygon Amoy、Arbitrum Sepolia など、メインネットは Base、Ethereum、Polygon、Arbitrum、Optimism など)。TS ではキャメルケース形式(
baseSepolia)を、compose.yamlでは compose のネットワーク名(base_sepolia)を使用します。 - 「リスクのしきい値はいくつにしますか?」 — リスクスコアは 0~100 です。スコアがしきい値以上の送信者は拒否されます。デフォルトは
50です。src/lib/constants.tsでRISK_THRESHOLDを設定してください。 - 「OFAC/制裁対象のスクリーニングを有効にしますか?」 (Webacy のみ — モックモードと独自 API を使用する場合は省略してください。) Webacy は、ウォレットが OFAC やその他の制裁リストに掲載されているかを確認する、別の制裁対象チェックエンドポイントを提供しています。これは厳格な判定であり、制裁対象の場合はしきい値にかかわらず即座に拒否されます。規制対象のユースケースやフィンテックのユースケースでは有効にすることをおすすめします。デフォルトは
falseです。src/lib/constants.tsでSANCTIONS_CHECK_ENABLEDを設定してください。同じWEBACY_API_KEYを使用するため、追加の認証情報は不要です。 - 受取人(単一の受取人を指定する場合のみ) — 「承認された支払いの送金先アドレスは何ですか?」 今すぐアドレスを指定することも、テスト用にオラクルウォレットのアドレスを使用することもできます(最も簡単な方法は「ひとまずオラクルウォレットを使います」です)。この設定は、後からコントラクトの
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を含めてください — ユーザーがアプリのディレクトリにいるとは限りません:
ユーザーは自身のシェルで値をエクスポートするか、chmod-600 のgoldsky compose secret set WEBACY_API_KEY --value "$WEBACY_API_KEY" -n $APP_NAME.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 は発行できないため、最初にアプリウォレットへ入金する必要があります。
$COMPOSE_WALLETに実際の USDC を送金します。外部ウォレット(MetaMask、Coinbase、取引所からの出金など)を使用してください。テストには、1.00 USDC のような少額で十分です。- 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の手順です。