Polkadot: Streaming Parachain events with Polkadot-API (PAPI) library; a Paseo Asset-Hub case study.

“Never miss a transaction again; meet Wallet Peep

Introduction

When you’re active in the blockchain space, one of the biggest annoyances is keeping track of wallet activity in real time.

You might receive tokens, get a transfer, or interact with a parachain, and never know when it happens until you check manually.

And one of the most exciting aspects of Web3 development is connecting decentralized infrastructure with everyday tools people already use.
That’s where @Wallet Peep comes in.

It’s a lightweight, real-time monitoring service that watches wallet addresses on Polkadot parachains (paseo asset hub for now) and sends instant Telegram notifications whenever a transaction happens, allowing users to track wallet activities in real-time without leaving their chat app.

In this tutorial, we’ll build Wallet Peep, a Telegram bot that listens to balance transfers and asset events on Polkadot’s AssetHub (Paseo) and sends instant alerts to subscribed users.

By the end of this guide, you’ll understand how to:

  • Connect to a Substrate-based chain using the Polkadot API (PAPI)
  • Set up a real-time listener for blockchain events
  • Store and manage user subscriptions in a Turso (SQLite Cloud) database
  • Deliver on-chain alerts directly to Telegram users
    Let’s build it step by step.

Project Overview

At its core, Wallet Peep connects three moving parts:

  1. Telegram Bot – built using Telegraf.js, responsible for user commands and alerts
  2. Blockchain Event Listener – powered by polkadot-api (PAPI) to stream chain data
  3. Database Layer (Turso) – manages users and their wallet subscriptions
    Each component communicates seamlessly to ensure events on-chain are instantly reflected in Telegram notifications.

Here’s an architectural overview:

Why These Tools?

  • Polkadot API (PAPI) → A modern, lightweight library for interacting with Polkadot/Substrate networks, with built-in support for WebSocket connections and typed APIs.
  • Telegraf → Makes Telegram bot development clean and modular.
  • Turso (SQLite Cloud) → A distributed SQLite database for quick cloud persistence.
  • Express.js → Minimal backend used to keep the bot service alive and expose a simple health route.

How Events Work On Polkadot ⛓️

On Polkadot (and sister chain Kusama), events are fundamentally baked into the State Transition Function itself, the Substrate Runtime (i know, crazy). The events represent state changes, like a token transfer or a balance update, without being part of the main transaction itself, if you’re familiar with Ethereum logs or Solana program logs, then it’s practically the same thing thing being mentioned here, Polkadot events serve the same purpose.

One thing of note that sets Polkadot apart is that events emitted by polkadot parachains provide far richer context for DeFi, assets, and other use cases, and while other blockchain ecosystems (ethereum, sui, solana etc.) rely solely on smart contracts as the sole mechanism for event emision, Polkadot has a distinct design of event emission baked into it’s Node (Substrate Runtime) and does not need smart contracts to emit events , events are natively emitted by pallets (modules such as Balances, MultiSigs, Identity, Assets etc.) that make up the Runtime.

And just like ethereum’s event topics, Polkadot events can be filtered by pallet and method, For example, when a transfer happens, an event like Balances.Transfer appears with details such as who sent the tokens, who received them, and how much was transferred (here).

I earlier mentioned that polkadot by default provides enriched event data, to not get lost in the noise or to see which events are available for a specific chain, check out https://chains.papi.how, it’s a great resource for browsing events, calls, and storage across networks. For example, the Paseo Asset Hub page at https://chains.papi.how/paseo_asset_hub/ lists:

  • Events (e.g., Balances.Transfer with parameters like from, to, and amount)
  • Calls
  • Storage

Just about everything you’d need for exploring what’s available when building your own event listeners!.

Note: For Polkadot Parachains with a Substrate Runtime that support smart contract functionality — by including pallet-revive/pallet-contracts — events are still emitted by smart contracts in addition to the already baked in pallet events.

Prerequisites

Before you begin, ensure you have:

Folder Structure

Below is how the Wallet Peep project is organized:

wallet-peep/
│
├── src/
│   ├── main.ts              # core logic: Bot, listener, and all!
├── schema.sql               # DB blueprint for users and subs.
├── package.json
├── tsconfig.json
└── README.md

Database Setup (Turso):

We’ll use Turso for lightweight and distributed persistence. The schema is small and simple.

-- schema.sql

