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 balanceOf call 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

  1. Open a WebSocket connection to a Somnia node's API endpoint.

  2. Call eth_subscribe using the method somnia_watch and a parameters object. The node returns a subscription ID.

  3. Read pushed eth_subscription notifications on the same connection. The result field of each notification is the event payload.

  4. To stop, call eth_unsubscribe with 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:

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 — an eth_call-style transaction request object (to, data, optional from, gas, etc.).

  • stateOverrides — an optional eth_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 address filter (or address is omitted).

  • For each topic position, a filter entry of null or 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 is keccak256("Transfer(address,address,uint256)"), so that only ERC-20 Transfer logs match. The log's other two topics (from and to) are left unconstrained.

  • eth_calls — a single read-only call, back to the token contract. Its data is the 4-byte selector keccak256("balanceOf(address)"); the 32-byte recipient argument is not written inline, but supplied by context below.

  • context — the topic3 selector instructs the node to append the log's third topic (the Transfer to, already left-padded to 32 bytes) to the simulated call's calldata. The result is a well-formed balanceOf(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