Tutorials & Use Cases
Tracking UTXOs for a Specific Address

Tracking UTXOs for a Specific Address

Introduction

Welcome to your first hands-on tutorial with Yaci Store! In this guide, you'll learn how to set up a lightweight Cardano indexer that tracks transactions and UTXOs (Unspent Transaction Outputs) for a specific address.

What is Yaci Store? Yaci Store is a Cardano blockchain indexer that reads data from the blockchain and stores it in a PostgreSQL database. Think of it as a specialized tool that helps you extract and organize blockchain data for your applications.

What will you build? By the end of this tutorial, you'll have a running Yaci Store instance that:

  • Synchronizes with the Cardano blockchain
  • Tracks UTXOs only for one specific address (ignoring everything else)
  • Stores the data in an easily queryable database
  • Optionally sends you Discord notifications when the address balance changes

Why is this useful? Instead of indexing the entire Cardano blockchain (which requires significant storage and processing power), you can focus on just the data you need. This is perfect for:

  • Monitoring a treasury wallet
  • Tracking payments to a specific address
  • Building lightweight applications that only care about one address
  • Development and testing with minimal resource requirements

What you'll learn:

  • How to set up Yaci Store using Docker
  • How to configure and enable plugins
  • How to write custom filtering logic
  • How to query blockchain data from the database

Let's get started!


Prerequisites

Before we begin, make sure you have:

This tutorial uses the Cardano preprod testnet, which is perfect for learning without dealing with real ADA or requiring extensive storage.


Step 1: Download Yaci Store

First, let's get the Yaci Store Docker distribution.

  1. Download the latest release

    Visit the Yaci Store releases page (opens in a new tab) and download the Docker distribution archive (look for yaci-store-docker-<version>.zip).

    For example: yaci-store-docker-2.0.0-beta5.zip

  2. Extract the archive

    # For .tar.gz files
    unzip yaci-store-docker-<version>.zip
    cd yaci-store-docker-<version>
  3. Explore the directory structure

    You should see the following files and folders:

    yaci-store/
    ├── yaci-store.sh          # Main script to start/stop Yaci Store
    ├── admin-cli.sh           # Admin operations
    ├── monitoring.sh          # Optional monitoring stack
    ├── psql.sh                # Quick database access
    ├── LICENSE                # License
    ├── compose/              # Docker compose files
    │   ├── .env             # Docker image versions
    │   ├── admin-cli-compose.yml
    │   ├── monitoring.yml
    │   ├── postgres-compose.yml
    │   ├── postgres-compose-minimal.yml
    │   ├── yaci-store.yml
    │   └── yaci-store-monolith.yml
    ├── config/              # Configuration files
    │   ├── env              # Environment variables
    │   ├── application.properties
    │   ├── application-ledger-state.properties
    │   └── application-plugins.yml
    └── plugins/      # Where we'll put our custom plugins
        ├── ext-jars
        └── scripts

The yaci-store.sh script is your main interface. It handles starting, stopping, and managing the Docker containers for you.


Step 2: Configure Network Connection

Now let's configure Yaci Store to connect to the Cardano preprod testnet.

  1. Edit the main configuration file

    Open config/application.properties in your text editor.

  2. Set the network connection

    Find the Cardano network configuration section and set these values:

    # Cardano Network Configuration
    store.cardano.host=preprod-node.play.dev.cardano.org
    store.cardano.port=3001
    store.cardano.protocol-magic=1

    What do these settings mean?

    • host: The Cardano node you'll connect to (we're using a public preprod node)
    • port: The port number for the node connection
    • protocol-magic: Network identifier (1 = preprod, 2 = preview, 764824073 = mainnet)
  3. Verify database settings

    The Docker distribution comes pre-configured for database access. You should see:

    spring.datasource.url=jdbc:postgresql://yaci-store-postgres:5432/yaci_store?currentSchema=yaci_store
    spring.datasource.username=yaci
    spring.datasource.password=dbpass
    ⚠️

    These are development credentials. For production use, change the database password!


Step 3: Optimize for Address Tracking

