Skip to main content

Overview

Many products need to answer one deceptively simple question: “Did assets just land in one of our addresses?” Exchanges, neobanks, on-ramps, and payment apps all maintain a large, changing set of receiving addresses and need to react the instant funds arrive. Doing this with raw RPC polling is inefficient (you poll thousands of addresses on every block), and standing up custom indexing infrastructure is overkill. Turbo sits in between: you get a fast, queryable index that you point at a set of addresses you maintain, without running any indexing infrastructure yourself. This guide shows how to build that with a Turbo pipeline:

Maintain an address set

Store the addresses you care about in a dynamic table and add/remove them at any time — no redeploy.

Query the index

Filter the transfer stream down to your addresses and land the matches in Postgres you can query directly.

Get alerted on arrival

Fire a webhook the moment a watched address receives funds.

How it works

  • The source is a curated transfer dataset (for example base.erc20_transfers) — a normalized, cross-chain stream of token transfers.
  • The dynamic table holds the addresses you’re watching. It’s backed by Postgres, so you can INSERT/DELETE addresses live and the pipeline picks up changes within a second or two — no redeploy.
  • A SQL transform keeps only transfers whose recipient is in your watched set, using dynamic_table_check().
  • Sinks deliver the result: a Postgres table you can query, a webhook for real-time alerts, or a running-balance table (all three can run in the same pipeline).

Prerequisites

  • The Turbo CLI extension installed, and a Goldsky account logged in to your project.
  • A Postgres database for the dynamic table and sinks. You have two options:
    • Goldsky-hosted Postgres (recommended) — Goldsky provisions and manages it for you. Available on Scale plans and above.
    • Bring your own Postgres — Neon, Supabase, RDS, Cloud SQL, or self-hosted.

Step 1: Provision Postgres

The dynamic table and the sinks all connect to Postgres through a Goldsky secret. You can use one secret for everything in the pipeline. Whichever path you choose, the rest of this guide refers to the secret as MY_POSTGRES.

Step 2: Create the pipeline

Create a file named address-watcher.yaml. This pipeline maintains a watched_addresses table, filters the Base ERC-20 transfer stream down to transfers received by one of those addresses, and writes the matches to a Postgres table you can query.
address-watcher.yaml
name: address-watcher
resource_size: s

sources:
  base_transfers:
    type: dataset
    dataset_name: base.erc20_transfers
    version: 1.2.0
    start_at: latest

transforms:
  # The set of addresses you're watching. Backed by Postgres so you can
  # add/remove addresses live without redeploying the pipeline.
  watched_addresses:
    type: dynamic_table
    backend_type: Postgres
    backend_entity_name: watched_addresses
    schema: public
    secret_name: MY_POSTGRES

  # Keep only transfers received by a watched address.
  incoming_transfers:
    type: sql
    primary_key: id
    sql: |
      SELECT
        id,
        address    AS token,      -- ERC-20 contract address
        sender,
        recipient,
        amount,
        block_number,
        to_timestamp(block_timestamp) AS block_time,
        transaction_hash,
        _gs_op
      FROM base_transfers
      WHERE dynamic_table_check('watched_addresses', recipient)

sinks:
  # A queryable index of every transfer landing in a watched address.
  incoming_index:
    type: postgres
    from: incoming_transfers
    schema: public
    table: incoming_transfers
    secret_name: MY_POSTGRES
    primary_key: id
  • sources.base_transfers — the curated base.erc20_transfers dataset. start_at: latest processes only new transfers going forward. Use start_at: earliest to backfill history first.
  • transforms.watched_addresses — the dynamic table. schema: public creates public.watched_addresses with a value (primary key) column and an updated_at timestamp. (If you omit schema, the dynamic table defaults to a streamling schema — setting it explicitly keeps everything in one place.)
  • transforms.incoming_transfers — filters the stream with dynamic_table_check('watched_addresses', recipient), which returns true when recipient exists in the dynamic table. Change recipient to sender (or check both) to track outgoing transfers too.
  • sinks.incoming_index — writes matches to public.incoming_transfers. primary_key: id makes writes idempotent upserts.
Store and match addresses in lowercase. Curated dataset addresses are already lowercased, and inserting your watched addresses with lower(...) (Step 4) keeps the match consistent.

Step 3: Deploy

Validate, then apply:
goldsky turbo validate address-watcher.yaml
goldsky turbo apply address-watcher.yaml
The dynamic table starts empty, so nothing matches yet — that’s expected. Watch data flow through the pipeline with live inspect:
goldsky turbo inspect address-watcher.yaml -n incoming_transfers

Step 4: Add addresses to watch

The dynamic table is just a Postgres table. Connect any SQL client to the same database and insert the addresses you want to watch. Changes take effect within a second or two — no redeploy.
-- Watch a single address (example placeholder address)
INSERT INTO public.watched_addresses (value)
VALUES (lower('0x1111111111111111111111111111111111111111'));

-- Watch several at once
INSERT INTO public.watched_addresses (value) VALUES
  (lower('0x1111111111111111111111111111111111111111')),
  (lower('0x2222222222222222222222222222222222222222')),
  (lower('0x3333333333333333333333333333333333333333'))
ON CONFLICT (value) DO NOTHING;
To stop watching an address, delete it:
DELETE FROM public.watched_addresses
WHERE value = lower('0x1111111111111111111111111111111111111111');
Inspect the current set at any time:
SELECT * FROM public.watched_addresses ORDER BY updated_at DESC;

