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

  1. Install the Solidity reactivity package (see Solidity package below).

    npm install @somnia-chain/reactivity-contracts
  2. Write a handler contract. Inherit from SomniaEventHandler and override the protected _onEvent hook:

    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
        }
    }
  3. Fill in a SomniaExtensions.SubscriptionFilter and a SomniaExtensions.SubscriptionOptions. See Solidity package for the struct shapes, and Filter semantics for what the filter fields mean.

  4. Deploy the contract, and ensure the contract account holds at least 32 SOMI (see Minimum balance).

  5. 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, use scheduleSubscriptionAtBlock / scheduleSubscriptionAtEpoch / scheduleSubscriptionAtTimestamp instead.

  6. The subscription runs until you remove it or the owner account runs out of SOMI (see Automatic removal).

  7. Query subscriptions off-chain via the somnia_reactivityGetSubscriptionInfo or somnia_reactivityGetSubscriptions JSON-RPC methods, or on-chain via SomniaExtensions.getSubscriptionInfo(id).

  8. 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:

  • from equal to the subscription owner

  • to equal to the subscription's handler contract

  • msg.sender inside the call equal to 0x0100

  • Calldata equal to handlerFunctionSelector followed by the ABI-encoded tuple (address emitter, bytes32[] eventTopics, bytes data) from the matching log

  • A 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: 0x0100

  • Solidity constant: SomniaExtensions.SOMNIA_REACTIVITY_PRECOMPILE_ADDRESS

The precompile exposes three functions. The function selectors are keccak256 of the signatures below.

Function
Signature

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

The tuple passed to subscribe, in order:

Field
Type
Purpose

eventTopics

bytes32[4]

Topic filters. A zero value means "match any". (See Filter semantics.)

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:

  • handlerContractAddress is zero.

  • None of eventTopics[0..3], origin, or emitter are set. At least one filter is required — wildcard subscriptions that match every log on the chain are not allowed.

  • gasLimit is zero or above max_reactivity_handler_gas_limit (200 million gas).

  • priorityFeePerGas is above the gas price cap.

  • priorityFeePerGas + baseFee exceeds maxFeePerGas.

  • 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 == 0x0100

  • tx.origin == subscription owner address

  • msg.value == 0 — reactive calls never carry value

  • Calldata 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 gasLimit when 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 gasLimit during 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 mirror SubscriptionData plus id and owner, with names in snake_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 the somnia_reactivityGetSubscriptionInfo response.

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 at 0x0100 — the SubscriptionData struct and the BlockTick, EpochTick, Schedule system-event signatures (so topic hashes are <Event>.selector), plus the raw subscribe/unsubscribe/ getSubscriptionInfo functions.

  • ISomniaEventHandler — the callback interface that SomniaEventHandler implements. Used as the canonical value for handlerFunctionSelector (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:

    Constant
    Value

    SOMNIA_REACTIVITY_PRECOMPILE_ADDRESS

    0x0100

    SUBSCRIPTION_OWNER_MINIMUM_BALANCE

    32 ether

    MINIMUM_BASE_FEE_PER_GAS

    6 gwei

    MAXIMUM_HANDLER_GAS_LIMIT

    200_000_000

    Helper structs:

    Functions (all internal, i.e. called from a Solidity contract, not an EOA):

    Function
    Purpose

    subscribe(handler, filter, options)

    Create a subscription with the given filter. Checks handler != 0, at least one filter is set, gasLimit is within range, maxFeePerGas is consistent with priorityFeePerGas + MINIMUM_BASE_FEE_PER_GAS, and the caller's balance ≥ SUBSCRIPTION_OWNER_MINIMUM_BALANCE — reverts with a typed error if any fails. Hardcodes handlerFunctionSelector = 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 .call because 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 a SubscriptionFilter with eventTopics[1] = 0 and call subscribe directly — 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.

Method
Purpose

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, EpochTick and Schedule come from fabricated 0x100 logs 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,000 gas 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:

  1. Subscription isn't active. A subscription is automatically removed if the owner's balance can't cover gasLimit when it fires. Check somnia_reactivityGetSubscriptions or somnia_reactivityGetSubscriptionInfo.

  2. Implementation of SomniaEventHandler interface is invalid. See Solidity package for the expected shape.

  3. Logs aren't matching. One gotcha: only successful transaction logs trigger reactivity. If a transaction reverts, its logs won't be matched against subscriptions.

  4. Subscription is pointing at wrong handler. Subscriptions point at concrete contract addresses, so redeploying a handler requires creating a new subscription.

  5. maxFeePerGas is too low. Note that maxFeePerGas and priorityFeePerGas are 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.

  6. priorityFeePerGas is 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.

  7. gasLimit is 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 block 0xabcdef has nonce 0xabcdef000000, the second 0xabcdef000001, and so on. Wallets and explorers don't yet render this specially; the nonce pattern is the quickest way to spot reactive txs in eth_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