On-chain Reactivity
On-chain reactivity lets a smart contract instantly react to events in the same block, without anyone sending an event handling transaction. A user creates a subscription through a call to a precompile, and the subscription persists in chain state. When an event log or system event matches the subscription's filter, validators include a synthetic transaction in the block that calls the subscription's handler contract. The creator of the subscription pays the gas.
This is a powerful feature. As far as we know, no other EVM can do this, and when combined with Somnia's 100 ms blocks, on-chain reactivity enables a class of applications that other chains can only approximate with off-chain infrastructure.
For client-side reactions to events, see off-chain reactivity.
Example use cases
Trigger a callback every time a specific ERC-20 Transfer event is emitted, regardless of who sent the transaction.
Run scheduled upkeep at every epoch boundary.
Move funds out of escrow at a predetermined time.
Build an automated liquidation bot as a contract rather than an off-chain service.
Forward DEX events to a settlement contract without the trader paying the forwarding cost.
Quick start
Install the Solidity reactivity package (see Solidity package below).
npm install @somnia-chain/reactivity-contractsWrite a handler contract. Inherit from
SomniaEventHandlerand override the protected_onEventhook:import { SomniaEventHandler } from "@somnia-chain/reactivity-contracts/contracts/SomniaEventHandler.sol"; contract MyHandler is SomniaEventHandler { function _onEvent( address emitter, bytes32[] calldata eventTopics, bytes calldata data ) internal override { // react to the event here } }Fill in a
SomniaExtensions.SubscriptionFilterand aSomniaExtensions.SubscriptionOptions. See Solidity package for the struct shapes, and Filter semantics for what the filter fields mean.Deploy the contract, and ensure the contract account holds at least 32 SOMI (see Minimum balance).
Create the subscription by calling
SomniaExtensions.subscribe(address(this), filter, options). The call returns the subscription ID, and the calling contract becomes the subscription owner. For one-shot subscriptions at a specific block, epoch, or timestamp, usescheduleSubscriptionAtBlock/scheduleSubscriptionAtEpoch/scheduleSubscriptionAtTimestampinstead.The subscription runs until you remove it or the owner account runs out of SOMI (see Automatic removal).
Query subscriptions off-chain via the
somnia_reactivityGetSubscriptionInfoorsomnia_reactivityGetSubscriptionsJSON-RPC methods, or on-chain viaSomniaExtensions.getSubscriptionInfo(id).Remove the subscription by calling
SomniaExtensions.unsubscribe(id)from the owning contract.
These steps assume the contract itself is the subscription owner, which is what the SomniaExtensions library is designed for. To own the subscription directly from an EOA, see the TypeScript package, or for the raw ABI-level surface, see Precompile and SubscriptionData in the Reference section.
How it works
Subscriptions are created by calling the Somnia reactivity precompile at address 0x0100.
When a transaction is executed, every event log it emits is checked against all active subscriptions. Matching subscriptions are placed on a per-block priority queue, ordered by each subscription's priorityFeePerGas.
After the block's user transactions have executed, the node drains the queue and executes each pending handler as a synthetic transaction.
These reactive transactions appear in the block's reactivity output alongside the ordinary transactions. They are visible on the block explorer, have normal receipts, emit normal logs, and pay normal gas.
Each reactive transaction has:
fromequal to the subscription ownertoequal to the subscription's handler contractmsg.senderinside the call equal to0x0100Calldata equal to
handlerFunctionSelectorfollowed by the ABI-encoded tuple(address emitter, bytes32[] eventTopics, bytes data)from the matching logA block-unique nonce derived from the block number and position in the reactivity queue (so that reactive transactions don't conflict with the owner's regular nonces)
If the handler reverts, runs out of gas, or the owner can't pay, the reactive transaction fails in the ordinary way. A failure doesn't itself remove the subscription, but see Automatic removal below.
Logs emitted by reactive transactions are checked against subscriptions the same way as user transactions, and any matches are immediately added to the block's reactivity queue. Note that this means that a subscription can provoke a recursive explosion, unstoppably draining the owner's balance.
Reference
Precompile
Address:
0x0100Solidity constant:
SomniaExtensions.SOMNIA_REACTIVITY_PRECOMPILE_ADDRESS
The precompile exposes three functions. The function selectors are keccak256 of the signatures below.
subscribe
subscribe((bytes32[4],address,address,address,address,bytes4,uint64,uint64,uint64,bool,bool))
unsubscribe
unsubscribe(uint256)
getSubscriptionInfo
getSubscriptionInfo(uint256)
Note that subscribe takes a single tuple parameter (the SubscriptionData struct), not eleven positional parameters — the outer parentheses are significant.
SubscriptionData
SubscriptionDataThe tuple passed to subscribe, in order:
origin
address
Matches logs from transactions sent by this address. Zero means "match any".
caller
address
Reserved. Pass 0x0.
emitter
address
Matches logs emitted from this contract address. Zero means "match any". Set to 0x0100 to subscribe to system events (see System events).
handlerContractAddress
address
The contract called when a match occurs. Must be non-zero.
handlerFunctionSelector
bytes4
The 4-byte function selector invoked on the handler contract.
priorityFeePerGas
uint64
Tip to validators, in wei. Determines order in the reactivity queue (see Limits).
maxFeePerGas
uint64
Maximum total fee per gas, in wei. If set to zero, the protocol chooses a maximum.
gasLimit
uint64
Maximum gas the handler may consume per invocation. Must be non-zero, maximum of 200,000,000.
isGuaranteed
bool
Reserved. Pass false.
isCoalesced
bool
Reserved. Pass false.
Validation at subscription creation
subscribe reverts if:
handlerContractAddressis zero.None of
eventTopics[0..3],origin, oremitterare set. At least one filter is required — wildcard subscriptions that match every log on the chain are not allowed.gasLimitis zero or abovemax_reactivity_handler_gas_limit(200 million gas).priorityFeePerGasis above the gas price cap.priorityFeePerGas + baseFeeexceedsmaxFeePerGas.The owner does not hold at least 32 SOMI.
If maxFeePerGas is passed as zero, the protocol replaces it with the maximum gas price before storing the subscription. Use this default with caution: if the gas price spikes, your subscription may become unprofitable to execute and fail to fire.
Filter semantics
A subscription matches an event log when every non-zero filter field equals the corresponding field on the log. Zero acts as a wildcard. The filter fields are: origin, emitter and eventTopics. Topic filters are positional: eventTopics[0] matches against the log's first topic (the event signature), eventTopics[1] against the second, and so on.
At least one field must be a non-wildcard. An all-zero subscription is rejected at creation.
There is no "OR" support on a single field — a filter value either matches a specific hash or matches everything. If you need disjunctive matching, create multiple subscriptions.
System events
Instead of an event log, you can subscribe to one of three system events, by setting the following fields in the subscription:
Event
emitter
eventTopics[0]
eventTopics[1]
When it fires
Block tick
0x100
keccak256("BlockTick(uint64)")
block number
Every block (i.e. ten times a second) if eventTopics[1] is zero, or otherwise that specific block
Epoch tick
0x100
keccak256("EpochTick(uint64,uint64)")
epoch number
At the end of every epoch (i.e. every five minutes) if eventTopics[1] is zero, or otherwise at the end of that specific epoch
Schedule
0x100
keccak256("Schedule(uint256)")
timestamp in milliseconds
Once, in the first block whose timestamp is ≥ eventTopics[1]
Schedule, block-specific BlockTick, and epoch-specific EpochTick are one-shot: the subscription is automatically removed after it fires. Every-block and every-epoch subscriptions are recurring.
Scheduled timestamps must be strictly greater than the current block timestamp at the moment the subscription is created.
Handler execution context
Inside a handler invocation:
msg.sender == 0x0100tx.origin == subscription owner addressmsg.value == 0— reactive calls never carry valueCalldata is
handlerFunctionSelector ++ abi.encode(address emitter, bytes32[] eventTopics, bytes data)where the arguments are from the log that matched
The handler runs with the subscription's gasLimit. If it exceeds that limit the transaction reverts and the owner pays for the attempt.
Minimum balance
The subscription owner must hold at least 32 SOMI at the moment subscribe is called. This is a sybil-resistance barrier against spammy subscription creation.
The 32 SOMI is not an escrow and not consumed. It sits in the owner's regular balance. Gas for each handler invocation is paid from the owner's balance at the normal per-tx rate.
Note that the 32 SOMI threshold is only enforced at creation. A subscription can continue to fire after its owner's balance has dropped below 32 SOMI, as long as the owner can still pay the gas for each individual handler invocation. See Automatic removal for what happens when the owner can't pay.
Gas costs
Creating a subscription (by calling subscribe on the reactivity precompile) costs 210,000 gas, charged to the transaction sender as usual.
Handler invocations are charged to the subscription owner. The price per gas is the block's current gas price plus the subscription's priorityFeePerGas. The handler can consume at most the subscription's gasLimit.
Limits
There are per-block limits on total execution gas, reactivity-specific execution gas and the number of reactivity transactions. When any of these is hit, the remaining matches stay in the reactivity queue and are attempted again in the next block.
Matches are sorted by priorityFeePerGas, so those that pay lower fees may be indefinitely deferred.
There are also limits on the size of the reactivity queue itself. When these are hit, the lowest-priority matches are evicted.
Automatic removal
A subscription is removed automatically when:
It's a one-shot scheduled subscription and it has just fired.
It was evicted from a full reactivity queue and was also one-shot.
The owner's balance doesn't cover the subscription's
gasLimitwhen it fires. i.e. It's less than(execution price per gas + priorityFeePerGas) * gasLimit.
A subscription is not removed when:
The handler simply reverts.
The handler exceeds
gasLimitduring execution.The owner's balance drops below 32 SOMI but is still enough to pay for individual handler invocations.
The handler execution is deferred due to queue limits or low priority fee.
Emitted events
subscribe and unsubscribe emit these logs from 0x0100:
SubscriptionCreated(uint256 id, address owner, SubscriptionData data)— data is the full tuple, ABI-encoded inline.SubscriptionRemoved(uint256 id, address owner)
These are the logs you should filter against off-chain if you want to track subscription lifecycle.
RPC methods
The node exposes two reactivity-specific RPC methods:
somnia_reactivityGetSubscriptionInfo— Takes a subscription ID (as a quantity string, e.g."23"or"0x15f019") and returns the details of the subscription. Can also take an array of such IDs. Response fields mirrorSubscriptionDataplusidandowner, with names insnake_case(handler_contract_address,handler_function_selector,priority_fee_per_gas,max_fee_per_gas,gas_limit).somnia_reactivityGetSubscriptions— Takes an owner address, returns an array of subscription objects currently owned by that address. Each entry has the same shape as thesomnia_reactivityGetSubscriptionInforesponse.
Both are eth_call-style JSON-RPC methods available on any Somnia node.
Solidity package
The @somnia-chain/reactivity-contracts npm package is the idiomatic Solidity wrapper around the precompile. Install it with npm install @somnia-chain/reactivity-contracts (or your toolchain's equivalent). It exposes four pieces:
ISomniaReactivityPrecompile— the typed interface for the precompile at0x0100— theSubscriptionDatastruct and theBlockTick,EpochTick,Schedulesystem-event signatures (so topic hashes are<Event>.selector), plus the rawsubscribe/unsubscribe/getSubscriptionInfofunctions.ISomniaEventHandler— the callback interface thatSomniaEventHandlerimplements. Used as the canonical value forhandlerFunctionSelector(ISomniaEventHandler.onEvent.selector).SomniaEventHandler— an abstract base contract that implements the "only the precompile may call me" check and the ERC-165 plumbing, leaving you to override a protected_onEvent.SomniaExtensions— the ergonomic helper library most user code will call into. Constants:ConstantValueSOMNIA_REACTIVITY_PRECOMPILE_ADDRESS0x0100SUBSCRIPTION_OWNER_MINIMUM_BALANCE32 etherMINIMUM_BASE_FEE_PER_GAS6 gweiMAXIMUM_HANDLER_GAS_LIMIT200_000_000Helper structs:
Functions (all
internal, i.e. called from a Solidity contract, not an EOA):FunctionPurposesubscribe(handler, filter, options)Create a subscription with the given filter. Checks
handler != 0, at least one filter is set,gasLimitis within range,maxFeePerGasis consistent withpriorityFeePerGas + MINIMUM_BASE_FEE_PER_GAS, and the caller's balance ≥SUBSCRIPTION_OWNER_MINIMUM_BALANCE— reverts with a typed error if any fails. HardcodeshandlerFunctionSelector = ISomniaEventHandler.onEvent.selector.scheduleSubscriptionAtTimestamp(handler, timestampMillis, options)One-shot Schedule subscription firing when the block timestamp (ms) first reaches
timestampMillis. Reverts if the timestamp is in the past.scheduleSubscriptionAtBlock(handler, blockNumber, options)One-shot BlockTick subscription firing on the specific block. Reverts if the block number is in the past.
scheduleSubscriptionAtEpoch(handler, epochNumber, options)One-shot EpochTick subscription firing at the end of the specific epoch.
unsubscribe(subscriptionId)Cancel a subscription owned by the caller. (Uses a low-level
.callbecause a typed call would revert before reaching the precompile.)getSubscriptionInfo(subscriptionId)Read a subscription's stored parameters and owner.
The
schedule*helpers only cover one-shot subscriptions at a specific block/epoch/timestamp. For recurring every-block or every-epoch subscriptions, build aSubscriptionFilterwitheventTopics[1] = 0and callsubscribedirectly — see the Example.
TypeScript package
The @somnia-chain/reactivity package wraps the precompile with Viem for use from TypeScript scripts and Node.js services. It's intended for off-chain orchestration: creating a subscription owned by an externally-owned account, inspecting subscription state, or cancelling a subscription from a script.
createSoliditySubscription(data)
Broadcast a subscribe transaction with the given SoliditySubscriptionData.
cancelSoliditySubscription(id)
Broadcast an unsubscribe transaction.
getSubscriptionInfo(id)
Read a subscription via eth_call.
createOnchainBlockTickSubscription({ blockNumber?, ... })
Convenience wrapper for BlockTick subscriptions. Omit blockNumber for a recurring every-block subscription; pass it for a one-shot subscription on a specific block.
scheduleOnchainCronJob({ timestampMs, ... })
Convenience wrapper for one-shot Schedule subscriptions.
If the subscription is owned by a Solidity contract — the pattern shown in Quick start and Minimal example — use SomniaExtensions from the contract directly. The TypeScript SDK is only useful when the subscription owner is an externally-owned account, or when you want to inspect or cancel a subscription from off-chain code. (The same package also drives off-chain reactivity over WebSocket.)
Minimal example
This contract subscribes itself to every block and counts them. It's built on the @somnia-chain/reactivity-contracts package (see Solidity package above).
Note that the contract is the subscription owner, not the deployer — the constructor calls subscribe from the contract's own address. If you don't call stop(), the subscription will keep running until the contract's balance can't cover gasLimit.
Deploying and driving the example with Foundry
Deploy the handler with Foundry, funding the contract with 33 SOMI (just above the 32 SOMI minimum balance) and passing a 2,000,000 gas limit to the constructor:
forge create prints the deployed address. Read the counter — it should be advancing once per block:
Inspect the subscription itself through the precompile's RPC:
Stop the handler by calling stop(), which unsubscribes via the library:
After that, count() stops advancing and somnia_reactivityGetSubscriptionInfo on the old ID returns an empty result.
Development advice
Designing
Handlers are separate transactions, not callbacks. A triggering transaction commits first; matching handlers are later executed as synthetic transactions, usually in the same block.
System events are just synthetic logs.
BlockTick,EpochTickandSchedulecome from fabricated0x100logs at block end.Avoid recursive explosions. Make sure that your subscription's handler can't emit an event which re-triggers your subscription.
Implementing
Use a dedicated account as the subscription owner. An ill-judged subscription can quietly drain an account. A recursive subscription, that reacts to its own handler, can do the same in moments. Consider limiting the blast radius by funding a dedicated account.
Budget for gas reserves in subscription gas limits. Somnia's storage operations require a
1,000,000gas reserve in case execution requires a disk read or a new storage allocation. Neglecting this reserve is a common cause of out-of-gas errors. See Somnia gas differences to Ethereum for the table of costs.Recreate subscriptions after redeploying. Subscriptions point at concrete contract addresses, so redeploying a handler requires creating a new subscription.
Why isn't your handler getting invoked?
Common problems:
Subscription isn't active. A subscription is automatically removed if the owner's balance can't cover
gasLimitwhen it fires. Checksomnia_reactivityGetSubscriptionsorsomnia_reactivityGetSubscriptionInfo.Implementation of
SomniaEventHandlerinterface is invalid. See Solidity package for the expected shape.Logs aren't matching. One gotcha: only successful transaction logs trigger reactivity. If a transaction reverts, its logs won't be matched against subscriptions.
Subscription is pointing at wrong handler. Subscriptions point at concrete contract addresses, so redeploying a handler requires creating a new subscription.
maxFeePerGasis too low. Note thatmaxFeePerGasandpriorityFeePerGasare denominated in wei, but Somnia's minimum base fee is 6 nanoSomi, i.e. 6 gWei, i.e. 6,000,000,000 wei. A common mistake is to specify a fee in wei thinking that the unit is gWei.priorityFeePerGasis too low for the current queue pressure. This is rare, but when reactivity execution hits its limits, low-priority-fee matches are deferred or even evicted. A schedule that fires late or not at all can mean queue pressure rather than a bug.gasLimitis too low. This is very common: Somnia operates on a different gas model to Ethereum, and some Somnia gas costs, measured in units of gas, are much higher than Ethereum's. Unlike the previous problems, this can be identified in the chain's history — see below.
Debugging reactive transactions
Reactive transactions can be recognised by their nonces. They're legacy-typed (
type == 0x0) and their nonces pack the block number in the high bytes and the queue position (0-indexed) in the low three bytes. E.g. the first reactive in block0xabcdefhas nonce0xabcdef000000, the second0xabcdef000001, and so on. Wallets and explorers don't yet render this specially; the nonce pattern is the quickest way to spot reactive txs ineth_getBlockByNumber(..., true)output.Reactive transactions have hashes and receipts, but fake signatures. They're encoded into history as legacy-style transactions with placeholder signature fields. They aren't actually signed by their owner.
Last updated