-- users table
CREATE TABLE IF NOT EXISTS users (
    telegram_id BIGINT PRIMARY KEY,
    wallet_address TEXT
);

-- subscriptions table
CREATE TABLE IF NOT EXISTS subscriptions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    telegram_id BIGINT NOT NULL,
    parachain TEXT NOT NULL,
    wallet_address TEXT NOT NULL,
    UNIQUE(telegram_id, parachain, wallet_address),
    FOREIGN KEY (telegram_id) REFERENCES users(telegram_id) ON DELETE CASCADE
);

This structure ensures:

  • Each Telegram user can subscribe to multiple wallets.
  • Each subscription is unique (no duplicates).
  • Foreign keys maintain relational integrity.

Note: Run this schema on your Turso database before starting the bot.

Obtaining Polkadot PAPI Descriptors (PAPI Codegen).

In the Polkadot ecosystem, PAPI descriptors play a very developer friendly role in ensuring that our application communicates with the substrate node in a type-safe way.
Descriptors are generated directly from a chain’s metadata and act as a guide that describe what calls, events, and storage items exist in a particular Substrate Runtime. This helps eliminate & prevent common bugs that arise from mismatched or outdated event structures.

Without PAPIs descriptors (thank you PAPI … the library), we would have had to make assumptions about how a chain’s data is formatted, which can lead to runtime errors or broken integrations whenever the chain’s substrate runtime changes (this is not a fork thanks to Polkadot’s design, another win), but with descriptors; we get strong typing, better developer experience, and type-safe code.

Alright, enough of my rambling, here’s how we generate Paseo Asset Hub PAPI descriptors using the Polkadot API CLI:

  1. Install the PAPI CLI: pnpm install -g @polkadot-api/cli.

  2. Add the chain: npx papi add passetHub -w wss://paseo-asset-hub-rpc.polkadot.io (Or use predefined: -n paseo_asset_hub for convenience!).

  3. Generate: npx papi generate, this places the descriptors in .papi/descriptors for import (it also installs it as a package dependency).

And thats! it!, now we can import like import { passetHub } from "@polkadot-api/descriptors"; in our code.

Note: It’s generally advised to regenerate PAPI descriptors after runtime upgrades so has to have the latest metadata changes (if any).

Listening to Paseo Asset Hub Transfers: Real-Time Event Tracking 🎧

We’ll use PAPI to connect to Paseo’s AssetHub, listen for transfers, and react in real time.
Here’s the code snippet handling that (main.ts):

// Paseo AssetHub WS endpoints – redundancy is key!
const WS_ENDPOINTS = [
    "wss://sys.ibp.network/asset-hub-paseo",
    "wss://asset-hub-paseo.dotters.network",
    "wss://rpc.ibp.network/asset-hub-paseo",
    "wss://asset-hub-paseo-rpc.dwellir.com",
];

// Start event listener for Paseo AssetHub
async function startListener() {
    const client = createClient(getWsProvider(WS_ENDPOINTS));
    const api = client.getTypedApi(passetHub);
    console.log('n[BlockCHain]n✅ Connected to Paseo AssetHub n🌫️ Listening for transfer events...n');

    // Subscribe to transfer events
    api.event.Balances.Transfer.watch((_) => true).subscribe(event => {
        const { from, to, amount } = event.payload;
        const amountStr = (BigInt(amount) / BigInt(1e10)).toString(); // PLANCK, 10 decimals
        const { number: blockNumber, hash: blockHash } = event.meta.block;

        // Check if from or to matches subscribed wallet addresses
        const matchedUsers = new Set<number>();
        if (subscriptions.has(from)) {
            subscriptions.get(from)!.forEach(tId => matchedUsers.add(tId));
        }
        if (subscriptions.has(to)) {
            subscriptions.get(to)!.forEach(tId => matchedUsers.add(tId));
        }

        if (matchedUsers.size === 0) {
            console.log(`🌫️[Transfer Event]: nFrom:${from} nTo:${to} nAmount:${amount} nBlockNo:#${blockNumber} nBlockHash(${blockHash})`);
        } else {
            console.log(`🟩[Matched Transfer Event]✅😀: nFrom:${from} nTo:${to} nAmount:${amount} nBlockNo:#${blockNumber} nBlockHash(${blockHash})`);
        }

        // Notify users
        for (const telegramId of matchedUsers) {
            bot.telegram.sendMessage(
                telegramId,
                `🔔 Chain Alert: Paseo AssetHub! ⛓️n` +
                `n` +
                `🚨 Event: Balance.Transfer n` +
                `n` +
                `💸 From: ${from}n` +
                `n` +
                `🎯 To: ${to}n` +
                `n` +
                `💰 Amount: ${amount} (PAS)n` +
                `n` +
                `🗳️ Block: #${blockNumber}n` +
                `Stay in the loop with your wallet activity! 🔍`
            );
        }
    });
}

