Somnia Reactivity / Tutorials

Cron Subscriptions via SDK

Create block-tick and scheduled subscriptions using SDK

The @somnia-chain/reactivity TypeScript SDK can create on-chain subscriptions owned by an externally-owned account (EOA). This tutorial covers the two system event helpers:

  • scheduleSubscriptionAtBlock
  • scheduleSubscriptionAtTimestamp

Use these helpers when a wallet or backend should own and pay for an on-chain callback, while a Solidity handler contract receives the reactive transaction.

For the system-event reference, see On-chain Reactivity.

What the Helpers Create

System events are synthetic logs emitted by the reactivity precompile at 0x0100.

Helper System event Behavior
scheduleSubscriptionAtBlock BlockTick(uint64) Every block if blockNumber is omitted, or once at the specified block.
scheduleSubscriptionAtTimestamp Schedule(uint256) Once, when block time first reaches the requested millisecond timestamp.

The handler contract must implement onEvent(address,bytes32[],bytes). The usual way to do that is to inherit from SomniaEventHandler, as shown in the Solidity on-chain tutorial.

Prerequisites

You'll need Node.js 20+.

Install the SDK and Viem:

npm install @somnia-chain/reactivity viem
npm install --save-dev tsx typescript @types/node

To create a subscription, you'll need:

  • a deployed handler contract
  • an EOA funded with at least 32 SOMI

Step 1: Prepare the SDK

This example uses Somnia Testnet. For mainnet, use the mainnet chain ID, currency and RPC URL from Network Info.

