# 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](/developer/reactivity/reactivity-offchain.md).

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

```bash
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](/developer/network-info.md).

```typescript
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.

```typescript
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

```typescript
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

```typescript
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`:

```typescript
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:

```bash
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](/developer/smart-contracts.md) or from the recent transactions on the [block explorer](https://explorer.somnia.network).
* If the subscription starts but decoding fails, confirm the ABI matches the event signature in `topicOverrides`.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.somnia.network/developer/reactivity/tutorials/off-chain-reactivity-filtered-subscriptions-tutorial.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
