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

# Track native token balances for a set of addresses

> Keep a current table of native token balances (ETH, or the native asset of any EVM chain) for the addresses you care about, fetched from an RPC only when a balance changes.

## Overview

Exchanges, custodians, treasuries, and payment apps all need to answer **"how much ETH does this address hold right now?"** for a set of addresses they control or watch. Polling `eth_getBalance` for every address on every block is wasteful. Most addresses do not change in most blocks, and the cost grows with the size of your address set.

The [native transfers](/turbo-pipelines/guides/token-transfers/native-transfers) dataset tells you **which addresses moved** native value and **in which block**. A [SQL function](/turbo-pipelines/reference/sql-functions), [`_gs_evm_get_balances`](/turbo-pipelines/reference/sql-functions#_gs_evm_get_balances), then reads the exact balance of just those addresses at that block from [Edge RPC](/edge-rpc/introduction) (or from an endpoint you choose). Writing the result to Postgres leaves you with a table of current balances.

This guide shows how to build that with a Turbo pipeline:

<CardGroup cols={3}>
  <Card title="Watch a set of addresses" icon="list">
    Store the addresses in a [dynamic table](/turbo-pipelines/transforms/dynamic-tables) and add or remove them at any time, no redeploy.
  </Card>

  <Card title="Update on change" icon="refresh-cw">
    Get balances via [SQL functions](/turbo-pipelines/reference/sql-functions#_gs_evm_get_balances) only for addresses that actually changed.
  </Card>

  <Card title="Query current balances" icon="database">
    Land one row per address in Postgres, replaced on every change.
  </Card>
</CardGroup>

## How it works

```mermaid actions={false} theme={"dark"}
flowchart LR
    A[Native transfers dataset<br/>ethereum.native_transfers] -->|sender, recipient, block| C{SQL filter<br/>either side ∈ watched set}
    B[(Dynamic table<br/>your addresses)] --> C
    C -->|watched transfers| D[_gs_evm_get_balances<br/>eth_getBalance at that block]
    D -->|address, balance| E[(Postgres sink<br/>one row per address)]
```

* The **source** is `ethereum.native_transfers`, one row per successful native value transfer including internal transactions. It is built from raw traces, so it exists for the chains where Goldsky indexes traces. See [native transfers](/turbo-pipelines/guides/token-transfers/native-transfers) for what it contains.
* The **dynamic table** holds the addresses you are watching. It is a Postgres table you `INSERT` into and `DELETE` from; the pipeline picks up changes within a second or two.
* A **SQL transform** keeps transfers where the sender or the recipient is watched, asks the RPC for both parties' balances at the transfer's block, and keeps the watched side.
* The **Postgres sink** upserts on `address`, so the table holds the latest known balance for each address.

## Prerequisites

* The [Turbo CLI extension](/turbo-pipelines/cli#installation) installed, and a Goldsky account logged in to your project.
* A Postgres database for the dynamic table and the sink. 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.
* An EVM JSON-RPC endpoint the pipeline can call. [Edge RPC](/edge-rpc/introduction) (recommended) lets you create an endpoint in the dashboard or via the CLI with `goldsky edge create`; use its URL in the form `https://edge.goldsky.com/standard/evm/1?key=YOUR_EDGE_KEY`. Any other `https://` endpoint works too.

<Note>
  The pipeline makes the RPC calls with your endpoint, so your RPC provider bills the usage. With Edge that is per request; see [Edge pricing](/pricing/summary). The pipeline below makes at most two `eth_getBalance` calls per watched transfer, and none for transfers that do not touch a watched address.
</Note>

## Step 1: Provision Postgres

The dynamic table and the sink both connect to Postgres through a Goldsky [secret](/turbo-pipelines/pipeline-config#secrets). You can use one secret for everything in the pipeline.

<Tabs>
  <Tab title="Goldsky-hosted Postgres (recommended)">
    Provision a managed database and register its secret in one step:

    ```bash theme={"dark"}
    goldsky hosted-sink create --type postgres --name MY_POSTGRES
    ```

    The command prints the secret's **name**, **ID**, and **type**. Look up the raw connection string in the web app under **Sinks** when you need to connect a SQL client. You can also provision from the [web app](https://app.goldsky.com) under **Sinks → New sink → Hosted Postgres**.

    <Info>
      Hosted Postgres is a **Scale** plan feature (and above). Adding a credit card to your account upgrades you to Scale. See [pricing](/pricing/summary#hosted-databases).
    </Info>
  </Tab>

  <Tab title="Bring your own Postgres">
    Create a writer role Goldsky can use:

    ```sql theme={"dark"}
    CREATE ROLE goldsky_writer WITH LOGIN PASSWORD 'your_secure_password';

    GRANT CREATE ON DATABASE your_database TO goldsky_writer;
    GRANT USAGE, CREATE ON SCHEMA public TO goldsky_writer;
    ```

    Then store the connection string as a secret:

    ```bash theme={"dark"}
    goldsky secret create MY_POSTGRES
    ```

    When prompted, paste your connection string:

    ```
    postgres://goldsky_writer:your_secure_password@db.example.com:5432/your_database?sslmode=require
    ```

    If your database only accepts connections from allowlisted IPs, see [static IPs and `use_dedicated_ip`](/turbo-pipelines/transforms/dynamic-tables#postgresql-setup).
  </Tab>
</Tabs>

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 `native-balances.yaml`. Replace `YOUR_EDGE_KEY` with your Edge key, or replace the whole URL with another provider's endpoint.

```yaml native-balances.yaml theme={"dark"}
name: native-balances
resource_size: s

sources:
  native_transfers:
    type: dataset
    dataset_name: ethereum.native_transfers
    version: 1.0.0
    start_at: latest

transforms:
  # The addresses whose balances you want to track. Backed by
  # Postgres so you can add and remove addresses live, no redeploy.
  watched_addresses:
    type: dynamic_table
    backend_type: Postgres
    backend_entity_name: watched_addresses
    schema: public
    secret_name: MY_POSTGRES

  # For every transfer that touches a watched address, read the
  # balance of both parties at that block, then keep the watched
  # side(s). Rows whose RPC lookup failed are dropped so they never
  # replace a good balance.
  native_balances:
    type: sql
    primary_key: address
    sql: |
      SELECT
        sub.b.address                     AS address,
        u256_to_string(sub.b.balance)     AS balance,
        sub.block_number,
        to_timestamp(sub.block_timestamp) AS block_time,
        sub.transaction_hash,
        sub.id                            AS transfer_id
      FROM (
        SELECT
          t.id,
          t.block_number,
          t.block_timestamp,
          t.transaction_hash,
          UNNEST(_gs_evm_get_balances(
            'https://edge.goldsky.com/standard/evm/1?key=YOUR_EDGE_KEY',
            make_array(t.sender, t.recipient),
            CAST(t.block_number AS BIGINT)
          )) AS b
        FROM native_transfers t
        WHERE dynamic_table_check('watched_addresses', t.sender)
           OR dynamic_table_check('watched_addresses', t.recipient)
      ) sub
      WHERE dynamic_table_check('watched_addresses', sub.b.address)
        AND sub.b.balance IS NOT NULL

sinks:
  # One row per watched address, replaced on every change.
  balances:
    type: postgres
    from: native_balances
    schema: public
    table: native_balances
    secret_name: MY_POSTGRES
    primary_key: address
```

<Accordion title="What each piece does">
  * **`sources.native_transfers`**: every successful native value transfer on Ethereum, including internal transactions, with lowercased `sender` and `recipient` columns and the transferred `amount` in wei. `start_at: latest` processes only new transfers going forward.
  * **`transforms.watched_addresses`**: the dynamic table. `schema: public` creates `public.watched_addresses` with a `value` (primary key) column and an `updated_at` timestamp.
  * **`transforms.native_balances`**, inner query: keeps only transfers where either party is watched, then calls `_gs_evm_get_balances` once per transfer with both addresses. The function returns a list with one `{address, balance}` element per input address. `UNNEST` turns that list into one row per address.
  * **`transforms.native_balances`**, outer query: keeps the watched side (if both sides are watched, both rows survive), drops rows whose lookup failed, and formats the columns. `balance` is a 256-bit integer; `u256_to_string` renders it as a decimal string in wei.
  * **`primary_key: address`** on the transform and the sink: each new transfer for an address replaces its previous row, so the table holds one current balance per address.
</Accordion>

<Tip>
  `CAST(t.block_number AS BIGINT)` asks for the balance at the end of the transfer's block, after that transfer and after any gas the address paid in that block. Pass `CAST(t.block_number - 1 AS BIGINT)` for the balance before the transfer, or `NULL` for the chain head.
</Tip>

## Step 3: Deploy

Validate, then apply:

```bash theme={"dark"}
goldsky turbo validate native-balances.yaml
goldsky turbo apply native-balances.yaml
```

The dynamic table starts empty, so nothing matches yet and no RPC calls are made. That is expected. Once you have added addresses, watch the transform with live inspect:

```bash theme={"dark"}
goldsky turbo inspect native-balances.yaml -n native_balances
```

## Step 4: Add addresses to watch

Connect any SQL client to the database behind `MY_POSTGRES` and insert the addresses you want to track. Changes take effect within a second or two.

```sql theme={"dark"}
INSERT INTO public.watched_addresses (value) VALUES
  (lower('0x1111111111111111111111111111111111111111')),
  (lower('0x2222222222222222222222222222222222222222'))
ON CONFLICT (value) DO NOTHING;
```

To stop tracking an address, delete it:

```sql theme={"dark"}
DELETE FROM public.watched_addresses
WHERE value = lower('0x1111111111111111111111111111111111111111');
```

<Tip>
  Store addresses in **lowercase**. The `sender` and `recipient` columns of `native_transfers` are already lowercased, and `_gs_evm_get_balances` returns each address in the form it received it, so the final `dynamic_table_check` matches only when the cases agree.
</Tip>

An address gets its first row in `native_balances` after its first native transfer following the insert. If you need a starting balance right away, read it once from your RPC when you insert the address.

## Step 5: Query current balances

```sql theme={"dark"}
-- Current balance of every watched address, in ETH
SELECT address,
       balance::numeric / 1e18 AS eth,
       block_number,
       block_time
FROM public.native_balances
ORDER BY balance::numeric DESC;

-- Addresses whose balance changed in the last hour
SELECT address, balance::numeric / 1e18 AS eth, block_time, transaction_hash
FROM public.native_balances
WHERE block_time > now() - interval '1 hour'
ORDER BY block_time DESC;
```

<Warning>
  `balance` is stored as a decimal **string in wei**. Cast it to `numeric` before doing arithmetic, as above. Do not store it in a `bigint` or `double` column: ETH balances in wei routinely exceed 64-bit integers, and doubles lose precision.
</Warning>

## Optional: keep a balance history

The table above holds one row per address. If you also want to chart how a balance changed over time, add a second Postgres sink that reads the same transform but has no `primary_key`. Without a primary key the sink inserts every row instead of upserting, so you get one row per watched address per transfer.

```yaml theme={"dark"}
sinks:
  balances:
    type: postgres
    from: native_balances
    schema: public
    table: native_balances
    secret_name: MY_POSTGRES
    primary_key: address

  # Every balance the pipeline has seen, one row per transfer.
  balance_history:
    type: postgres
    from: native_balances
    schema: public
    table: native_balance_history
    secret_name: MY_POSTGRES
```

Redeploy with `goldsky turbo apply native-balances.yaml`, then chart one address:

```sql theme={"dark"}
SELECT block_time, block_number, balance::numeric / 1e18 AS eth
FROM public.native_balance_history
WHERE address = lower('0x1111111111111111111111111111111111111111')
ORDER BY block_time;
```

Each row is the address's actual balance at the end of that block, read from the RPC, not a running sum of transfers.

## Coverage and limitations

<AccordionGroup>
  <Accordion title="Which chains have a native transfers dataset">
    `native_transfers` is built from raw traces, so it is available for the EVM chains with a check in the **Traces** column of the [supported networks table](/chains/supported-networks#fast-scan). Swap `ethereum.native_transfers` for `base.native_transfers`, `arbitrum_one.native_transfers`, and so on, and change the URL passed to `_gs_evm_get_balances` to the matching chain (`https://edge.goldsky.com/standard/evm/8453?key=...` for Base).
  </Accordion>

  <Accordion title="Balance changes that are not transfers">
    The pipeline reads an address's balance again only when a native value transfer touches it. A balance can also change without one: gas paid on a transaction that sent no value, validator withdrawals, block rewards. Because every read fetches the true balance from the RPC rather than adding up transfers, the stored value catches up at the address's next transfer, but between transfers it can drift by those amounts. If that matters for an address, refresh it on a schedule from your side.
  </Accordion>

  <Accordion title="Failed RPC lookups">
    `_gs_evm_get_balances` never fails the pipeline. If the endpoint is unreachable, rate limits the call, or returns an error, that address's `balance` is `NULL` for that row and a warning is written to the pipeline logs. The `AND sub.b.balance IS NOT NULL` filter drops those rows so they never replace a good balance in the sink; the address is read again at its next transfer. If you remove the filter, a failed lookup overwrites the stored balance with `NULL`. If every balance is `NULL`, check the URL and key first.
  </Accordion>

  <Accordion title="Token balances">
    For ERC-20, ERC-721, and ERC-1155 balances use the `<chain>.balances` dataset instead, which Goldsky maintains for you with no RPC calls. See [EVM sources](/turbo-pipelines/sources/evm).
  </Accordion>

  <Accordion title="Filtering at the source">
    A `filter:` on the source is applied to the underlying raw traces, whose columns are `from_address` and `to_address` rather than `sender` and `recipient`. Filter in the SQL transform as shown above unless you need the source-level filter for a block range.
  </Accordion>
</AccordionGroup>

## Related

* [Native transfers](/turbo-pipelines/guides/token-transfers/native-transfers)
* [SQL functions](/turbo-pipelines/reference/sql-functions)
* [Dynamic tables](/turbo-pipelines/transforms/dynamic-tables)
* [Deposit detection guide](/solutions/deposit-detection)
* [PostgreSQL sink](/turbo-pipelines/sinks/postgres)

Can't find what you're looking for? Reach out to us at [support@goldsky.com](mailto:support@goldsky.com) for help.


## Related topics

- [Detect incoming assets to a set of addresses](/solutions/deposit-detection.md)
- [Native transfers](/turbo-pipelines/guides/token-transfers/native-transfers.md)
- [Turbo SQL Functions Reference](/turbo-pipelines/reference/sql-functions.md)
- [Solana sources for Turbo pipelines](/turbo-pipelines/sources/solana.md)
- [Solana Stablecoin Transfers](/turbo-pipelines/sources/solana-stablecoin-transfers.md)