Since we only want to track one address, let's disable stores we don't need. This dramatically reduces resource usage.

  1. Continue editing config/application.properties

  2. Disable unnecessary stores

    Add or modify these settings:

    # Disable stores we don't need
     store.assets.enabled=false
     store.blocks.enabled=false
     store.epoch.enabled=false
     store.metadata.enabled=false
     store.mir.enabled=false
     store.script.enabled=false
     store.staking.enabled=false
     store.transaction.enabled=false
     store.governance.enabled=false
     
     # Keep the UTXO store enabled (we need this!)
     store.utxo.enabled=true
  3. Set a recent starting point (optional but recommended)

    Instead of syncing from the beginning of the blockchain, we can start from a recent point. However, to get accurate balance or UTXOs for your selected address, you need to choose a point before the first transaction of that address. If it's a new address with no transactions, you can select the current tip.

    The following configuration is based on the example address used in this tutorial. Change these values according to your selected address.

    # Start syncing from a recent slot (adjust to current preprod slot)
    store.cardano.sync-start-slot=107754724
    store.cardano.sync-start-blockhash=534d3c2d1c3915523ea8843449dd91fcf4d647719d9ef2dc64a97d47be39447b

    To get the latest preprod slot and block hash, visit Cardano Explorer (Preprod) (opens in a new tab) or use a recent block. This allows you to start syncing quickly without processing historical data.

  4. Save the file


Step 4: Enable the Plugin System

Yaci Store uses a plugin system to extend its functionality. Let's enable it.

