Somnia Reactivity / Tutorials

Off-Chain Reactivity: Filtered Subscriptions Tutorial

This tutorial shows how to narrow an off-chain Reactivity subscription to a specific contract and event. It subscribes to ERC-20 Transfer logs and asks the node to run balanceOf(recipient) for each matching log, so the pushed notification contains both the event and the recipient's post-transfer balance.

For the lower-level protocol reference, see Off-chain Reactivity.

What You Will Build

The script will:

  1. Connect to a Somnia WebSocket RPC endpoint.
  2. Subscribe to Transfer(address,address,uint256) logs from one ERC-20.
  3. Use context: "topic3" to append the Transfer recipient to a balanceOf ethCall.
  4. Decode both the event log and the returned balance.
  5. Unsubscribe cleanly when the process exits.

Prerequisites

You'll need Node.js 20+.

Install the SDK and Viem:

npm install @somnia-chain/reactivity viem
npm install --save-dev tsx typescript @types/node

Step 1: Set Up the SDK

This example uses Somnia mainnet. To use testnet, replace the chain ID, native currency and RPC URLs with the testnet values from Network Info.

import { SDK } from '@somnia-chain/reactivity';
import { createPublicClient, defineChain, webSocket } from 'viem';

const somniaMainnet = defineChain({
  id: 5031,
  name: 'Somnia Mainnet',
  nativeCurrency: {
    decimals: 18,
    name: 'SOMI',
    symbol: 'SOMI',
  },
  rpcUrls: {
    default: {
      http: ['https://api.infra.mainnet.somnia.network'],
      webSocket: ['wss://api.infra.mainnet.somnia.network/ws'],
    },
  },
});

const publicClient = createPublicClient({
  chain: somniaMainnet,
  transport: webSocket(somniaMainnet.rpcUrls.default.webSocket[0]),
});

const sdk = new SDK({ public: publicClient });

Step 2: Build the Filter and Read-Only Call

The SDK fields map directly to the somnia_watch RPC parameters:

SDK field RPC field Meaning
eventContractSources address Emitters to watch.
topicOverrides topics Positional topic filter.
ethCalls eth_calls Read-only calls to run for each matching log.
context context Log fields to append to each call's calldata.
onlyPushChanges push_changes_only Drop events whose call results match the previous pushed event.

For ERC-20 Transfer, topics[2] is the recipient address. The off-chain Reactivity context selector is one-based, so topic3 appends topics[2] to the call data.

import { toEventSelector, toFunctionSelector } from 'viem';

const tokenAddress = '0x046EDe9564A72571df6F5e44d0405360c0f4dCab';
const transferTopic = toEventSelector('Transfer(address,address,uint256)');
const balanceOfSelector = toFunctionSelector('balanceOf(address)');

const ethCall = {
  to: tokenAddress,
  data: balanceOfSelector,
};

balanceOfSelector contains only the 4-byte function selector. The node appends the recipient address from the log because the subscription uses context: "topic3".

Step 3: Subscribe

const subscription = await sdk.watch({
  eventContractSources: [tokenAddress],
  topicOverrides: [transferTopic],
  ethCalls: [ethCall],
  context: 'topic3',
  onlyPushChanges: false,
  onData: (data) => {
    const event = data.result;
    console.log('Filtered notification:', event);
  },
  onError: (error) => {
    console.error('Subscription error:', error);
  },
});

if (subscription instanceof Error) {
  throw subscription;
}

onlyPushChanges compares the raw simulationResults with the previous pushed event on this subscription. It is useful when you care only about changed view results, but it is not a general event filter. For this recipient-balance example, leave it false unless you are comfortable dropping consecutive transfers whose recipients happen to have identical balances.

Step 4: Decode the Event and Balance

import { decodeEventLog, decodeFunctionResult, erc20Abi } from 'viem';

function decodeTransferWithBalance(event: {
  topics: `0x${string}`[];
  data: `0x${string}`;
  simulationResults: `0x${string}`[];
}) {
  const transfer = decodeEventLog({
    abi: erc20Abi,
    topics: event.topics,
    data: event.data,
  });

  const recipientBalance = decodeFunctionResult({
    abi: erc20Abi,
    functionName: 'balanceOf',
    data: event.simulationResults[0],
  });

  console.log('Transfer:', transfer.args);
  console.log('Recipient balance:', recipientBalance);
}

Call decodeTransferWithBalance(data.result) from onData.

Full Script

Save this as filtered.ts:

import { SDK } from '@somnia-chain/reactivity';
import {
  createPublicClient,
  decodeEventLog,
  decodeFunctionResult,
  defineChain,
  erc20Abi,
  toEventSelector,
  toFunctionSelector,
  webSocket,
} from 'viem';

const somniaMainnet = defineChain({
  id: 5031,
  name: 'Somnia Mainnet',
  nativeCurrency: {
    decimals: 18,
    name: 'SOMI',
    symbol: 'SOMI',
  },
  rpcUrls: {
    default: {
      http: ['https://api.infra.mainnet.somnia.network'],
      webSocket: ['wss://api.infra.mainnet.somnia.network/ws'],
    },
  },
});

// Wrapped SOMI - see Troubleshooting below.
const tokenAddress = '0x046EDe9564A72571df6F5e44d0405360c0f4dCab';

const transferTopic = toEventSelector('Transfer(address,address,uint256)');
const balanceOfSelector = toFunctionSelector('balanceOf(address)');

const publicClient = createPublicClient({
  chain: somniaMainnet,
  transport: webSocket(somniaMainnet.rpcUrls.default.webSocket[0]),
});

const sdk = new SDK({ public: publicClient });

async function main() {
  const subscription = await sdk.watch({
    eventContractSources: [tokenAddress],
    topicOverrides: [transferTopic],
    ethCalls: [
      {
        to: tokenAddress,
        data: balanceOfSelector,
      },
    ],
    context: 'topic3',
    onlyPushChanges: false,
    onData: (data) => {
      const event = data.result;

      const transfer = decodeEventLog({
        abi: erc20Abi,
        topics: event.topics,
        data: event.data,
      });

      const recipientBalance = decodeFunctionResult({
        abi: erc20Abi,
        functionName: 'balanceOf',
        data: event.simulationResults[0],
      });

      console.log('Transfer:', transfer.args);
      console.log('Recipient balance:', recipientBalance);
    },
    onError: (error) => {
      console.error('Subscription error:', error);
    },
  });

  if (subscription instanceof Error) {
    throw subscription;
  }

  console.log('Subscribed:', subscription.subscriptionId);

  process.on('SIGINT', async () => {
    await subscription.unsubscribe();
    process.exit(0);
  });
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

Run it:

npx tsx filtered.ts

Troubleshooting

  • Wrapped SOMI is not always actively transferred, so you may not see many events. You can pick another from Smart contracts or from the recent transactions on the block explorer.
  • If the subscription starts but decoding fails, confirm the ABI matches the event signature in topicOverrides.

Copied from docs.somnia.network (somnia-main-docs) by scripts/import-gitbook.mts, until it finds a home in a product's docs.Edit this page