Step 5: Query the index

Once addresses are being watched and matching transfers arrive, query the Postgres table directly:
-- Most recent assets received across all watched addresses
SELECT recipient, token, amount, block_time, transaction_hash
FROM public.incoming_transfers
ORDER BY block_time DESC
LIMIT 20;

-- Everything a specific address has received
SELECT token, amount, block_time, transaction_hash
FROM public.incoming_transfers
WHERE recipient = lower('0x1111111111111111111111111111111111111111')
ORDER BY block_time DESC;

Get a webhook when funds land

To be notified the moment a watched address receives funds, add a webhook sink. It POSTs each matching transfer to your endpoint. You can run it alongside the Postgres sink in the same pipeline. First, create an auth secret for your endpoint:
goldsky secret create
# Choose "httpauth", then set a header (e.g. Authorization) and value (e.g. "Bearer <token>")
Then add the sink under sinks: in address-watcher.yaml:
sinks:
  incoming_index:
    type: postgres
    from: incoming_transfers
    schema: public
    table: incoming_transfers
    secret_name: MY_POSTGRES
    primary_key: id

  # Fire one webhook request per transfer as soon as it's detected.
  arrival_alerts:
    type: webhook
    from: incoming_transfers
    url: https://api.example.com/asset-arrived
    secret_name: MY_WEBHOOK_AUTH
    one_row_per_request: true
Redeploy:
goldsky turbo apply address-watcher.yaml
Each request body is a single JSON object for the matched transfer:
{
  "id": "...",
  "token": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
  "sender": "0x...",
  "recipient": "0x1111111111111111111111111111111111111111",
  "amount": "1000000",
  "block_time": "2026-07-09T18:36:00Z",
  "transaction_hash": "0x..."
}
The webhook sink guarantees at-least-once delivery and retries 5xx/timeout responses with backoff. Your endpoint should be idempotent — dedupe on id (or transaction_hash) in case a batch is retried.

Optional: track a running balance per address

The queryable index above logs every transfer that landed. If you also want to answer “which of these addresses currently hold funds?”, you can optionally add a running balance per watched address using the postgres_aggregate sink, which maintains a live sum in a database trigger. The example below is a self-contained pipeline you can adapt — add its transform and sink to address-watcher.yaml, or run it on its own. Take the parts you need. It tracks the net balance each watched address holds of a single token (USDC on Base). It counts credits (incoming) as positive and debits (outgoing) as negative, so the balance reflects deposits minus withdrawals.
balance-watcher.yaml
name: balance-watcher
resource_size: s

sources:
  base_transfers:
    type: dataset
    dataset_name: base.erc20_transfers
    version: 1.2.0
    start_at: latest

transforms:
  watched_addresses:
    type: dynamic_table
    backend_type: Postgres
    backend_entity_name: watched_addresses
    schema: public
    secret_name: MY_POSTGRES

  # Emit one signed balance-change row per watched address involved in a
  # USDC transfer: +amount when it receives, -amount when it sends.
  # `amount` is a 256-bit integer, so use the I256 helpers (a plain CAST
  # to DECIMAL is not supported on 256-bit columns).
  balance_changes:
    type: sql
    primary_key: entry_id
    sql: |
      SELECT
        concat(id, '-credit') AS entry_id,
        lower(recipient) AS account,
        to_i256(amount) AS delta
      FROM base_transfers
      -- 0x8335...2913 is the USDC token contract on Base
      WHERE lower(address) = lower('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913')
        AND dynamic_table_check('watched_addresses', recipient)

      UNION ALL

      SELECT
        concat(id, '-debit') AS entry_id,
        lower(sender) AS account,
        i256_neg(to_i256(amount)) AS delta
      FROM base_transfers
      -- 0x8335...2913 is the USDC token contract on Base
      WHERE lower(address) = lower('0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913')
        AND dynamic_table_check('watched_addresses', sender)

sinks:
  balances:
    type: postgres_aggregate
    from: balance_changes
    schema: public
    landing_table: usdc_balance_log
    agg_table: usdc_balances
    primary_key: entry_id
    secret_name: MY_POSTGRES
    group_by:
      account:
        type: text
    aggregate:
      balance:
        from: delta
        fn: sum
        type: numeric(78, 0)
Each transfer produces two distinct rows — a -credit entry for the recipient and a -debit entry for the sender — so entry_id (not the raw transfer id) is the primary key. Reusing id would make the two rows collide in the aggregate’s landing table.
Query which watched addresses currently hold a balance:
SELECT account, balance
FROM public.usdc_balances
WHERE balance > 0
ORDER BY balance DESC;
Balances here are in the token’s base units (USDC has 6 decimals, so 1000000 = 1 USDC). Divide by 10^decimals when displaying. The sum aggregation supports insert/delete streams but not updates — see the postgres_aggregate caveats.

Coverage and limitations

Add more sources (ethereum.erc20_transfers, arbitrum.erc20_transfers, and so on) and UNION ALL them in the SQL transform, tagging each with a chain column. See multi-chain monitoring. The same watched_addresses table can back all chains.
Some chains expose dedicated balance datasets (for example solana.native_balances, stellar_mainnet.balances). For EVM chains, derive balances from the transfer stream as shown above, or query a single address on demand via Edge RPC eth_getBalance.
Can’t find what you’re looking for? Reach out to us at support@goldsky.com for help.