Skip to main content
Onchain settlement is often described as a compliance problem. In practice it is a compliance advantage - every transfer is visible, in real time, on a public ledger. The challenge is operational: you need to watch every flow as it happens, react to sanctions changes in minutes, and prove to a regulator exactly what you did and why. This guide builds that surveillance layer. A Turbo pipeline screens every stablecoin transfer against a live watchlist you can update in seconds, and Compose turns a hit into an attested, auditable decision.

Why real-time and provable

Two properties make onchain monitoring different from a nightly batch screen:
  • Sanctions lists change without warning. When OFAC adds an address, you cannot wait for tomorrow’s redeploy. A dynamic table lets you push a new address to a running pipeline and start flagging within seconds.
  • Regulators want the “why,” not just the “what.” Compose traces every external call with inputs and outputs and can run in a Trusted Execution Environment that attests the exact code executed. Each freeze, alert, or SAR trigger comes with a verifiable record.

How it works

Prerequisites

  • The Turbo CLI extension installed and logged in.
  • A Postgres database and a Goldsky secret. This database also backs the dynamic-table watchlist, so you can keep it inside your own compliance boundary.
  • The stablecoin/TIP-20 contract addresses you settle in. Confirm the Tempo dataset name in the Datasource explorer.

Step 1: Screen transfers against a live watchlist

The watchlist lives in a dynamic table. The pipeline checks both counterparties of every transfer against it with dynamic_table_check().
aml-screening.yaml
name: stablecoin-aml-screening
resource_size: s

sources:
  tempo_transfers:
    type: dataset
    dataset_name: tempo.tip20_transfers
    version: 1.0.0
    start_at: latest

transforms:
  # The watchlist - updatable at runtime with no redeploy
  watchlist:
    type: dynamic_table
    backend_type: Postgres
    backend_entity_name: sanctioned_addresses
    secret_name: MY_POSTGRES

  # Flag any transfer touching a watchlisted address
  flagged_transfers:
    type: sql
    primary_key: id
    sql: |
      SELECT
        id,
        address AS token,
        sender,
        recipient,
        amount,
        block_timestamp,
        transaction_hash,
        CASE
          WHEN dynamic_table_check('watchlist', sender)    THEN 'sender'
          WHEN dynamic_table_check('watchlist', recipient) THEN 'recipient'
        END AS watchlist_hit,
        _gs_op
      FROM tempo_transfers
      WHERE dynamic_table_check('watchlist', sender)
         OR dynamic_table_check('watchlist', recipient)

sinks:
  # Persist every hit for case management and audit
  flagged_sink:
    type: postgres
    from: flagged_transfers
    schema: compliance
    table: flagged_transfers
    secret_name: MY_POSTGRES
    primary_key: id

  # Push each hit to your case-management system immediately
  case_management:
    type: webhook
    from: flagged_transfers
    url: https://compliance.example.com/alerts/aml-hit
    one_row_per_request: true
    secret_name: COMPLIANCE_WEBHOOK_AUTH
goldsky turbo apply aml-screening.yaml

Step 2: React to a sanctions update in seconds

This is the capability that batch systems cannot match. When a new address must be watched, insert it into the dynamic table from any Postgres client - no redeploy, no re-sync:
-- New OFAC SDN address - live within seconds
INSERT INTO streamling.sanctioned_addresses (value)
VALUES (lower('0xNEWLY_SANCTIONED_ADDRESS'));
The running pipeline picks it up on the next batch and begins flagging matching transfers immediately. You can automate this by having your sanctions-list ingestion job write directly to the table.
Because the watchlist is a table in your database, you can populate it from OFAC’s published SDN list, a vendor feed (Chainalysis, TRM, Elliptic), and your own internal blocklist at once - and you can prune it by updated_at to satisfy data-retention rules.

Step 3: Monitor issuer-level enforcement

TIP-20’s compliance controls emit their own events. Decode them from raw_logs to keep a real-time view of every policy change and blocked transfer - the issuer-side counterpart to your own screening. Decoding happens in two steps - one transform defines decoded, the next filters on it - because Flink SQL can’t reference a column alias in the WHERE of the same query.
policy-events.yaml
name: tip20-policy-events
resource_size: s

