Skip to main content

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 dataset tells you which addresses moved native value and in which block. A SQL function, _gs_evm_get_balances, then reads the exact balance of just those addresses at that block from Edge RPC (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:

Watch a set of addresses

Store the addresses in a dynamic table and add or remove them at any time, no redeploy.

Update on change

Get balances via SQL functions only for addresses that actually changed.

Query current balances

Land one row per address in Postgres, replaced on every change.

How it works

  • 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 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 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 (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.
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. The pipeline below makes at most two eth_getBalance calls per watched transfer, and none for transfers that do not touch a watched address.

Step 1: Provision Postgres

The dynamic table and the sink both 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 native-balances.yaml. Replace YOUR_EDGE_KEY with your Edge key, or replace the whole URL with another provider’s endpoint.
native-balances.yaml
  • 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.
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.

Step 3: Deploy

Validate, then apply:
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:

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.
To stop tracking an address, delete it:
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.
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

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.

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.
Redeploy with goldsky turbo apply native-balances.yaml, then chart one address:
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

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. 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).
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.
_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.
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.
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.
Can’t find what you’re looking for? Reach out to us at support@goldsky.com for help.