This snippet above connects to multiple WebSocket providers, ensuring that our listener remains stable even if one connection fails. Once connected, it watches for Balances.Transfer events triggered whenever tokens move between accounts!, we log the sender, receiver, and amount, giving us real-time insight into on-chain activity ⛓️.

Note: In a production setup, this could expand to trigger slack/whatsapp/email/push notifications, or power analytics dashboards.

Implementing the Telegram Bot: Chatty and Cheerful!💬

PAPI might be the star of this show, but the telegram bot gives it a stage to shine!(thank you Pavel Durov). Implementing the bot commands actually turned out to be fairly simple and straightforward, practically self explanatory.

1. Init and Env Check:

dotenv.config();

const TURSO_URL = process.env.TURSO_DATABASE_URL!;
const TURSO_TOKEN = process.env.TURSO_AUTH_TOKEN!;
const TELEGRAM_TOKEN = process.env.TELEGRAM_TOKEN!;

// Exit if env vars are missing – safety first!
if (!TURSO_URL || !TURSO_TOKEN || !TELEGRAM_TOKEN) {
    console.error('Missing required environment variables. Please set TURSO_DATABASE_URL, TURSO_AUTH_TOKEN, and TELEGRAM_TOKEN.');
    process.exit(1);
}

// Turso DB client
let db: Client;
try {
    db = createDbClient({ url: TURSO_URL, authToken: TURSO_TOKEN });
} catch (error) {
    throw new Error(`🟥 DB-ERROR: ${error}`);
}
console.log("✅ -> Connected to TursoDB cloud! ✅");

// Telegram bot
const bot = new Telegraf(TELEGRAM_TOKEN);

2. /start command: Welcomes with open arms!

bot.command('start', (ctx) => {
    ctx.reply(
        "👋 Hey there, Trailblazer!nn" +
        "Welcome to Wallet Peep Bot🤖 your personal on-chain watchdog! 🐶🪙nn" +
        "Here's what I can do for you:n" +
        "💬 /chains - See which networks I'm watching (Paseo AssetHub for now)! .n" +
        "📬 /add <address> - Get alerts whenever wallet <address> sends or receives PAS.n" +
        "🔔 /alerts - View your active subscriptions.n" +
        "❌ /remove <address> - Stop tracking a wallet address.nn" +
        "Stay ahead of every transaction, never miss a transfer, ever again⚡nn" +
        "Let's get started: try adding your first wallet withn" +
        "👉 /add <your_wallet_address>nn" +
        "🌫️The chain never sleeps 💤, but now, you don't have to either. 🌙"
    );

    console.info(`🌫️ [Start]: User:${ctx.from.username}`);

    // Insert user into DB if not exists
    const telegramId = ctx.from.id;
    db.execute({
        sql: 'INSERT OR IGNORE INTO users (telegram_id, username) VALUES (?, ?)',
        args: [telegramId, ctx.from.username || ""],
    })
        .then((r) => {
            console.log(`✅ -> User:${ctx.from.username} added to DB with RowId:${r.lastInsertRowid}`);
            ctx.reply(`n✅ Welcome: ${ctx.from.username}`);
        })
        .catch(console.error);
});

3. /add command: Add a wallet with validation.

bot.command('add', async (ctx) => {
    const args = ctx.message.text.split(' ').slice(1);
    if (args.length !== 1) {
        return ctx.reply('Usage: /add <address>');
    }
    const address = args[0];
    console.log(`🌫️ -> Adding watchlist address:${address} for user:${ctx.from.username}`);

    if (!isAddress(address)) {
        return ctx.reply('🟥 Invalid address format, please provide a valid Substrate address.');
    }

    const userId = ctx.from.id;
    // Store in DB
    try {
        await db.execute({
            sql: 'INSERT OR REPLACE INTO subscriptions (telegram_id, parachain, wallet_address) VALUES (?, ?, ?)',
            args: [userId, 'paseo', address],
        });
    }
    catch (error) {
        console.log(`🟥 Error Adding Address:${address} for User:${ctx.from.username}`, error);
        return ctx.reply("🟥 Failed to add address to watchlist.");
    }

    // Update memory
    if (!subscriptions.has(address)) {
        subscriptions.set(address, new Set());
    }
    subscriptions.get(address)!.add(userId);
    ctx.reply(`✅ Added address ${address} for Paseo AssetHub!⚡.`);
    console.log(`✅ -> Address:${address} added to watchlist for:${ctx.from.username}`);
    console.log("🌫️ [Subscriptions]: ", subscriptions);
});