sources:
  tempo_tip20_logs:
    type: dataset
    dataset_name: tempo.raw_logs
    version: 1.0.0
    start_at: latest
    filter: address = lower('0xYOUR_TIP20_TOKEN')

transforms:
  # 1. Decode every log with the TIP-20 ABI
  decoded_logs:
    type: sql
    primary_key: id
    sql: |
      SELECT
        id,
        address AS token,
        _gs_log_decode(
          _gs_fetch_abi('https://raw.githubusercontent.com/your-org/abis/main/tip20.json', 'raw'),
          topics,
          data
        ) AS decoded,
        block_timestamp,
        transaction_hash,
        _gs_op
      FROM tempo_tip20_logs

  # 2. Keep only compliance/policy events (this transform can filter on `decoded`)
  policy_events:
    type: sql
    primary_key: id
    sql: |
      SELECT
        id,
        token,
        decoded.event_signature AS event,
        to_timestamp(block_timestamp) AS at,
        transaction_hash,
        _gs_op
      FROM decoded_logs
      WHERE decoded IS NOT NULL
        AND decoded.event_signature IN (
          'TransferPolicyUpdate', 'TransferBlocked', 'BurnBlocked', 'PauseStateUpdate'
        )

sinks:
  policy_ledger:
    type: postgres
    from: policy_events
    schema: compliance
    table: policy_events
    secret_name: MY_POSTGRES
    primary_key: id
The four events this keeps are the issuer-side enforcement signals:
  • TransferPolicyUpdate - the token’s whitelist/blacklist policy (TIP-403) changed.
  • TransferBlocked - a transfer was redirected by receive-policy enforcement.
  • BurnBlocked - tokens were removed from a policy-restricted address.
  • PauseStateUpdate - the token was paused or unpaused.

Step 4: Attest every decision with Compose

A flag is not a decision. Route each hit to a Compose task that verifies against your KYC/sanctions provider and records the outcome. Running the task in a TEE produces an attestation that the exact screening logic ran, which is what makes the decision defensible.
src/tasks/screen-transfer.ts
import type { TaskContext } from "compose";

export async function main(
  context: TaskContext,
  payload: { transaction_hash: string; sender: string; recipient: string; amount: string; watchlist_hit: string },
) {
  const { fetch, evm } = context;

  // 1. Verify against your sanctions / KYC provider (retried durably)
  const screen = await fetch<{ decision: "clear" | "block"; caseId: string }>(
    "https://screening.example.com/v1/screen",
    {
      method: "POST",
      body: JSON.stringify(payload),
      max_attempts: 3,
      initial_interval_ms: 1000,
      backoff_factor: 2,
    },
  );

  // 2. Write a tamper-evident attestation on-chain
  const wallet = await evm.wallet({ name: "compliance-attestor", sponsorGas: true });
  await wallet.writeContract(
    evm.chains.tempo,
    "0xYOUR_ATTESTATION_REGISTRY",
    "recordDecision(bytes32,string,string)",
    [payload.transaction_hash, screen.decision, screen.caseId],
  );

  // 3. Return the decision - the full trace (inputs, outputs, attestation) is saved
  return { decision: screen.decision, caseId: screen.caseId };
}
Trigger this task from the case_management webhook in Step 1, or directly from an onchain event trigger. Every run is traceable step by step in the CLI and UI.

Business outcomes

  • Minutes, not days, to enforce a sanctions change - the watchlist updates a running pipeline in seconds.
  • Defensible decisions - TEE attestations and full execution traces answer “how did you decide?” with a record, not a reconstruction.
  • Data stays in your boundary - the watchlist and flagged-transfer tables live in your database, easing residency and retention obligations.
  • One surveillance layer across chains and tokens - the same pipeline pattern covers TIP-20 on Tempo and standard stablecoins on any supported chain.

Resources

Can’t find what you’re looking for? Reach out to us at support@goldsky.com for help.