import { SDK } from '@somnia-chain/reactivity';
import {
  createPublicClient,
  createWalletClient,
  defineChain,
  http,
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

const somniaTestnet = defineChain({
  id: 50312,
  name: 'Somnia Testnet',
  nativeCurrency: {
    decimals: 18,
    name: 'STT',
    symbol: 'STT',
  },
  rpcUrls: {
    default: {
      http: ['https://api.infra.testnet.somnia.network'],
    },
  },
});

const privateKey = process.env.PRIVATE_KEY as `0x${string}` | undefined;

if (!privateKey) {
  throw new Error('Set PRIVATE_KEY=0x... before running this script');
}

const account = privateKeyToAccount(privateKey);

const publicClient = createPublicClient({
  chain: somniaTestnet,
  transport: http(somniaTestnet.rpcUrls.default.http[0]),
});

const walletClient = createWalletClient({
  account,
  chain: somniaTestnet,
  transport: http(somniaTestnet.rpcUrls.default.http[0]),
});

const sdk = new SDK({
  public: publicClient,
  wallet: walletClient,
});

Step 2: Subscribe to Block Ticks

Omit blockNumber for a recurring every-block subscription. Use this carefully: it can invoke your handler every block until the subscription is removed. See the on-chain Reactivity development advice before using a recurring system-event subscription in production.

const handlerContractAddress = process.env.HANDLER_ADDRESS as `0x${string}`;

const txHash = await sdk.scheduleSubscriptionAtBlock({
  handlerContractAddress,
  priorityFeePerGas: 1n,
  maxFeePerGas: 0n,
  gasLimit: 2_000_000n,
  isGuaranteed: false,
  isCoalesced: false,
});

if (txHash instanceof Error) {
  throw txHash;
}

console.log('Block tick subscription tx:', txHash);

Pass blockNumber for a one-shot subscription to a specific future block:

const currentBlock = await publicClient.getBlockNumber();
const handlerContractAddress = process.env.HANDLER_ADDRESS as `0x${string}`;

const txHash = await sdk.scheduleSubscriptionAtBlock({
  blockNumber: currentBlock + 20n,
  handlerContractAddress,
  priorityFeePerGas: 1n,
  maxFeePerGas: 0n,
  gasLimit: 2_000_000n,
  isGuaranteed: false,
  isCoalesced: false,
});

if (txHash instanceof Error) {
  throw txHash;
}

Internally, the SDK sets:

  • emitter to the reactivity precompile address
  • eventTopics[0] to the BlockTick(uint64) selector
  • eventTopics[1] to the block number, or zero for every block
  • handlerFunctionSelector to ISomniaEventHandler.onEvent.selector when you omit it

Step 3: Schedule a One-Off Callback

scheduleSubscriptionAtTimestamp creates a one-shot Schedule(uint256) subscription. The timestamp is an absolute Unix timestamp in milliseconds.

const timestampMs = Date.now() + 5 * 60 * 1000;
const handlerContractAddress = process.env.HANDLER_ADDRESS as `0x${string}`;

const txHash = await sdk.scheduleSubscriptionAtTimestamp({
  timestampMs,
  handlerContractAddress,
  priorityFeePerGas: 1n,
  maxFeePerGas: 0n,
  gasLimit: 2_000_000n,
  isGuaranteed: false,
  isCoalesced: false,
});

if (txHash instanceof Error) {
  throw txHash;
}

console.log('Scheduled callback tx:', txHash);

The SDK returns an Error when timestampMs < Date.now() + 12_000, so schedule at least 12 seconds ahead. It does not throw for that validation path, so always check instanceof Error.

Full Script

Save this as cron.ts:

import { SDK } from '@somnia-chain/reactivity';
import {
  createPublicClient,
  createWalletClient,
  defineChain,
  http,
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';

const handlerContractAddress = process.argv[2] as `0x${string}`;
const privateKey = process.env.PRIVATE_KEY as `0x${string}` | undefined;

if (!handlerContractAddress) {
  throw new Error('Usage: npx tsx cron.ts <handlerContractAddress>');
}

if (!privateKey) {
  throw new Error('Set PRIVATE_KEY=0x... before running this script');
}

const somniaTestnet = defineChain({
  id: 50312,
  name: 'Somnia Testnet',
  nativeCurrency: {
    decimals: 18,
    name: 'STT',
    symbol: 'STT',
  },
  rpcUrls: {
    default: {
      http: ['https://api.infra.testnet.somnia.network'],
    },
  },
});

const account = privateKeyToAccount(privateKey);

const publicClient = createPublicClient({
  chain: somniaTestnet,
  transport: http(somniaTestnet.rpcUrls.default.http[0]),
});

const walletClient = createWalletClient({
  account,
  chain: somniaTestnet,
  transport: http(somniaTestnet.rpcUrls.default.http[0]),
});

const sdk = new SDK({
  public: publicClient,
  wallet: walletClient,
});

async function main() {
  const currentBlock = await publicClient.getBlockNumber();

  const blockTickTx = await sdk.scheduleSubscriptionAtBlock({
    blockNumber: currentBlock + 20n,
    handlerContractAddress,
    priorityFeePerGas: 1n,
    maxFeePerGas: 0n,
    gasLimit: 2_000_000n,
    isGuaranteed: false,
    isCoalesced: false,
  });

  if (blockTickTx instanceof Error) {
    throw blockTickTx;
  }

  console.log('One-shot block tick subscription tx:', blockTickTx);

  // Wait for the first subscription to land before broadcasting the next one,
  // so the two writes don't race on the wallet's nonce.
  await publicClient.waitForTransactionReceipt({ hash: blockTickTx });

  const scheduleTx = await sdk.scheduleSubscriptionAtTimestamp({
    timestampMs: Date.now() + 5 * 60 * 1000,
    handlerContractAddress,
    priorityFeePerGas: 1n,
    maxFeePerGas: 0n,
    gasLimit: 2_000_000n,
    isGuaranteed: false,
    isCoalesced: false,
  });

  if (scheduleTx instanceof Error) {
    throw scheduleTx;
  }

  console.log('Scheduled callback tx:', scheduleTx);
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

Run it:

PRIVATE_KEY=0x... npx tsx cron.ts <handlerContractAddress>

Cancel from TypeScript

The SDK can also cancel subscriptions owned by the same wallet client. Pass the subscription ID, not the creation transaction hash:

const cancelTx = await sdk.cancelSoliditySubscription(subscriptionId);

if (cancelTx instanceof Error) {
  throw cancelTx;
}

console.log('Cancelled subscription tx:', cancelTx);

Equivalent Solidity Helpers

If a Solidity contract owns the subscription, prefer SomniaExtensions from inside that contract instead of the TypeScript SDK. The snippets below assume options is a SomniaExtensions.SubscriptionOptions value.

One-shot block subscription:

// Any future block works. This example uses a small offset for easy testing.
uint256 subscriptionId = SomniaExtensions.scheduleSubscriptionAtBlock(
    address(this),
    uint64(block.number + 20),
    options
);

One-shot timestamp subscription:

uint256 subscriptionId = SomniaExtensions.scheduleSubscriptionAtTimestamp(
    address(this),
    (block.timestamp + 5 minutes) * 1000,
    options
);

Recurring every-block subscription:

SomniaExtensions.SubscriptionFilter memory filter =
    SomniaExtensions.SubscriptionFilter({
        eventTopics: [
            ISomniaReactivityPrecompile.BlockTick.selector,
            bytes32(0),
            bytes32(0),
            bytes32(0)
        ],
        origin: address(0),
        emitter: SomniaExtensions.SOMNIA_REACTIVITY_PRECOMPILE_ADDRESS
    });

uint256 subscriptionId = SomniaExtensions.subscribe(
    address(this),
    filter,
    options
);

The scheduleSubscriptionAt* Solidity helpers are one-shot. For recurring every-block or every-epoch subscriptions, build the filter and call SomniaExtensions.subscribe directly.

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