From zerion-agent
Guides Somnia Reactivity pub/sub for reactive dApps: TypeScript WebSocket subscriptions, Solidity handlers, gas config, system events (BlockTick/Schedule), troubleshooting.
npx claudepluginhub zeriontech/zerion-ai --plugin zerion-agentThis skill uses the workspace's default tool permissions.
You are an expert on Somnia Reactivity — the event-driven execution model built into the Somnia blockchain. You help developers build reactive dApps using both off-chain (TypeScript/WebSocket) and on-chain (Solidity handler) subscriptions.
Supplies Somnia EVM L1 blockchain details: network configs, Reactivity pub/sub for events+state, Solidity handlers, TypeScript SDK, gas model, and deployment guidance.
Builds Solana applications using Helius infrastructure for transaction sending, asset/NFT queries via DAS API, real-time streaming with WebSockets/Laserstream, webhooks, priority fees, and wallet analysis.
Integrates Alchemy APIs into application code (servers, backends, dApps, scripts) via API key. Covers EVM JSON-RPC, Token/NFT/Transfers/Prices/Portfolio APIs, Solana RPC/DAS/gRPC, Sui gRPC, Wallets. Requires $ALCHEMY_API_KEY.
Share bugs, ideas, or general feedback.
You are an expert on Somnia Reactivity — the event-driven execution model built into the Somnia blockchain. You help developers build reactive dApps using both off-chain (TypeScript/WebSocket) and on-chain (Solidity handler) subscriptions.
Related skill: For broader Somnia chain knowledge (gas model, deployment with Foundry/Hardhat, Session RPCs with
@somnia-chain/viem-session-account, Agents), see thesomnia-blockchainskill. This skill is the detailed Reactivity reference that supplements the Reactivity overview insomnia-blockchain.
Reactivity is currently only available on Somnia Testnet.
| Property | Value |
|---|---|
| Chain ID | 50312 |
| RPC (HTTP) | https://api.infra.testnet.somnia.network |
| RPC (WebSocket) | wss://api.infra.testnet.somnia.network |
| Block Explorer | https://shannon-explorer.somnia.network |
| Native Token | STT (Somnia Testnet Token), 18 decimals |
| Faucet | https://testnet.somnia.network |
| Min Balance for On-Chain Subs | 32 STT |
import { defineChain } from 'viem'
const somniaTestnet = defineChain({
id: 50312,
name: 'Somnia Testnet',
nativeCurrency: { name: 'STT', symbol: 'STT', decimals: 18 },
rpcUrls: {
default: {
http: ['https://api.infra.testnet.somnia.network'],
webSocket: ['wss://api.infra.testnet.somnia.network']
}
},
blockExplorers: {
default: { name: 'Somnia Explorer', url: 'https://shannon-explorer.somnia.network' }
}
})
Reactivity is Somnia's pub/sub system baked into the blockchain. When a smart contract emits an event, validators detect it, bundle the event with related contract state (read at the same block height), and deliver the payload to subscribers. Execution of on-chain handlers happens in a subsequent block, not the same block as the event.
Key distinction: state reads are from the event's block (consistent snapshot), but handler execution occurs in the next block(s).
Off-chain subscriptions use WebSockets for real-time event + state delivery to JavaScript/TypeScript applications.
npm i @somnia-chain/reactivity viem
import { createPublicClient, createWalletClient, http } from 'viem'
import { SDK } from '@somnia-chain/reactivity'
const publicClient = createPublicClient({
chain: somniaTestnet,
transport: http()
})
// Optional: Wallet client for on-chain writes
const walletClient = createWalletClient({
account,
chain: somniaTestnet,
transport: http()
})
const sdk = new SDK({
public: publicClient,
wallet: walletClient // omit if not executing on-chain transactions
})
For applications that create or manage many subscriptions rapidly, use a session wallet client instead of a private key EOA. Session accounts eliminate nonce management and signing overhead.
npm i @somnia-chain/viem-session-account
import { createPublicClient, http } from 'viem'
import { createSessionClient, somniaTestnet } from '@somnia-chain/viem-session-account'
import { SDK } from '@somnia-chain/reactivity'
const publicClient = createPublicClient({
chain: somniaTestnet,
transport: http(),
})
// Session client replaces the standard walletClient
const sessionClient = await createSessionClient({
seed, // cryptographically secure hex string
chain: somniaTestnet,
transport: http(),
})
const sdk = new SDK({
public: publicClient,
wallet: sessionClient, // works as a drop-in replacement
})
// All SDK write operations now use session RPCs
await sdk.createSoliditySubscription({ ... })
Seed security: The session seed is equivalent to a private key. Generate it with
toHex(randomBytes(32))(Node.js) ortoHex(crypto.getRandomValues(new Uint8Array(32)))(browser). See thesomnia-blockchainskill for full session account setup.
import { SDK, SubscriptionCallback } from '@somnia-chain/reactivity'
const subscription = await sdk.subscribe({
ethCalls: [],
onData: (data: SubscriptionCallback) => {
console.log('Event topics:', data.result.topics)
console.log('Event data:', data.result.data)
console.log('State reads:', data.result.simulationResults)
},
onError: (error: Error) => console.error('Subscription error:', error),
eventContractSources: ['0xContractAddress'],
topicOverrides: ['0xEventSignatureHash'],
onlyPushChanges: false
})
// Clean up when done
subscription.unsubscribe()
| Parameter | Type | Required | Description |
|---|---|---|---|
ethCalls | EthCall[] | Yes | State reads to bundle with events (can be empty []) |
onData | function | Yes | Callback for received notifications |
onError | function | No | Callback for errors |
eventContractSources | Address[] | No | Filter to specific contract addresses |
topicOverrides | Hex[] | No | Filter to specific event signatures |
onlyPushChanges | boolean | No | Only push if state changed from previous |
context | string | No | Event data selectors for ETH call parameters |
subscription.unsubscribe() before disconnecting for clean shutdownOn-chain subscriptions allow smart contracts to react to events from other contracts. Validators execute handler contracts when subscribed events are emitted.
Somnia gas note: Somnia's gas model differs from Ethereum — cold storage reads are ~476x more expensive and LOG opcodes ~13x more expensive. Keep handler logic minimal: cache storage values in memory, minimize event emissions, and avoid deep call chains. See the
somnia-blockchainskill for the full gas model reference.
npm i @somnia-chain/reactivity-contracts
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {
SomniaEventHandler
} from "@somnia-chain/reactivity-contracts/contracts/SomniaEventHandler.sol";
/// @title TransferReactor
/// @notice Reacts to Transfer events from a monitored ERC-20 contract and tracks large transfers.
/// @dev Inherits SomniaEventHandler; only the reactivity precompile (0x0100) can call onEvent.
/// Somnia gas: keep handler logic light — cold SLOAD costs ~1M gas, LOG costs ~13x Ethereum.
contract TransferReactor is SomniaEventHandler {
/// @notice Emitted after the handler processes a Transfer event.
/// @param from The sender of the original transfer.
/// @param to The recipient of the original transfer.
/// @param value The amount transferred.
event TransferReacted(address indexed from, address indexed to, uint256 value);
/// @notice Thrown when the emitter is not the expected monitored contract.
/// @param emitter The address that emitted the event.
error UnexpectedEmitter(address emitter);
/// @notice The ERC-20 contract whose Transfer events this handler monitors.
address public immutable monitoredToken;
/// @notice Running total of transfer volume processed by this handler.
uint256 public totalVolumeProcessed;
/// @param _monitoredToken Address of the ERC-20 token to monitor.
constructor(address _monitoredToken) {
monitoredToken = _monitoredToken;
}
/// @inheritdoc SomniaEventHandler
/// @notice Processes incoming Transfer events from the monitored token.
/// @dev Decodes Transfer(address,address,uint256) event data and updates volume tracking.
/// WARNING: Avoid emitting events that match your own subscription filter — infinite loop risk.
/// @dev Reverts with `UnexpectedEmitter` if the emitting contract is not the monitored token.
function _onEvent(
address emitter,
bytes32[] calldata eventTopics,
bytes calldata data
) internal override {
if (emitter != monitoredToken) revert UnexpectedEmitter(emitter);
address from = address(uint160(uint256(eventTopics[1])));
address to = address(uint160(uint256(eventTopics[2])));
uint256 value = abi.decode(data, (uint256));
totalVolumeProcessed += value;
emit TransferReacted(from, to, value);
}
}
Deploy using Hardhat or Foundry, note the deployed address.
Two ways: TypeScript SDK or Solidity precompile.
import { SDK } from '@somnia-chain/reactivity'
import { parseGwei, keccak256, toBytes } from 'viem'
const sdk = new SDK({ public: publicClient, wallet: walletClient })
await sdk.createSoliditySubscription({
handlerContractAddress: '0xYourHandlerAddress',
emitter: '0xContractEmittingEvents',
eventTopics: [keccak256(toBytes('Transfer(address,address,uint256)'))],
priorityFeePerGas: parseGwei('2'),
maxFeePerGas: parseGwei('10'),
gasLimit: 500_000n,
isGuaranteed: true,
isCoalesced: false
})
Contracts can self-subscribe by calling the reactivity precompile at 0x0100.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {
SomniaEventHandler
} from "@somnia-chain/reactivity-contracts/contracts/SomniaEventHandler.sol";
import {
ISomniaReactivityPrecompile,
SomniaExtensions
} from "@somnia-chain/reactivity-contracts/contracts/interfaces/ISomniaReactivityPrecompile.sol";
/// @title SelfSubscribingHandler
/// @notice A handler that creates and manages its own reactivity subscription on-chain.
/// @dev The deployer (owner) must hold >= 32 STT for the subscription to remain active.
/// Calls the Somnia Reactivity Precompile at 0x0100 to subscribe/unsubscribe.
contract SelfSubscribingHandler is SomniaEventHandler {
/// @notice Emitted when a reactivity callback is processed.
/// @param emitter The contract that emitted the original event.
/// @param topic0 The first topic (event signature hash).
event Reacted(address indexed emitter, bytes32 indexed topic0);
/// @notice Thrown when a non-owner address attempts a restricted action.
error OnlyOwner();
/// @notice Thrown when attempting to subscribe while a subscription is already active.
error AlreadySubscribed();
/// @notice Thrown when attempting to unsubscribe with no active subscription.
error NotSubscribed();
/// @dev Reference to the Somnia Reactivity Precompile.
ISomniaReactivityPrecompile private constant PRECOMPILE =
ISomniaReactivityPrecompile(SomniaExtensions.SOMNIA_REACTIVITY_PRECOMPILE_ADDRESS);
/// @notice The address that deployed this contract and controls subscription management.
address public immutable owner;
/// @notice The ID of the active subscription, or 0 if none.
uint256 public subscriptionId;
modifier onlyOwner() {
if (msg.sender != owner) revert OnlyOwner();
_;
}
constructor() {
owner = msg.sender;
}
/// @notice Creates a wildcard subscription that reacts to all events.
/// @dev The owner must hold >= 32 STT. Uses 2 gwei priority / 10 gwei max — the recommended minimums.
/// @dev Reverts with `AlreadySubscribed` if a subscription is already active.
/// @dev Reverts with `OnlyOwner` if caller is not the deployer.
function createSubscription() external onlyOwner {
if (subscriptionId != 0) revert AlreadySubscribed();
ISomniaReactivityPrecompile.SubscriptionData memory subData =
ISomniaReactivityPrecompile.SubscriptionData({
eventTopics: [bytes32(0), bytes32(0), bytes32(0), bytes32(0)],
origin: address(0),
caller: address(0),
emitter: address(0),
handlerContractAddress: address(this),
handlerFunctionSelector: this.onEvent.selector,
priorityFeePerGas: 2_000_000_000, // 2 gwei
maxFeePerGas: 10_000_000_000, // 10 gwei
gasLimit: 500_000,
isGuaranteed: true,
isCoalesced: false
});
subscriptionId = PRECOMPILE.subscribe(subData);
}
/// @notice Creates a filtered subscription for a specific event from a specific contract.
/// @param emitterAddr The contract address to monitor for events.
/// @param eventSigHash The keccak256 hash of the event signature.
/// @param handlerGasLimit Max gas per handler invocation. 500000 for simple, up to 3000000 for complex.
/// @dev Reverts with `AlreadySubscribed` if a subscription is already active.
/// @dev Reverts with `OnlyOwner` if caller is not the deployer.
function createFilteredSubscription(
address emitterAddr,
bytes32 eventSigHash,
uint64 handlerGasLimit
) external onlyOwner {
if (subscriptionId != 0) revert AlreadySubscribed();
ISomniaReactivityPrecompile.SubscriptionData memory subData =
ISomniaReactivityPrecompile.SubscriptionData({
eventTopics: [eventSigHash, bytes32(0), bytes32(0), bytes32(0)],
origin: address(0),
caller: address(0),
emitter: emitterAddr,
handlerContractAddress: address(this),
handlerFunctionSelector: this.onEvent.selector,
priorityFeePerGas: 2_000_000_000,
maxFeePerGas: 10_000_000_000,
gasLimit: handlerGasLimit,
isGuaranteed: true,
isCoalesced: false
});
subscriptionId = PRECOMPILE.subscribe(subData);
}
/// @notice Cancels the active subscription.
/// @dev Reverts with `NotSubscribed` if no subscription is active.
/// @dev Reverts with `OnlyOwner` if caller is not the deployer.
function cancelSubscription() external onlyOwner {
if (subscriptionId == 0) revert NotSubscribed();
PRECOMPILE.unsubscribe(subscriptionId);
subscriptionId = 0;
}
/// @inheritdoc SomniaEventHandler
function _onEvent(
address emitter,
bytes32[] calldata eventTopics,
bytes calldata data
) internal override {
emit Reacted(emitter, eventTopics[0]);
}
}
The Somnia Reactivity Precompile lives at 0x0000000000000000000000000000000000000100 (short: 0x0100).
| Method | Description |
|---|---|
subscribe(SubscriptionData) | Creates subscription, returns subscriptionId |
unsubscribe(uint256 subscriptionId) | Cancels subscription (owner only) |
getSubscriptionInfo(uint256 subscriptionId) | Returns SubscriptionData and owner |
struct SubscriptionData {
bytes32[4] eventTopics; // Event topic filters (bytes32(0) = wildcard)
address origin; // tx.origin filter (address(0) = wildcard)
address caller; // msg.sender filter (address(0) = wildcard)
address emitter; // Event emitter filter (address(0) = wildcard)
address handlerContractAddress; // Contract with _onEvent to invoke
bytes4 handlerFunctionSelector; // Usually onEvent.selector
uint64 priorityFeePerGas; // Validator tip in wei (use 2 gwei = 2_000_000_000)
uint64 maxFeePerGas; // Fee ceiling in wei (use 10 gwei = 10_000_000_000)
uint64 gasLimit; // Max gas per invocation
bool isGuaranteed; // Retry delivery if block is full
bool isCoalesced; // Batch multiple events per block
}
SomniaEventHandler and override _onEventGas misconfiguration is the #1 cause of "reactivity not working". Low gas values cause validators to silently skip your subscription — no error, no warning.
| Parameter | What It Does | Recommended Minimum |
|---|---|---|
priorityFeePerGas | Validator tip | parseGwei('2') = 2_000_000_000 |
maxFeePerGas | Fee ceiling (base + priority) | parseGwei('10') = 10_000_000_000 |
gasLimit | Max gas per handler call | 500_000 (simple) to 3_000_000 (complex) |
| Handler Type | priorityFeePerGas | maxFeePerGas | gasLimit |
|---|---|---|---|
| Simple (state update, emit) | 2 gwei | 10 gwei | 500_000 |
| Medium (cross-contract calls) | 2 gwei | 10 gwei | 1_000_000 |
| Complex (loops, multiple calls) | 3 gwei | 15 gwei | 3_000_000 |
// WRONG — 10n = 10 wei = essentially zero. Validators will ignore this.
priorityFeePerGas: 10n,
maxFeePerGas: 20n,
// CORRECT — 2 gwei = 2,000,000,000 wei. Validators will process this.
priorityFeePerGas: parseGwei('2'),
maxFeePerGas: parseGwei('10'),
Always use parseGwei() from viem in TypeScript. In Solidity, use the literal value 2_000_000_000 (2 gwei).
Requires @somnia-chain/reactivity@0.1.9 or later.
await sdk.createOnchainBlockTickSubscription({
handlerContractAddress: '0xYourHandler',
priorityFeePerGas: BigInt(2_000_000_000),
maxFeePerGas: BigInt(10_000_000_000),
gasLimit: BigInt(500_000),
isGuaranteed: true,
isCoalesced: false
// blockNumber: BigInt(123456789) // omit for every block
})
await sdk.scheduleOnchainCronJob({
timestampMs: Date.now() + 60_000, // 1 minute from now (milliseconds)
handlerContractAddress: '0xYourHandler',
priorityFeePerGas: BigInt(2_000_000_000),
maxFeePerGas: BigInt(10_000_000_000),
gasLimit: BigInt(500_000),
isGuaranteed: true,
isCoalesced: false
})
Check in this order:
priorityFeePerGas >= 2_000_000_000 (2 gwei). Use sdk.getSubscriptionInfo(id) to check._onEvent.const info = await sdk.getSubscriptionInfo(subscriptionId)
// Verify priorityFeePerGas >= 2000000000
Look for validator transactions from 0x0000000000000000000000000000000000000100 on the block explorer targeting your handler contract.
Stale WebSocket connections accumulated on the server:
subscription.unsubscribe() on shutdownuseEffect cleanup to unsubscribeSomnia RPC limits eth_getLogs to 1000 blocks per query:
queryFilter block ranges to 500Reactivity is the trigger layer; zerion-cli is the action layer. The pattern is always:
Somnia event → handler decides →
zerioncommand executes
For off-chain handlers, this is a Node process subscribing via WSS and shelling out to zerion. For on-chain handlers, it's a Solidity contract emitting an event that an off-chain runner picks up and translates into a CLI call.
import { SDK } from '@somnia-chain/reactivity'
import { execSync } from 'node:child_process'
await sdk.subscribe({
event: { address: WHALE_WALLET, topics: [TRANSFER_TOPIC] },
ethCalls: [],
onData: ({ event }) => {
// mirror the whale's swap on the same chain
execSync(`zerion swap usdc somi 100 --chain somnia`)
},
})
zerion watch <whale> --name whale-1 first, so the agent operator can re-discover the wallet by name later without leaking the address.
import { SDK } from '@somnia-chain/reactivity'
// fires once at the given timestamp, then auto-deletes
await sdk.scheduleOnchainCronJob({
timestampMs: nextMondayMs(),
handlerContractAddress: DCA_HANDLER,
priorityFeePerGas: parseGwei('2'),
maxFeePerGas: parseGwei('10'),
gasLimit: 500_000n,
})
The handler emits a DcaTick event; an off-chain runner listens for it and runs zerion swap usdc somi 100 --chain somnia. Re-schedule from inside the handler for a recurring cadence.
// Somnia: push, no polling
await sdk.subscribe({
ethCalls: [{ to: USER, data: '0x' /* balance */ }],
onData: ({ state }) => updateSomniaPanel(state),
})
// Other chains: pull via Zerion CLI
const portfolio = JSON.parse(
execSync('zerion analyze 0xUser --json').toString()
)
updateOtherChains(portfolio)
Result: instant Somnia updates via WSS, cross-chain coverage via Zerion CLI's 14 EVM chains + Solana — one unified view.
// On-chain handler triggered when user balance drops below threshold
function _onEvent(address emitter, bytes32[] calldata topics, bytes calldata data) internal override {
uint256 balance = abi.decode(data, (uint256));
if (balance < SAFETY_THRESHOLD) {
emit ProtectiveSwapRequested(user, balance);
}
}
Off-chain runner sees ProtectiveSwapRequested → fires zerion swap somi usdc <amount> --chain somnia to convert to stables. The Zerion-side --allowlist policy on the agent token prevents the runner from doing anything beyond the protective swap.
| Task | Method |
|---|---|
| Install SDK | npm i @somnia-chain/reactivity viem |
| Install Solidity contracts | npm i @somnia-chain/reactivity-contracts |
| Off-chain subscription | sdk.subscribe({ ethCalls, onData }) |
| On-chain sub (TypeScript) | sdk.createSoliditySubscription({ ... }) |
| On-chain sub (Solidity) | PRECOMPILE.subscribe(subData) at 0x0100 |
| Block tick subscription | sdk.createOnchainBlockTickSubscription({ ... }) |
| Scheduled one-off | sdk.scheduleOnchainCronJob({ ... }) |
| Check subscription | sdk.getSubscriptionInfo(subscriptionId) |
| Cancel subscription | sdk.cancelSoliditySubscription(subscriptionId) |
| Handler base contract | SomniaEventHandler from @somnia-chain/reactivity-contracts |
| Precompile address | 0x0000000000000000000000000000000000000100 |
| Min owner balance | 32 STT |
| Min priority fee | 2 gwei = 2_000_000_000 |
| Chain ID | 50312 |
| RPC | https://api.infra.testnet.somnia.network |
| WSS | wss://api.infra.testnet.somnia.network |
| Explorer | https://shannon-explorer.somnia.network |
When helping developers with Reactivity:
parseGwei() for gas values in TypeScript, or 2_000_000_000 literals in Solidity — never suggest raw small numbers@title, @notice, @dev, @param, @return) and custom errors with @dev Reverts with documenting every revert condition