4. /remove command: Remove a wallet from watchlist.

bot.command('remove', async (ctx) => {
    const args = ctx.message.text.split(' ').slice(1);
    if (args.length !== 1) return ctx.reply('Usage: /remove <address>');
    const address = args[0];
    const telegramId = ctx.from.id;
    const result = await db.execute({
        sql: 'DELETE FROM subscriptions WHERE telegram_id = $telegram_id AND parachain = $parachain AND wallet_address = $wallet_address',
        args: { telegram_id: telegramId, parachain: 'paseo', wallet_address: address },
    });
    if (result.rowsAffected === 0) {
        return ctx.reply('🌫️ No subscription found for that address.');
    }
    if (subscriptions.has(address)) {
        subscriptions.get(address)!.delete(telegramId);

        // If no subscriptions exist for the address, we obliterate it.
        if (subscriptions.get(address)!.size === 0) {
            subscriptions.delete(address);
        }
    }
    ctx.reply(`✅ Removed address ${address} from Paseo AssetHub watchlist!.`);
});

5. We preload subscriptions (watchlist) from DB and launch!🚀.

// Load subscriptions from DB into memory
async function loadSubscriptions() {
    const result = await db.execute({
        sql: 'SELECT telegram_id, wallet_address, parachain FROM subscriptions WHERE parachain = $parachain',
        args: { parachain: 'paseo' },
    });
    console.log("🌫️ [Alert Subscriptions]: ", result.rows);

    for (const row of result.rows) {
        const telegramId = Number(row.telegram_id);
        const address = row.wallet_address as string;
        if (subscriptions.has(address)) {
            subscriptions.get(address)!.add(telegramId);
        } else {
            const subscribedUsers = new Set<number>([telegramId]);
            subscriptions.set(address, subscribedUsers);
        }
    }
    console.log('✅ -> Loaded subscriptions from DB.');
}

// Start everything
async function main() {
    await loadSubscriptions();
    await startListener();
    bot.launch(() => {
        console.log('🌫️ Bot launched. 🚀');
    });

    // Express server (minimal)
    app.get('/', (req, res) => res.send('🟩 -> Wallet Peep Bot Service Is Running. 🚀'));
    app.listen(PORT, () => console.log(`🟩 -> Express listening on port ${PORT} 🚀`));
}

main().catch(console.error);

Our overall code structure and design ensures that whenever a transfer involving a tracked wallet occurs, every subscribed user receives a Telegram alert, simple & efficient, just how I like things.

Here’s what the bot looks like in action:



Running Wallet Peep: Let’s Get This 🤖 Started!.

To start locally: Clone the repo, install dependencies, and fire away!.

pnpm install
pnpm run dev

Or even ts-node src/main.ts for TypeScript fans! (Bun would be nice here)

Note: Might need to run corepack enable to use pnpm.

If everything is set up correctly, and all’s well, you’ll see logs like:

✅ -> Connected to TursoDB cloud! ✅
🌫️ Bot launched. 🚀
[BlockCHain]
✅ Connected to Paseo AssetHub 
🌫️ Listening for transfer events...

Test it: Add a wallet via Telegram, make a test transfer on Paseo (grab PAS from the faucet!), and watch the alerts roll in. Pure joy!

Conclusion

Wallet Peep demonstrates how powerful it can be to connect on-chain data with familiar off-chain interfaces like Telegram. By integrating the Polkadot API (PAPI), a real-time database layer like Turso, and a responsive Telegram bot, we can bring blockchain transparency directly to users where they already are, without requiring them to open a block explorer or manage complex tools.

You can explore the complete source code and implementation details on GitHub: https://github.com/TEMHITHORPHE/wallet-peep, fork, tweak, and make it yours!

Got ideas? Drop a comment🗨️, let’s spread the JAM!!⚡

Similar Posts