Skip to main content
This guide walks you through building a compliance-gated payment gateway. A smart contract accepts a USDC payment and holds it in escrow. A Compose task screens the sender’s wallet against the Webacy AML risk API, then calls back on-chain to either approve the transfer (funds go to the business wallet) or reject it (funds return to the sender). Every decision is written to a durable collection as an audit trail, and a reconciliation cron catches any transfer left stuck in escrow. It demonstrates on-chain event triggers, secrets, private-key wallets with sponsored gas, external API calls, collection storage, and cron triggers. For more on how Goldsky supports AML and sanctions-screening workflows, see Compliance monitoring.

How it works

  1. Sender approves the escrow contract to spend USDC, then calls requestTransfer
  2. Escrow contract pulls the funds in and emits a TransferRequested event
  3. Compose task is triggered by the event and screens the sender via the Webacy API
  4. Oracle wallet signs approveTransfer (funds to the business wallet) or rejectTransfer (funds back to the sender), with gas sponsored
  5. Collection stores an audit record of the screening result and the decision
  6. 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:
The escrow contract imports OpenZeppelin’s IERC20, so install the contracts library:
Add a foundry.toml:
And a tsconfig.json for the Compose tasks:
Add a .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:
Native USDC only exists on mainnet, so for Base Sepolia also add 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’s oracle 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:
Save the private key in a .env file in the project root (never commit this file):
The oracle EOA only needs gas for these two contract deploys. Runtime callbacks are sponsored. Fund it with a small amount of Base Sepolia ETH from a faucet if cast balance shows zero. Deploy MockUSDC first, then the escrow with the token and oracle addresses as constructor args:
Save both deployed addresses. You’ll wire them into the app next.

Step 4: Configure the Compose app

The compose.yaml declares two secrets and two tasks: an on-chain event listener for TransferRequested and the reconcile cron. Fill in your escrow contract address:
The task code reads its chain and addresses from src/lib/constants.ts. Fill in the same escrow address plus your MockUSDC address:
The chain appears in both files, in different formats: camelCase in TypeScript (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:
Webacy is one screening provider. The same pattern works with any AML or sanctions API that scores an address. See Compliance monitoring for the broader screening patterns Goldsky supports.

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 needed
  • evm.wallet({ privateKey, sponsorGas: true }): loads the oracle EOA from the ORACLE_PRIVATE_KEY secret with sponsored gas, so the wallet never needs native token at runtime
  • context.fetch: HTTP client used by the Webacy screening call
  • collection: the transfer-audits collection is a durable, queryable audit trail keyed by transfer ID
  • retry_config: transient Webacy or RPC failures retry the whole task (3 attempts with backoff)
Don’t import 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:
Both names must appear under 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

The first deploy can take a minute or two. Once it reports 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 open mint):
Then approve the escrow as a spender and request the transfer (ERC-20 pulls require prior approval):
Watch the decision land in the logs:
You should see 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/rejectTransfer reverts with not oracle. The Compose wallet’s address doesn’t match the contract’s oracle. Both must derive from the same ORACLE_PRIVATE_KEY. Check that cast call $CONTRACT_ADDRESS "oracle()(address)" --rpc-url $RPC_URL equals cast wallet address $ORACLE_PRIVATE_KEY.
  • requestTransfer reverts with “transfer amount exceeds allowance”. The sender didn’t approve the escrow to spend their USDC first.
  • Task never fires. Confirm the contract: and network: in compose.yaml match where you deployed, and that chain in constants.ts agrees. Check the trigger is active with goldsky compose status.
  • Webacy returns an empty response. Check WEBACY_API_KEY is set as a secret and valid. Transient failures are absorbed by the task’s retry_config.
  • Edits don’t take effect after redeploy. Stale bundle cache: run rm -rf .compose/ and redeploy.
  • insufficient funds for gas on 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 deploy ComplianceGatedTransfer 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 share ORACLE_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 stale Pending transfers itself rather than just alerting.

Resources