Somnia Reactivity / Tutorials
Solidity On-Chain Reactivity Tutorial
Create a contract-owned on-chain subscription that reacts to ERC-20 Transfer events
This tutorial builds a Solidity handler contract that subscribes to ERC-20
Transfer events and counts them on-chain. The subscription is owned by the
handler contract itself, so the contract pays gas for each reactive
transaction.
Use on-chain reactivity when the reaction should be part of chain execution. For client-side subscriptions over WebSocket, use off-chain reactivity.
How This Example Works
The contract:
- Inherits from
SomniaEventHandler. - Builds a non-wildcard
SubscriptionFilterfor one token'sTransferlogs. - Calls
SomniaExtensions.subscribe(address(this), filter, options)from the constructor. - Receives matching callbacks through
_onEvent. - Can unsubscribe later through
stop().
On-chain wildcard subscriptions are not allowed. At least one of eventTopics,
origin, or emitter must be set.
Prerequisites
You'll need:
- Node.js 20+
- A Foundry project
- A Somnia RPC endpoint (see Network info)
- A deployer account with enough SOMI or STT to fund the handler contract with at least 32 native tokens, plus gas for deployment and callback execution
- An ERC-20 token address to watch (see Smart contracts)
Install the Solidity package:
npm install @somnia-chain/reactivity-contracts
Step 1: Write the Handler
Create src/TransferCounter.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {
SomniaEventHandler
} from "@somnia-chain/reactivity-contracts/contracts/SomniaEventHandler.sol";
import {
SomniaExtensions
} from "@somnia-chain/reactivity-contracts/contracts/interfaces/SomniaExtensions.sol";
contract TransferCounter is SomniaEventHandler {
bytes32 private constant TRANSFER_TOPIC =
keccak256("Transfer(address,address,uint256)");
event TransferObserved(address indexed from, address indexed to, uint256 value);
address public immutable owner;
address public immutable token;
uint256 public subscriptionId;
uint256 public transferCount;
mapping(address recipient => uint256 totalReceived) public receivedBy;
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
constructor(address token_, uint64 gasLimit) payable {
require(token_ != address(0), "Token is zero address");
owner = msg.sender;
token = token_;
SomniaExtensions.SubscriptionFilter memory filter =
SomniaExtensions.SubscriptionFilter({
eventTopics: [
TRANSFER_TOPIC,
bytes32(0),
bytes32(0),
bytes32(0)
],
origin: address(0),
emitter: token_
});
SomniaExtensions.SubscriptionOptions memory options =
SomniaExtensions.SubscriptionOptions({
priorityFeePerGas: 1,
maxFeePerGas: 0,
gasLimit: gasLimit
});
subscriptionId = SomniaExtensions.subscribe(
address(this),
filter,
options
);
}
function _onEvent(
address emitter,
bytes32[] calldata eventTopics,
bytes calldata data
) internal override {
require(emitter == token, "Unexpected emitter");
require(eventTopics.length >= 3, "Missing Transfer topics");
address from = address(uint160(uint256(eventTopics[1])));
address to = address(uint160(uint256(eventTopics[2])));
uint256 value = abi.decode(data, (uint256));
transferCount += 1;
receivedBy[to] += value;
emit TransferObserved(from, to, value);
}
function stop() external onlyOwner {
require(subscriptionId != 0, "No active subscription");
SomniaExtensions.unsubscribe(subscriptionId);
subscriptionId = 0;
}
receive() external payable {}
}
SomniaEventHandler verifies that only the reactivity precompile can call the
external onEvent entry point. Your contract only implements _onEvent.
Step 2: Deploy and Fund the Handler
The subscription owner must hold at least 32 SOMI when the subscription is created. Because this constructor creates the subscription, send the funding with the deploy transaction.
export RPC_URL=<your Somnia RPC endpoint>
export PK=<your funded private key>
export TOKEN=<erc20 token address to watch>
forge create \
--rpc-url "$RPC_URL" \
--private-key "$PK" \
--broadcast \
--value 33ether \
src/TransferCounter.sol:TransferCounter \
--constructor-args "$TOKEN" 2000000
2000000 is the handler gas limit. Increase it if your _onEvent logic does
more work. Somnia storage operations can require more gas than the same pattern
on Ethereum, so leave margin. See the on-chain Reactivity
development advice for more
gas-limit guidance.
Step 3: Inspect the Subscription
Set the deployed address:
export COUNTER=<address printed by forge create>
Read the subscription ID:
cast call --rpc-url "$RPC_URL" "$COUNTER" 'subscriptionId()(uint256)'
Fetch subscription details from the node:
SUB_ID=$(cast call --rpc-url "$RPC_URL" "$COUNTER" 'subscriptionId()(uint256)' | awk '{print $1}')
cast rpc --rpc-url "$RPC_URL" \
somnia_reactivityGetSubscriptionInfo "$(cast to-hex "$SUB_ID")"
You should see the Transfer event topic and the token address in the stored
filter.
Step 4: Trigger a Matching Transfer
Send or otherwise trigger a transfer on the watched token:
cast send --rpc-url "$RPC_URL" --private-key "$PK" \
"$TOKEN" 'transfer(address,uint256)' <recipient> <amount>
After the transfer lands, the chain checks the log against active subscriptions. If it matches, validators include a synthetic transaction that calls the handler.
Read the counter:
cast call --rpc-url "$RPC_URL" "$COUNTER" 'transferCount()(uint256)'
Read the tracked amount for a recipient:
cast call --rpc-url "$RPC_URL" "$COUNTER" \
'receivedBy(address)(uint256)' <recipient>
Step 5: Stop the Subscription
When you no longer want the contract to react, unsubscribe:
cast send --rpc-url "$RPC_URL" --private-key "$PK" \
"$COUNTER" 'stop()'
After stop() succeeds, the old subscription ID should return an empty result from
somnia_reactivityGetSubscriptionInfo.
Notes
- The handler runs in a separate reactive transaction after the triggering transaction has executed.
msg.senderinsideonEventis the reactivity precompile at0x0100.tx.origininside the handler is the subscription owner.- The owner pays for every handler invocation.
caller,isGuaranteed, andisCoalescedare reserved fields on the raw precompile struct.SomniaExtensionssets them to their required defaults.- Avoid feedback loops where the handler emits an event that also matches its own subscription. See the on-chain Reactivity development advice before subscribing to events your handler might emit.
For the full API reference and failure modes, see On-chain Reactivity.
Copied from docs.somnia.network (somnia-main-docs) by scripts/import-gitbook.mts, until it finds a home in a product's docs.Edit this page