Reactivity

Somnia pushes events to you with the state that goes with them. That is the whole idea: on any other EVM chain, reacting to an event is "see the log, then fetch the state" — two round trips, racing the next block. Here the notification carries the log and the results of a fixed set of eth_calls executed at that same block.

This module is a pointer, not a port. The implementation is @somnia-chain/reactivity — the upstream repo owns the protocol, the Solidity side (SomniaEventHandler, in @somnia-chain/reactivity-contracts) and the client. @somnia-chain/markets-sdk/reactivity re-exports that package verbatim and adds only the glue a markets consumer needs, so there is no second copy of the ABIs or the validation rules to drift out of step.

ts
import { createReactivity, unwrap } from "@somnia-chain/markets-sdk/reactivity";

Install

@somnia-chain/reactivity is an optional peer dependency — exactly like react is for the /react entry. Only callers of this subpath install it:

sh
pnpm add @somnia-chain/reactivity

It is published on npmjs, while @somnia-chain/markets-sdk is published to GitHub Packages. If your .npmrc pins the whole scope to GitHub Packages — this repo's root .npmrc does — that pin has to be relaxed for the install to resolve, since npm's registry config is per scope, not per package:

ini
# .npmrc — resolve @somnia-chain/reactivity from npmjs, markets-sdk from GH Packages
@somnia-chain:registry=https://registry.npmjs.org/
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}

(Inside this repo the SDK's own devDependency sidesteps the pin by pointing at the npmjs tarball directly, so pnpm install in packages/sdk needs no .npmrc change and the publish workflow's registry config stays untouched. The clean long-term fix is mirroring @somnia-chain/reactivity into GitHub Packages and switching that entry back to ^0.2.1.)

What this entry adds

