> ## Documentation Index
> Fetch the complete documentation index at: https://docs.goldsky.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a compliance oracle

> Build a compliance-gated USDC payment gateway using Compose and the Webacy AML API

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](https://developers.webacy.co) 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](/solutions/compliance-monitoring).

## How it works

```mermaid theme={"dark"}
flowchart LR
    A[Sender] -->|"requestTransfer"| B[Escrow Contract]
    B -->|"emit TransferRequested"| C[Compose Task]
    C -->|"screen sender"| D[Webacy AML API]
    D -->|"risk score"| C
    C -->|"approveTransfer / rejectTransfer"| B
    C -->|"audit record"| E[Collection]
```

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`

<Note>
  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.
</Note>

## Prerequisites

* [Goldsky CLI installed](/installation)
* [Foundry](https://book.getfoundry.sh/getting-started/installation) for contract deployment
* A [Webacy API key](https://developers.webacy.co) (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

```text theme={"dark"}
compliance-oracle/
├── compose.yaml                        # Compose configuration
├── tsconfig.json                       # TypeScript config
├── foundry.toml                        # Foundry config
├── contracts/
│   ├── ComplianceGatedTransfer.sol     # Escrow contract
│   └── MockUSDC.sol                    # Test USDC (Base Sepolia only)
├── src/
│   ├── lib/
│   │   ├── constants.ts                # Chain and contract config
│   │   └── webacy.ts                   # Webacy screening client
│   └── tasks/
│       ├── on-transfer-requested.ts    # Main screening task
│       └── reconcile.ts                # Safety-net cron
```

## Step 1: Set up the project

Create the project layout:

```bash theme={"dark"}
mkdir -p compliance-oracle/src/tasks compliance-oracle/src/lib compliance-oracle/contracts
cd compliance-oracle
```

The escrow contract imports OpenZeppelin's `IERC20`, so install the contracts library:

```bash theme={"dark"}
forge install OpenZeppelin/openzeppelin-contracts --no-commit
```

Add a `foundry.toml`:

```toml theme={"dark"}
[profile.default]
src = "contracts"
out = "out"
libs = ["lib"]
```

And a `tsconfig.json` for the Compose tasks:

```json theme={"dark"}
{
  "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"]
}
```

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`:

```solidity theme={"dark"}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

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

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

    IERC20 public immutable usdc;
    address public oracle;
    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 OracleUpdated(address indexed oldOracle, address indexed newOracle);

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

    constructor(address _usdc, address _oracle) {
        usdc = IERC20(_usdc);
        oracle = _oracle;
    }

    /// @notice User calls this to send a compliance-screened payment.
    ///         User must have approved this contract to spend `amount` of USDC first.
    ///         If approved, funds go to the oracle (business) wallet.
    function requestTransfer(uint256 amount) external {
        require(amount > 0, "zero amount");

        usdc.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);
    }

    /// @notice Oracle approves the transfer — funds go to the oracle (business) wallet.
    function approveTransfer(uint256 id) external onlyOracle {
        Transfer storage t = transfers[id];
        require(t.status == Status.Pending, "not pending");
        t.status = Status.Approved;
        usdc.transfer(oracle, t.amount);
        emit TransferApproved(id);
    }

    /// @notice Oracle rejects the transfer — funds returned to sender.
    function rejectTransfer(uint256 id) external onlyOracle {
        Transfer storage t = transfers[id];
        require(t.status == Status.Pending, "not pending");
        t.status = Status.Rejected;
        usdc.transfer(t.sender, t.amount);
        emit TransferRejected(id);
    }

    /// @notice Allow oracle address to be updated (for key rotation).
    function setOracle(address _oracle) external onlyOracle {
        emit OracleUpdated(oracle, _oracle);
        oracle = _oracle;
    }
}
```

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:

```solidity theme={"dark"}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockUSDC is ERC20 {
    constructor() ERC20("Mock USDC", "USDC") {}

    function decimals() public pure override returns (uint8) {
        return 6;
    }

    /// @notice Open mint — testnet only.
    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}
```

## 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:

```bash theme={"dark"}
cast wallet new
```

Save the private key in a `.env` file in the project root (never commit this file):

