Somnia 백서

Somnia

원문

bitpro가 원문에서 글자만 모은 내용이에요. 표·그림·수식은 빠지거나 깨질 수 있어요. 전체 54쪽 중 9쪽

net', nativeCurrency: { decimals: 18, name: 'SOMI', symbol: 'SOMI', }, rpcUrls: { default: { http: ['https://api.infra.mainnet.somnia.network'], webSocket: ['wss://api.infra.mainnet.somnia.network/ws'], }, }, }); const publicClient = createPublicClient({ chain: somniaMainnet, transport: webSocket(somniaMainnet.rpcUrls.default.webSocket[0]), }); const sdk = new SDK({ public: publicClient }); ``` ## Step 2: Build the Filter and Read-Only Call The SDK fields map directly to the `somnia_watch` RPC parameters: | SDK field | RPC field | Meaning | | ---------------------- | ------------------- | --------------------------------------------------------------- | | `eventContractSources` | `address` | Emitters to watch. | | `topicOverrides` | `topics` | Positional topic filter. | | `ethCalls` | `eth_calls` | Read-only calls to run for each matching log. | | `context` | `context` | Log fields to append to each call's calldata. | | `onlyPushChanges` | `push_changes_only` | Drop events whose call results match the previous pushed event. | For ERC-20 `Transfer`, `topics[2]` is the recipient address. The off-chain Reactivity context selector is one-based, so `topic3` appends `topics[2]` to the call data. ```typescript import { toEventSelector, toFunctionSelector } from 'viem'; const tokenAddress = '0x046EDe9564A72571df6F5e44d0405360c0f4dCab'; const transferTopic = toEventSelector('Transfer(address,address,uint256)'); const balanceOfSelector = toFunctionSelector('balanceOf(address)'); const ethCall = { to: tokenAddress, data: balanceOfSelector, }; ``` `balanceOfSelector` contains only the 4-byte function selector. The node appends the recipient address from the log because the subscription uses `context: "topic3"`. ## Step 3: Subscribe ```typescript const subscription = await sdk.watch({ eventContractSources: [tokenAddress], topicOverrides: [transferTopic], ethCalls: [ethCall], context: 'topic3', onlyPushChanges: false, onData: (data) => { const event = data.result; console.log('Filtered notification:', event); }, onError: (error) => { console.error('Subscription error:', error); }, }); if (subscription instanceof Error) { throw subscription; } ``` `onlyPushChanges` compares the raw `simulationResults` with the previous pushed event on this subscription. It is useful when you care only about changed view results, but it is not a general event filter. For this recipient-balance example, leave it false unless you are comfortable dropping consecutive transfers whose recipients happen to have identical balances. ## Step 4: Decode the Event and Balance ```typescript import { decodeEventLog, decodeFunctionResult, erc20Abi } from 'viem'; function decodeTransferWithBalance(event: { topics: `0x${string}`[]; data: `0x${string}`; simulationResults: `0x${string}`[]; }) { const transfer = decodeEventLog({ abi: erc20Abi, topics: event.topics, data: event.data, }); const recipientBalance = decodeFunctionResult({ abi: erc20Abi, functionName: 'balanceOf', data: event.simulationResults[0], }); console.log('Transfer:', transfer.args); console.log('Recipient balance:', recipientBalance); } ``` Call `decodeTransferWithBalance(data.result)` from `onData`. ## Full Script Save this as `filtered.ts`: ```typescript import { SDK } from '@somnia-chain/reactivity'; import { createPublicClient, decodeEventLog, decodeFunctionResult, defineChain, erc20Abi, toEventSelector, toFunctionSelector, webSocket, } from 'viem'; const somniaMainnet = defineChain({ id: 5031, name: 'Somnia Mainnet', nativeCurrency: { decimals: 18, name: 'SOMI', symbol: 'SOMI', }, rpcUrls: { default: { http: ['https://api.infra.mainnet.somnia.network'], webSocket: ['wss://api.infra.mainnet.somnia.network/ws'], }, }, }); // Wrapped SOMI - see Troubleshooting below. const tokenAddress = '0x046EDe9564A72571df6F5e44d0405360c0f4dCab'; const transferTopic = toEventSelector('Transfer(address,address,uint256)'); const balanceOfSelector = toFunctionSelector('balanceOf(address)'); const publicClient = createPublicClient({ chain: somniaMainnet, transport: webSocket(somniaMainnet.rpcUrls.default.webSocket[0]), }); const sdk = new SDK({ public: publicClient }); async function main() { const subscription = await sdk.watch({ eventContractSources: [tokenAddress], topicOverrides: [transferTopic], ethCalls: [ { to: tokenAddress, data: balanceOfSelector, }, ], context: 'topic3', onlyPushChanges: false, onData: (data) => { const event = data.result; const transfer = decodeEventLog({ abi: erc20Abi, topics: event.topics, data: event.data, }); const recipientBalance = decodeFunctionResult({ abi: erc20Abi, functionName: 'balanceOf', data: event.simulationResults[0], }); console.log('Transfer:', transfer.args); console.log('Recipient balance:', recipientBalance); }, onError: (error) => { console.error('Subscription error:', error); }, }); if (subscription instanceof Error) { throw subscription; } console.log('Subscribed:', subscription.subscriptionId); process.on('SIGINT', async () => { await subscription.unsubscribe(); process.exit(0); }); } main().catch((error) => { console.error(error); process.exit(1); }); ``` Run it: ```bash npx tsx filtered.ts ``` ## Troubleshooting * Wrapped SOMI is not always actively transferred, so you may not see many events. You can pick another from [Smart contracts](/developer/smart-contracts) or from the recent transactions on the [block explorer](https://explorer.somnia.network). * If the subscription starts but decoding fails, confirm the ABI matches the event signature in `topicOverrides`. # 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](/developer/reactivity/reactivity-offchain). ## How This Example Works The contract: 1. Inherits from `SomniaEventHandler`. 2. Builds a non-wildcard `SubscriptionFilter` for one token's `Transfer` logs. 3. Calls `SomniaExtensions.subscribe(address(this), filter, options)` from the constructor. 4. Receives matching callbacks through `_onEvent`. 5. 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](/developer/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](/developer/smart-contracts)) Install the Solidity package: ```bash npm install @somnia-chain/reactivity-contracts ``` ## Step 1: Write the Handler Create `src/TransferCounter.sol`: ```solidity // 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. ```bash 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](/developer/reactivity/reactivity-onchain#development-advice) for more gas-limit guidance. ## Step 3: Inspect the Subscription Set the deployed address: ```bash export COUNTER=<address printed by forge create> ``` Read the subscription ID: ```bash cast call --rpc-url "$RPC_URL" "$COUNTER" 'subscriptionId()(uint256)' ``` Fetch subscription details from the node: ```bash 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: ```bash 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: ```bash cast call --rpc-url "$RPC_URL" "$COUNTER" 'transferCount()(uint256)' ``` Read the tracked amount for a recipient: ```bash 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: ```bash 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.sender` inside `onEvent` is the reactivity precompile at `0x0100`. * `tx.origin` inside the handler is the subscription owner. * The owner pays for every handler invocation. * `caller`, `isGuaranteed`, and `isCoalesced` are reserved fields on the raw precompile struct. `SomniaExtensions` sets 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](/developer/reactivity/reactivity-onchain#development-advice) before subscribing to events your handler might emit. For the full API reference and failure modes, see [On-chain Reactivity](/developer/reactivity/reactivity-onchain). # 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](/developer/reactivity/reactivity-onchain#system-events). ## 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](/developer/reactivity/tutorials/solidity-on-chain-reactivity-tutorial). ## Prerequisites You'll need Node.js 20+. Install the SDK and Viem: ```bash 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](/developer/network-info). ```typescript 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](/developer/reactivity/reactivity-onchain#development-advice) before using a recurring system-event subscription in production. ```typescript 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: ```typescript 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. ```typescript 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`: ```typescript 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: fa
프로젝트로 돌아가기