ExportWhy it isn't upstream
createReactivity(client, { wallet? })builds the reactivity client on the markets client's own socket
unwrap(result)upstream returns Error objects; this SDK throws (see CONVENTIONS)
SOMNIA_REACTIVITY_PRECOMPILE_ADDRESSnot exposed by upstream's published build
DEFAULT_SUBSCRIPTION_OPTIONSthe protocol's own SomniaExtensions.DEFAULT_* values: upstream ships them at runtime but omits them from its .d.ts, so they can't be re-exported with types (a test pins ours against upstream's)
isLocalPrecompileUnavailable(chainId)already lives in this package; the check to run before any Solidity subscription

Everything else — Reactivity / SDK, SomniaReactivityPrecompileABI, SomniaEventHandlerABI and every type — is upstream's, re-exported. A test asserts the re-exported classes are upstream's own identity, so this can never quietly become a fork.

Two flavours

  • WebSocket reactivity (watch) — a TypeScript subscription over the socket (somnia_watch). Each matched log arrives with your eth_call results from the same block. Nothing on-chain, nothing to pay for, gone when you disconnect.
  • Solidity reactivity (subscribe) — a subscription registered on-chain against the reactivity precompile, which makes validators call your handler contract's onEvent(address,bytes32[],bytes) when a matching log lands. Callbacks are paid out of the subscription owner's native balance (which must hold at least 32 SOMI/STT).

This protocol already runs on the second one: ProphecyOracleAdapter is a Solidity handler the precompile pings on AnswerPosted, and MarketCreator rolls are scheduled subscriptions — which is what enableReactivity / setReactivityGasParams on the admin surfaces are configuring. This module is the same primitive pointed at your contracts.

Watching (TypeScript)

createReactivity hands upstream the markets client's public client, which is already a WebSocket pointed at the right node:

ts
import { SomniaMarkets } from "@somnia-chain/markets-sdk";
import { createReactivity, unwrap } from "@somnia-chain/markets-sdk/reactivity";
import { somniaShannon } from "@somnia-chain/markets-sdk/chains";

const exchange = new SomniaMarkets({ chain: somniaShannon, wsRpcUrl, indexerUrl });
const reactivity = createReactivity(exchange.client);

// Every Transfer on the collateral token, with the sender's new balance read
// at the very same block — one notification, no follow-up call.
const watch = unwrap(
  await reactivity.watch({
    eventContractSources: [collateral],
    topicOverrides: [transferTopic],
    ethCalls: [{ to: collateral, data: balanceOfCalldata }],
    onData: (n: ReactivityNotification) => console.log(n.result.simulationResults),
  }),
);
await watch.unsubscribe();

Use a chain definition from /chains. Upstream's watch opens its own socket with viem's webSocket() without a URL, so the endpoint comes from chain.rpcUrls.default.webSocket[0]. viem's own somniaShannon has no WebSocket entry, so watch fails on it; every definition in @somnia-chain/markets-sdk/chains carries one.

Notes that matter in practice:

  • Every filter is optional, and omitting one means "everything". A bare { ethCalls: [], onData } tails every event on the chain.
  • ethCalls is the point. Batch several reads into one call against Multicall3 where you can — the notification is only as fast as the calls it carries. simulationResults comes back in the order subscribed.
  • context splices event-sourced values into the ethCalls calldata (topic1topic4, data, address), so one subscription can read state about the thing that just happened rather than a fixed address.
  • onlyPushChanges suppresses notifications whose call results match the previous ones — a cheap way to watch for a state change rather than an event.
  • The payload is at notification.result{ address, topics, data, simulationResults }. Upstream's README and type docs say notification.params.result, one level deeper; that is stale, because viem's WebSocket transport unwraps the JSON-RPC envelope before calling onData. Type the callback with ReactivityNotification (upstream types it any) — the shape is asserted against a live node in test/reactivity.e2e.test.ts, which runs with SOMNIA_E2E_WS set.

This is a different tool from client.watchMarket: the markets tail materializes the order book from indexed protocol events, while watch is a general-purpose log+state subscription for anything on the chain.

Subscribing (Solidity)

Deploy a handler extending SomniaEventHandler (from @somnia-chain/reactivity-contracts), then register it. The signer becomes the subscription owner and its balance funds every callback:

ts
import { createReactivity, unwrap, DEFAULT_SUBSCRIPTION_OPTIONS } from "@somnia-chain/markets-sdk/reactivity";

const reactivity = createReactivity(exchange.client, { wallet: walletClient });

const hash = unwrap(
  await reactivity.subscribe({
    handlerContractAddress: handler,
    filter: { emitter: collateral, eventTopics: [transferTopic] },
    options: DEFAULT_SUBSCRIPTION_OPTIONS,
  }),
);

Upstream validates the precompile's preconditions before spending gas — a non-zero handler, well-formed bytes32 topics, at least one narrowing filter (a match-everything subscription is rejected), 0 < gasLimit <= 200_000_000, maxFeePerGas >= priorityFeePerGas + 6 gwei (or 0 to skip that check), and the owner holding ≥ 32 SOMI/STT. Note that writes need a wallet client: pass one to createReactivity, or upstream's write silently resolves to null.

Cancel with unsubscribe(subscriptionId) (owner only), read one back with getSubscriptionInfo(subscriptionId), and use subscribeRaw when you need the precompile's full struct including the protocol-reserved fields.

Scheduling — cron, blocks, epochs

The precompile emits its own system events (Schedule, BlockTick, EpochTick), so "call me later" is just a subscription to one of them with the tick as a topic filter:

ts
await reactivity.scheduleSubscriptionAtTimestamp({ timestampMs: Date.now() + 60_000, handlerContractAddress, options });
await reactivity.scheduleSubscriptionAtBlock({ blockNumber: head + 100n, handlerContractAddress, options });
await reactivity.scheduleSubscriptionAtEpoch({ epochNumber: 7n, handlerContractAddress, options });

scheduleSubscriptionAtBlock with no blockNumber leaves the block topic a wildcard — a callback on every block. Timestamps are unix milliseconds and must be at least a second out; a block must be past the head.

Errors

Upstream methods resolve to T | Error instead of throwing. Two ways to live with that, both fine:

ts
// 1. this SDK's contract — throw (recommended)
const hash = unwrap(await reactivity.subscribe({ ... }));

// 2. upstream's own idiom — check
const result = await reactivity.subscribe({ ... });
if (result instanceof Error) throw result;

Local development

The precompile does not exist on anvil or hardhat — check isLocalPrecompileUnavailable(chainId) before offering Solidity subscriptions in a UI, and note that it has no bytecode on any chain, so eth_getCode can't probe for it. somnia_watch is a node feature too: a plain anvil node doesn't serve it. Test reactivity against Shannon (or Elwood/Hideki).