Somnia Reactivity
Off-chain Reactivity
Off-chain reactivity works through a WebSocket subscription served by the node API. A client subscribes to matching logs and receives pushed events as they land in blocks. Each event notification can optionally include the results of read-only simulations run against the same block, so that an application can react to both the event and its derived state at once. There's no additional latency, and no risk of inconsistency.
The subscription is served by the RPC node the client is connected to. It is not stored on chain, does not cost SOMI, and disappears when the client disconnects or unsubscribes.
For reactions that are part of chain execution rather than client-side, see on-chain reactivity.
Example use cases
- Drive a UI that updates the moment a token is transferred, without polling balances.
- Pipe live swap and liquidity events into an indexer or analytics database.
- Attach a
balanceOfcall to every matching Transfer log, so downstream consumers see both the event and the resulting balance atomically. - Build a bot that watches a DEX pool and only receives a message when the pool state has actually changed.
Quick start
Open a WebSocket connection to a Somnia node's API endpoint.
Call
eth_subscribeusing the methodsomnia_watchand a parameters object. The node returns a subscription ID.Read pushed
eth_subscriptionnotifications on the same connection. Theresultfield of each notification is the event payload.To stop, call
eth_unsubscribewith the subscription ID, or simply close the connection.
Minimal JSON-RPC sequence:
// -> request
{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_subscribe",
"params": [
"somnia_watch",
{
"eth_calls": []
}
]
}
// <- response
{ "jsonrpc": "2.0", "id": 1, "result": "0x1234..." }
// <- notifications
{
"jsonrpc": "2.0",
"method": "eth_subscription",
"params": {
"subscription": "0x1234...",
"result": {
"address": "0x...",
"topics": ["0x...", ...],
"data": "0x...",
"simulationResults": []
}
}
}
Using from TypeScript
TypeScript and JavaScript apps can use the @somnia-chain/reactivity package. It wraps the WebSocket protocol with Viem and manages subscription/unsubscription, so you don't have to drive eth_subscribe directly. (The same package can also create and manage on-chain reactivity subscriptions owned by an EOA.)
npm install @somnia-chain/reactivity viem
import { createPublicClient, defineChain, webSocket } from 'viem';
import { SDK } from '@somnia-chain/reactivity';
const chain = defineChain({ /* see viem docs */ });
const sdk = new SDK({
public: createPublicClient({ chain, transport: webSocket() }),
});
const subscription = await sdk.watch({
eventContractSources: ['0x046EDe9564A72571df6F5e44d0405360c0f4dCab'],
topicOverrides: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'],
ethCalls: [],
onData: (data) => {
const event = data.result;
console.log(event);
},
});
if (subscription instanceof Error) {
throw subscription;
}
// later
await subscription.unsubscribe();
The SDK requires the public client to use a WebSocket transport. You don't need a wallet client — off-chain subscriptions don't sign anything.
sdk.subscribe accepts a WebsocketSubscriptionInitParams object that maps to the Request parameters below:
| SDK field | RPC field |
|---|---|
eventContractSources |
address |
topicOverrides |
topics |
ethCalls |
eth_calls |
context |
context |
onlyPushChanges |
push_changes_only |
onData / onError |
(callback hooks, no RPC equivalent) |
For step-by-step walkthroughs, see the Wildcard Off-Chain Reactivity Tutorial and Off-Chain Reactivity: Filtered Subscriptions tutorial.
Reference
Subscription method
somnia_watch. Subscribed via the standard eth_subscribe request over a WebSocket transport.
Unsubscribed via eth_unsubscribe.
Request parameters
The parameters object follows eth_getLogs-style filter conventions with additions for
read-only simulation.
| Field | Type | Purpose |
|---|---|---|
address |
address or address[] |
Optional. Matches only logs emitted by one of these addresses. Omit or pass null for any address. |
topics |
(Hash | Hash[] | null)[] |
Optional. Positional topic filter. Entry i constrains topic i of the log. Each entry can be a single hash, an array of hashes (OR), or null (any). Omit for any topics. |
eth_calls |
array | Required. A list of read-only calls the node runs for each matching log. Pass [] if you only want log notifications with no attached simulation. See eth_calls below. |
context |
string or string[] |
Optional. Selectors that tell the node which fields of the matching log to append to each simulated call's calldata. See context below. |
push_changes_only |
bool |
Optional, default false. If true, and eth_calls is non-empty, only events whose simulation results differ from the previous event are pushed. |
eth_calls
Each element is a tuple of (callRequest, stateOverrides), mirroring eth_call:
callRequest— aneth_call-style transaction request object (to,data, optionalfrom,gas, etc.).stateOverrides— an optionaleth_call-style state override set, applied for the duration of the simulation.
For every matching log the node executes each call as a read-only simulation against
the end-of-transaction state. The raw returned bytes are included in simulationResults, in order.
The same single-item-or-list shortcut used elsewhere in Ethereum JSON-RPC applies: you can pass just the call request to skip state overrides.
context
When context is non-empty, for each simulated call the node appends 32-byte chunks drawn
from the matching log to the call's calldata, in the order given. This lets a single
simulation template be parameterised by the log that triggered it.
Valid selectors:
| Selector | What is appended |
|---|---|
address |
The log's emitter address, left-padded to 32 bytes. |
data |
The log's data bytes (not padded — appended verbatim). |
topic1 |
The log's first topic (topics[0]). |
topic2 |
The log's second topic (topics[1]). |
topic3 |
The log's third topic (topics[2]). |
topic4 |
The log's fourth topic (topics[3]). |
Note that the 1-based names refer to position within the log, so topic1 is the first topic
(usually the event signature), not the second. Selectors that ask for a topic the log does
not have are silently skipped.
If context is empty or absent, the node runs each eth_call exactly as written.
push_changes_only
If true and eth_calls is non-empty, the node compares the new simulationResults against
the previous pushed event on this subscription. If they are byte-identical, the new event
is dropped. The comparison is strictly adjacent — not against any reference value — so a
zero result will still be pushed whenever the prior push was non-zero. Use this to avoid
re-notifying on a genuinely unchanged view (e.g. a wash transfer that returns a balance to
its previous value), not to filter out uninteresting ones.
push_changes_only has no effect when eth_calls is empty, since there is nothing to
compare.
Event payload
Each pushed notification has a result field shaped as:
| Field | Type | Meaning |
|---|---|---|
address |
address |
The log's emitter. |
topics |
Hash[] |
The log's topics, up to four entries. |
data |
bytes |
The log's data payload. |
simulationResults |
bytes[] |
One entry per eth_calls entry, in order. Empty if no eth_calls were configured. Each entry is the raw returndata from the simulated call. |
Topics and data are exactly as they appear in the committed block, so decoding follows the same rules as the event's Solidity definition.
Filter matching semantics
The address and topics fields follow the same rules as eth_getLogs and
eth_subscribe("logs", ...):
- A log matches when its emitter matches the
addressfilter (oraddressis omitted). - For each topic position, a filter entry of
nullor omitted matches anything, a single hash matches exactly, and an array of hashes matches any member (OR). - The subscription only considers logs from transactions that were included in a block. It does not see pending pool activity.
Lifetime
The subscription lives on the API connection that created it. If the connection closes (client disconnect, node restart, network blip) the subscription is gone and must be recreated. The node does not buffer missed events across disconnects.
Subscriptions are not authenticated or owner-scoped in any way. Every client creates and sees only its own subscriptions.
Historical data
Off-chain reactivity only pushes events from the moment the subscription is created onwards.
For historical event retrieval, use eth_getLogs or another indexing solution.
Minimal example
Here's how to subscribe to all Wrapped SOMI transfer events on mainnet, checking the token balance of each recipient:
wscat --connect wss://api.infra.mainnet.somnia.network/ws --wait 300 --execute '
{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_subscribe",
"params": [
"somnia_watch",
{
"address": "0x046EDe9564A72571df6F5e44d0405360c0f4dCab",
"topics": [
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
],
"eth_calls": [
{
"to": "0x046EDe9564A72571df6F5e44d0405360c0f4dCab",
"data": "0x70a08231"
}
],
"context": ["topic3"],
"push_changes_only": false
}
]
}'
Walking through each field:
address— restricts matches to logs emitted by this specific ERC-20 token contract (see Smart contracts for the canonical mainnet address).topics— the single entry iskeccak256("Transfer(address,address,uint256)"), so that only ERC-20 Transfer logs match. The log's other two topics (fromandto) are left unconstrained.eth_calls— a single read-only call, back to the token contract. Itsdatais the 4-byte selectorkeccak256("balanceOf(address)"); the 32-byte recipient argument is not written inline, but supplied bycontextbelow.context— thetopic3selector instructs the node to append the log's third topic (the Transferto, already left-padded to 32 bytes) to the simulated call's calldata. The result is a well-formedbalanceOf(recipient)call, even though the subscription template is fixed.push_changes_only— enabling this here would discard events when two successive recipients happened to have the same token balance — not very helpful.
On running the command, you'll first receive the subscription ack:
{"jsonrpc":"2.0","id":1,"result":"0x..."}
And then one eth_subscription notification per WSOMI Transfer, as they happen:
{"jsonrpc":"2.0","method":"eth_subscription","params":{"subscription":"0x...","result":{"address":"0x046ede9564a72571df6f5e44d0405360c0f4dcab","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x...","0x..."],"data":"0x...","simulationResults":["0x..."]}}}
Each simulationResults[0] is the recipient's WSOMI balance after the transfer, denoted in wei.
Close the connection (Ctrl-C) to unsubscribe.
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