How it works
- Sender approves the escrow contract to spend USDC, then calls
requestTransfer - Escrow contract pulls the funds in and emits a
TransferRequestedevent - Compose task is triggered by the event and screens the sender via the Webacy API
- Oracle wallet signs
approveTransfer(funds to the business wallet) orrejectTransfer(funds back to the sender), with gas sponsored - Collection stores an audit record of the screening result and the decision
- A separate reconcile cron runs every 5 minutes and alerts on transfers stuck in
Pending
The escrow contract’s
approveTransfer and rejectTransfer functions are restricted to a single oracle address fixed at deployment. That is the security model (only the oracle can release escrowed funds), but it also means there is no shared contract to point your app at. Every deployment binds its own oracle wallet, so you deploy your own contract in this guide.Prerequisites
- Goldsky CLI installed
- Foundry for contract deployment
- A Webacy API key (a demo key is available after signup)
- A small amount of Base Sepolia ETH to deploy the contracts (runtime transactions are gas-sponsored)
Project structure
Step 1: Set up the project
Create the project layout:IERC20, so install the contracts library:
foundry.toml:
tsconfig.json for the Compose tasks:
.gitignore containing .env, lib/, and .compose/. You will store a real private key in .env shortly.
Step 2: Write the escrow contract
ComplianceGatedTransfer.sol is a single-payee escrow. requestTransfer pulls USDC from the sender and records it as Pending. On approval, the funds go to the oracle (business) wallet; on rejection, they return to the sender. Both decision functions are onlyOracle:
MockUSDC.sol, a mintable 6-decimal ERC-20 with an open mint so you can fund test wallets freely:
Step 3: Create the oracle wallet and deploy the contracts
The oracle is a plain EOA. Its address becomes the contract’soracle at construction, and the same private key signs the Compose callbacks at runtime. If they don’t match, every callback reverts with not oracle. Generate one:
.env file in the project root (never commit this file):
cast balance shows zero.
Deploy MockUSDC first, then the escrow with the token and oracle addresses as constructor args:
Step 4: Configure the Compose app
Thecompose.yaml declares two secrets and two tasks: an on-chain event listener for TransferRequested and the reconcile cron. Fill in your escrow contract address:
src/lib/constants.ts. Fill in the same escrow address plus your MockUSDC address:
baseSepolia) and snake_case in the manifest (base_sepolia). Updating only one of them is the most common reason the task never fires.
Step 5: Add the screening library
src/lib/webacy.ts fetches Webacy’s risk report for an address and normalizes it into a pass/fail result. A sender fails when its overallRisk score is at or above RISK_THRESHOLD; tags with severity 2 or higher are surfaced as the triggered rules:
Step 6: Write the main task
src/tasks/on-transfer-requested.ts runs the whole decision flow: decode the event, screen the sender, sign the callback with the oracle key, and persist an audit record to the transfer-audits collection:
Key Compose features used
evm.decodeEventLog: decodes the raw event payload into typed args, no external ABI library neededevm.wallet({ privateKey, sponsorGas: true }): loads the oracle EOA from theORACLE_PRIVATE_KEYsecret with sponsored gas, so the wallet never needs native token at runtimecontext.fetch: HTTP client used by the Webacy screening callcollection: thetransfer-auditscollection is a durable, queryable audit trail keyed by transfer IDretry_config: transient Webacy or RPC failures retry the whole task (3 attempts with backoff)
viem, ethers, or other external packages in task code: evm, fetch, collection, env, and logger all come from the injected context. The only import from outside your project is compose itself, for types.
Step 7: Add the reconciliation cron
Escrow that silently holds funds is the failure mode to design against.src/tasks/reconcile.ts runs every 5 minutes, scans the contract for transfers still in Pending (a missed event or a failed callback) and logs an error so nothing sits in escrow unnoticed:
Step 8: Set the secrets
The deployed app needs the oracle key (to sign callbacks) and the Webacy key (to screen). Set both as Compose secrets:secrets: in compose.yaml (they do, from Step 4) for the values to be injected into ctx.env at runtime. See Wallets and secrets for details.
Step 9: Deploy to Goldsky
Deployed compose app: compliance-oracle, both the event listener and the reconcile cron are live.
Step 10: Test the flow
Drive the full flow from a separate sender wallet (not the oracle key). First mint test USDC to the sender (the MockUSDC has an openmint):
deposit received, screening complete ... risk score N, then transfer #0 APPROVED (or a rejection warning) with an oracleTxHash. Confirm on-chain that the transfer’s status is 1 (Approved) or 2 (Rejected), not 0:
A fresh testnet wallet has no on-chain history, so Webacy scores it low and the transfer is approved. To exercise the reject path, screen a known-flagged address or test on mainnet where real risk data exists.
Troubleshooting
approveTransfer/rejectTransferreverts withnot oracle. The Compose wallet’s address doesn’t match the contract’soracle. Both must derive from the sameORACLE_PRIVATE_KEY. Check thatcast call $CONTRACT_ADDRESS "oracle()(address)" --rpc-url $RPC_URLequalscast wallet address $ORACLE_PRIVATE_KEY.requestTransferreverts with “transfer amount exceeds allowance”. The sender didn’tapprovethe escrow to spend their USDC first.- Task never fires. Confirm the
contract:andnetwork:incompose.yamlmatch where you deployed, and thatchaininconstants.tsagrees. Check the trigger is active withgoldsky compose status. - Webacy returns an empty response. Check
WEBACY_API_KEYis set as a secret and valid. Transient failures are absorbed by the task’sretry_config. - Edits don’t take effect after redeploy. Stale bundle cache: run
rm -rf .compose/and redeploy. insufficient funds for gason deploy. Only the contract deploy needs gas on the oracle EOA. Fund it from a faucet; runtime callbacks are sponsored.
Going to production
Use Base mainnet and native USDC
Skip MockUSDC and deployComplianceGatedTransfer with native USDC on Base (0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913) as the _usdc constructor arg. Then update the chain in both places: chain: "base" in src/lib/constants.ts and network: "base" in compose.yaml. Real risk data also makes the reject path meaningful: flagged mainnet wallets will actually score above the threshold.
The security model
Each deployment binds one oracle address, fixed at construction. Only that key can release escrow, so never shareORACLE_PRIVATE_KEY, never commit it, and never log it. It exists only in your .env and as a Compose secret. If the key is compromised, rotate it: call setOracle from the old key with the new oracle address, update the ORACLE_PRIVATE_KEY secret, and redeploy.
Tune the risk threshold
RISK_THRESHOLD in constants.ts sets the cutoff on Webacy’s 0-100 scale. A lower threshold rejects more aggressively; where to set it depends on your risk policy. The transfer-audits collection keeps the full screening result (score and triggered rules) for every decision, so you can review past decisions when calibrating. See Compliance monitoring for guidance on screening policies.
Harden the reconciler
The demo cron scans every transfer from ID 0 on each run and only logs. In production, track a cursor in a collection so each run scans only new transfers, and consider having the reconciler re-screen and resolve stalePending transfers itself rather than just alerting.