```env theme={"dark"}
ORACLE_PRIVATE_KEY=0x_your_oracle_private_key
RPC_URL=https://sepolia.base.org
WEBACY_API_KEY=your_webacy_api_key
```

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](https://www.alchemy.com/faucets/base-sepolia) if `cast balance` shows zero.

Deploy MockUSDC first, then the escrow with the token and oracle addresses as constructor args:

```bash theme={"dark"}
source .env
ORACLE_ADDRESS=$(cast wallet address "$ORACLE_PRIVATE_KEY")

# 1) MockUSDC
forge create contracts/MockUSDC.sol:MockUSDC \
  --rpc-url "$RPC_URL" --private-key "$ORACLE_PRIVATE_KEY" --broadcast
# save "Deployed to:" as USDC_ADDRESS

# 2) ComplianceGatedTransfer(usdc, oracle)
forge create contracts/ComplianceGatedTransfer.sol:ComplianceGatedTransfer \
  --rpc-url "$RPC_URL" --private-key "$ORACLE_PRIVATE_KEY" --broadcast \
  --constructor-args "$USDC_ADDRESS" "$ORACLE_ADDRESS"
# save "Deployed to:" as CONTRACT_ADDRESS
```

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:

```yaml theme={"dark"}
name: "compliance-oracle"
api_version: "stable"

secrets:
  - ORACLE_PRIVATE_KEY
  - WEBACY_API_KEY

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

  - path: "./src/tasks/reconcile.ts"
    name: "reconcile"
    triggers:
      - type: "cron"
        expression: "*/5 * * * *"
```

The task code reads its chain and addresses from `src/lib/constants.ts`. Fill in the same escrow address plus your MockUSDC address:

```typescript theme={"dark"}
import type { Hex } from "compose";

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

  // Your deployed ComplianceGatedTransfer contract
  contractAddress: "0xYOUR_ESCROW_CONTRACT_ADDRESS" as Hex,

  // Your MockUSDC on Base Sepolia (native USDC on mainnet)
  usdcAddress: "0xYOUR_USDC_ADDRESS" as Hex,

  usdcDecimals: 6,
};

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

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:

```typescript theme={"dark"}
import { TaskContext } from "compose";

export type WalletScreeningResult = {
  address: string;
  riskScore: number | null;
  passed: 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[];
};

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

export async function screenWallet(
  address: string,
  apiKey: string,
  riskThreshold: number,
  fetchFn: TaskContext["fetch"],
): Promise<WalletScreeningResult> {
  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.name);

  return {
    address,
    riskScore,
    passed: riskScore === null || riskScore < riskThreshold,
    triggeredRules,
  };
}
```

Webacy is one screening provider. The same pattern works with any AML or sanctions API that scores an address. See [Compliance monitoring](/solutions/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:

```typescript theme={"dark"}
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) / 1e6).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({ privateKey: env.ORACLE_PRIVATE_KEY, sponsorGas: true });

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

  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 };
}
```

### 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:

```typescript theme={"dark"}
import { TaskContext } from "compose";
import { CONFIG } from "../lib/constants";

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

  // Use the oracle private key for read calls (address must match the contract's oracle)
  const wallet = await evm.wallet({ privateKey: ctx.env.ORACLE_PRIVATE_KEY });

  // 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;
}
```

## 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:

```bash theme={"dark"}
source .env
goldsky compose secret set ORACLE_PRIVATE_KEY --value "$ORACLE_PRIVATE_KEY"
goldsky compose secret set WEBACY_API_KEY --value "$WEBACY_API_KEY"
```

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](/compose/secrets) for details.

## Step 9: Deploy to Goldsky

```bash theme={"dark"}
goldsky compose deploy
```

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`):

```bash theme={"dark"}
cast send $USDC_ADDRESS "mint(address,uint256)" $SENDER_ADDRESS 1000000 \
  --rpc-url $RPC_URL --private-key $ORACLE_PRIVATE_KEY   # 1.00 USDC
```

Then approve the escrow as a spender and request the transfer (ERC-20 pulls require prior approval):

```bash theme={"dark"}
cast send $USDC_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
```

Watch the decision land in the logs:

```bash theme={"dark"}
goldsky compose 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`:

```bash theme={"dark"}
cast call $CONTRACT_ADDRESS "transfers(uint256)(address,uint256,uint8)" 0 --rpc-url $RPC_URL
```

<Note>
  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.
</Note>

## 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](/solutions/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

* [Compose introduction](/compose/introduction)
* [Task triggers](/compose/task-triggers)
* [Wallets and secrets](/compose/secrets)
* [Collections](/compose/context/collections)
* [Compliance monitoring](/solutions/compliance-monitoring)
* [Webacy developer docs](https://developers.webacy.co)


## Related topics

- [Stablecoin compliance & AML monitoring](/solutions/compliance-monitoring.md)
- [Build a Bitcoin price oracle](/compose/guides/build-a-bitcoin-oracle.md)
- [Build a multi-chain NAV oracle](/compose/guides/build-a-nav-oracle.md)
- [Agent Skills](/ai-skills.md)
- [Build Offchain-x-Onchain Systems with Compose](/compose/introduction.md)
