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.)
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:
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.
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:
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:
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:
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:
And then one eth_subscription notification per WSOMI Transfer, as they happen:
Each simulationResults[0] is the recipient's WSOMI balance after the transfer, denoted in wei.
Close the connection (Ctrl-C) to unsubscribe.
Last updated