4.1 Enable Plugin Loading

  1. Edit config/env

    Find and uncomment (remove the # at the beginning) this line:

    JDK_JAVA_OPTIONS=${JDK_JAVA_OPTIONS} -Dloader.path=plugins,plugins/lib,plugins/ext-jars

    This tells Yaci Store to load plugins from the plugins directory.

4.2 Enable Plugins in Configuration

  1. Edit config/application-plugins.yml

  2. Enable the plugin system

    Make sure this is set:

    store:
      plugins:
        enabled: true

Step 5: Create the Plugin Scripts

Now comes the fun part - writing the JavaScript plugins that will filter UTXOs by address!

We'll create three plugin files:

5.1 Create the Address Filter

  1. Create a new file plugins/scripts/filter-by-address.js

  2. Add this code:

     /**
      * Filters a list of UTXO items by a specific address
      * @param {Array} items - List of UTXO items to filter
      * @return {Array} List of filtered UTXOs that match the address filter
      */
     function filterByAddress(items) {
       // Get the target address from configuration
         // Get the address filter from environment configuration
         const address = env.getProperty("address.filter");
     
         // Filter UTXOs by owner address using functional approach
         const filteredUtxos = items.filter(item => item.getOwnerAddr() === address);
     
         // If any UTXOs were found, update global state and log the count
         if (filteredUtxos.length > 0) {
             global_state.put("utxo.found", true);
             //console.log("Utxo found : " + filteredUtxos.length);
         }
     
         return filteredUtxos;
     }

    What does this do?

    • Gets the target address from your configuration
    • Filters incoming UTXOs to only keep ones for that address
    • Returns the filtered list (which is what gets saved to the database)

5.2 Create the Commit Event Handler

  1. Create a new file plugins/scripts/handle-commit.js

  2. Add this code:

    /**
      * Handles commit events by cleaning up additional transaction inputs
      *
      * APPROACH: Since tx_input records don't have address filtering and Yaci Store processes
      * blocks in parallel batches (100 blocks during initial sync, 1 block at tip), tx_input
      * records may be inserted before their corresponding UTXO entries in address_utxo table.
      * The CommitEvent is published at the end of each batch and handlers are processed
      * sequentially, making it the perfect place to delete orphaned tx_input records.
      *
      * @param {Object} event - The commit event containing metadata like slot number
     */
    function handleCommitEvent(event) {
       // Get the last processed slot from global_state, default to 0 if not set
        let last_tx_inputs_slot = global_state.get("last_tx_inputs_slot");
        if (last_tx_inputs_slot === null || last_tx_inputs_slot === undefined) {
            last_tx_inputs_slot = 0;
        }
     
        console.log("Deleting additional tx inputs after slot: " + last_tx_inputs_slot);
     
        // SQL query to delete orphaned tx_input records
        // Only deletes records that are not referenced by address_utxo table
        const sql = "DELETE FROM tx_input ti " +
                "WHERE ti.spent_at_slot > :given_slot " +
                "AND NOT EXISTS ( " +
                "  SELECT 1 FROM address_utxo au " +
                "  WHERE au.tx_hash = ti.tx_hash " +
                "    AND au.output_index = ti.output_index " +
                ")";
     
        // Set parameters for the SQL query
        const params = { "given_slot": last_tx_inputs_slot };
     
        // Execute the delete operation and get count of deleted records
        const count = named_jdbc.update(sql, params);
     
        // Log the number of deleted records if any were found
        if (count > 0) {
            console.log("Deleted " + count + " additional tx inputs.");
        }
     
        // Update the last processed slot to current event slot
        global_state.put("last_tx_inputs_slot", event.getMetadata().getSlot());
     
    }

    What does this do?

    • Deletes any transaction inputs that don't belong to our target address

5.3 Create the Rollback event Handler

  1. Create a new file plugins/scripts/handle-rollback.js

  2. Add this code:

     function handleRollbackEvent(event) {
         //Reset last_tx_input_slot used in CommitEvent to delete additional tx inputs
         console.log("Utxo by address plugin handleRollbackEvent: " + event.getRollbackTo().getSlot());
         global_state.put("last_tx_inputs_slot", event.getRollbackTo().getSlot());
     }

5.4 Create the Discord Notification (Optional)

This plugin sends you a Discord message when your address balance changes!

  1. Create a new file plugins/scripts/notify-discord.js

  2. Add this code:

    /**
     * Sends Discord notification when balance changes for the monitored address
     * @param {Object} event - The commit event containing metadata like block number
     */
     function sendDiscordNotificationOnBalanceChange(event) {
         const utxoFound = global_state.get("utxo.found");
     
         if (utxoFound !== null && utxoFound) {
              global_state.remove("utxo.found");
     
              // Skip sending notification if not at tip yet
              if (!event.getMetadata().isSyncMode()) {
                  return;
              }
     
              const address = env.getProperty("address.filter");
     
              const sql = "SELECT SUM(au.lovelace_amount) AS balance " +
                   "FROM address_utxo au " +
                   "WHERE au.owner_addr = :address " +
                   "AND NOT EXISTS (SELECT 1 FROM tx_input ti WHERE ti.tx_hash = au.tx_hash AND ti.output_index = au.output_index)";
     
              const params = { "address": address };
     
              const result = named_jdbc.queryForMap(sql, params);
              const balance = result["balance"];
     
              const discordUrl = env.getProperty("discord.webhook.url");
     
              const jsonData = {
                  "content": `💰 Unspent UTXO Balance: ${balance} Lovelace at Block: ${event.getMetadata().getBlock()}\n Address: ${address}`
              };
     
              const response = http.postJson(discordUrl, jsonData, { "Content-Type": "application/json" });
              console.log("Discord response: " + response);
         }
     }

    What does this do?

    • Calculates the current balance of your address from the database
    • Formats a nice Discord embed message
    • Sends it to your Discord webhook whenever a UTXO is added

Step 6: Configure the Plugins

Now let's wire up these plugins in the configuration.

  1. Edit config/application-plugins.yml

  2. Replace the contents with this configuration:

   store:
     plugins:
       enabled: true
       api-enabled: false
       metrics:
        enabled: false
 
       # Filter plugins run BEFORE data is saved
       filters:
         utxo.unspent.save:
           - name: "Filter UTXOs by Address"
             lang: js
             script:
               file: /app/plugins/scripts/filter-by-address.js
               function: filterByAddress
 
       # Event handlers react to blockchain events
       event-handlers:
         CommitEvent:
           - name: "Clean Orphaned TxInputs"
             lang: js
             script:
               file: /app/plugins/scripts/handle-commit.js
               function: handleCommitEvent
           - name: "Send Balance Change Notification"
             lang: js
             script:
               file: /app/plugins/scripts/notify-discord.js
               function: sendDiscordNotificationOnBalanceChange
 
         RollbackEvent:
           - name: "Handle Rollback Event"
             lang: js
             script:
               file: /app/plugins/scripts/handle-rollback.js
               function: handleRollbackEvent

Note that the local plugins folder is mapped to /app/plugins for the Yaci Store docker distribution.

  1. Set your target address in config/application.properties

    Add this line (replace with the address you want to track):

    # The Cardano address to track
    address.filter=addr_test1qp5z7zmlw848szvmpe693ukfu9yq4t3we4s6u483j9sq4zr09qy27awlm22wdh98zm2le5l8yluezf5amygq5d6htj5qdx4p3k

    This is a preprod testnet address. Replace it with any preprod address you want to track! You can get test ADA from the Cardano Testnet Faucet (opens in a new tab).

  2. (Optional) Set up Discord webhook

    If you want balance notifications, add this to config/application.properties:

    # Optional: Discord webhook URL for notifications
    discord.webhook.url=https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN

    To create a Discord webhook:

    • Go to your Discord server settings
    • Navigate to Integrations → Webhooks
    • Create a new webhook and copy the URL

Step 7: Start Yaci Store

Time to launch everything!

  1. Start Yaci Store

    ./yaci-store.sh start

    This will:

    • Pull the necessary Docker images (first time only)
    • Start PostgreSQL database
    • Start Yaci Store application
    • Begin synchronizing with the blockchain
  2. Watch the logs

    ./yaci-store.sh logs:yaci-store

Step 8: Query Your Data

Let's verify that everything is working by querying the database!

  1. Connect to the database

    ./psql.sh

    This opens a PostgreSQL console connected to your Yaci Store database.

  2. Set the schema

    SET search_path TO yaci_store;
  3. Check UTXOs for the monitored address

    SELECT
      owner_addr,
      lovelace_amount  as lovelace,
      amounts,
      tx_hash,
      output_index,
      slot
    FROM address_utxo
    WHERE owner_addr = <monitored_address>
    ORDER BY slot DESC
    LIMIT 10;

    What you'll see:

    • All UTXOs currently available at your tracked address
    • The amounts in each UTXO
    • The transaction hash and output index
    • The slot number when it was created
  4. Calculate address balance

    SELECT SUM(au.lovelace_amount) AS balance
    FROM address_utxo au
    WHERE au.owner_addr = <monitored_address>
    AND
    NOT EXISTS
       (SELECT 1 FROM tx_input ti
        WHERE ti.tx_hash = au.tx_hash AND ti.output_index = au.output_index);
  5. Exit the database console

    \q

If you don't see any data yet, the address might not have received any transactions since your sync starting point. Try sending some test ADA to your tracked address from the testnet faucet (opens in a new tab)!


Step 9: Understanding Your Setup

What's happening behind the scenes?

  1. Yaci Store connects to the Cardano node and requests blockchain data starting from your configured slot

  2. The node streams blocks containing all transactions and UTXOs

  3. Your filter plugin runs for every UTXO before it's saved:

    • If the UTXO belongs to your address → it gets saved
    • If it belongs to any other address → it's discarded
  4. The commit event handler cleans up any transaction input records that don't match your address

  5. The notification plugin (if configured) sends you a Discord message whenever new UTXOs arrive

Resource usage

Because you're only tracking one address:

  • Database size: Very small (megabytes instead of gigabytes)
  • CPU usage: Minimal
  • Memory: ~1-2GB for the Docker containers
  • Sync time: Fast (only processes blocks with relevant transactions)

Customizing for your needs

Want to track a different address? Just:

  1. Stop Yaci Store: ./yaci-store.sh stop
  2. Change address.filter in config/application.properties
  3. Update store.cardano.sync-start-slot and store.cardano.sync-start-blockhash in config/application.properties (If required)
  4. Reset the database: sudo rm -rf db-data
  5. Start again: ./yaci-store.sh start

Troubleshooting

Plugins not loading?

Check that you:

  • Uncommented the plugin loader line in config/env
  • Set store.plugins.enabled=true in config/application-plugins.yml
  • Used the correct file paths (they should start with /app/plugins/scripts/)

No data appearing?

Verify:

  • Your address.filter is set correctly
  • The address has received transactions after your sync starting slot
  • Yaci Store is connected to the network (check logs)

Discord notifications not working?

  • Make sure your webhook URL is correct
  • Check that the address has actually received new UTXOs
  • Look at the logs for any error messages

Need to restart?

./yaci-store.sh stop
./yaci-store.sh start

Next Steps

Congratulations! You've successfully set up a Yaci Store instance with custom plugins.

Learn more

Explore more plugins

Check out the yaci-store-plugins repository (opens in a new tab) for more examples.

Try these modifications

  • Track multiple addresses: Modify the filter to accept a list of addresses
  • Add native token tracking: Extend the queries to show native tokens (not just ADA)
  • Add webhook notifications: Send HTTP callbacks instead of Discord messages

Summary

In this tutorial, you learned:

  • ✅ How to download and set up Yaci Store with Docker
  • ✅ How to configure network connections and optimize settings
  • ✅ How to enable and write custom JavaScript plugins
  • ✅ How to filter blockchain data for specific addresses
  • ✅ How to query blockchain data from PostgreSQL
  • ✅ How to set up Discord notifications (optional)

You now have a powerful, lightweight Cardano indexer that tracks exactly the data you need. This is just the beginning - the plugin system opens up endless possibilities for customizing Yaci Store to your specific use case!

Happy indexing! 🚀