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:
- Connect to a Somnia WebSocket RPC endpoint.
- Subscribe to
Transfer(address,address,uint256)logs from one ERC-20. - Use
context: "topic3"to append the Transfer recipient to abalanceOfethCall. - Decode both the event log and the returned balance.
- 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