Part 3: Reading an Onchain Value
In the previous part, you successfully fetched data from an offchain API. Now, you will complete the "Onchain Calculator" by reading a value from a smart contract and combining it with your offchain result.
This part of the guide introduces EVM interactions using the TypeScript SDK's EVMClient and Viem for type-safe contract interactions.
What you'll do
- Configure your project with a Sepolia RPC URL.
- Use the EVM client with Viem to read a value from a deployed smart contract.
- Integrate the onchain value into your main workflow logic.
Step 1: The smart contract
For this guide, we will interact with a simple Storage contract that has already been deployed to the Sepolia testnet. All it does is store a single uint256 value.
Here is the Solidity source code for the contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract Storage {
uint256 public value;
constructor(uint256 initialValue) {
value = initialValue;
}
function get() public view returns (uint256) {
return value;
}
}
A version of this contract has been deployed to Sepolia at 0xa17CF997C28FF154eDBae1422e6a50BeF23927F4 with an initialValue of 22.
Step 2: Configure your environment
To interact with a contract on Sepolia, your workflow needs EVM chain details.
-
Contract address and chain name: Add the deployed contract's address and chain name to your
config.staging.jsonfile. We use anevmsarray to hold the configuration, which makes it easy to add more contracts (or chains) later.{ "schedule": "*/30 * * * * *", "apiUrl": "https://api.mathjs.org/v4/?expr=randomInt(1,101)", "evms": [ { "storageAddress": "0xa17CF997C28FF154eDBae1422e6a50BeF23927F4", "chainName": "ethereum-testnet-sepolia" } ] } -
RPC URL: For your workflow to interact with the blockchain, it needs an RPC endpoint. The
cre initcommand has already configured a public Sepolia RPC URL in yourproject.yamlfile for convenience. Let's take a look at what was generated:Open your
project.yamlfile at the root of your project. Yourstaging-settingstarget should look like this:# in onchain-calculator/project.yaml staging-settings: rpcs: - chain-name: ethereum-testnet-sepolia url: https://ethereum-sepolia-rpc.publicnode.comThis public RPC endpoint is sufficient for testing and following this guide. However, for production use or higher reliability, you should consider using a dedicated RPC provider like Infura, Alchemy, or QuickNode.
Step 3: Create the contract ABI file
To interact with the Storage contract in a type-safe and maintainable way, you'll create an ABI file that defines the contract's interface.
The TypeScript SDK uses Viem for EVM interactions, which provides excellent TypeScript type inference when you define ABIs as TypeScript modules.
-
Create the ABI directory: From your project root (
onchain-calculator/), create thecontracts/abidirectory:mkdir -p contracts/abi -
Add the Storage contract ABI: Create a new file called
Storage.tsin thecontracts/abidirectory:touch contracts/abi/Storage.tsOpen
contracts/abi/Storage.tsand paste the following ABI definition:export const Storage = [ { inputs: [], name: "get", outputs: [{ internalType: "uint256", name: "", type: "uint256" }], stateMutability: "view", type: "function", }, ] as constThe
as constassertion is important—it tells TypeScript to infer the most specific type possible, which enables Viem's type-safe contract interactions. -
Create an index file: To make imports cleaner, create an
index.tsfile that exports all your ABIs:touch contracts/abi/index.tsOpen
contracts/abi/index.tsand add:export { Storage } from "./Storage"This allows you to import ABIs using:
import { Storage } from "../contracts/abi".
Step 4: Update your workflow logic
Now that you have the ABI defined, you can import and use it in your workflow. Replace the entire content of onchain-calculator/my-calculator-workflow/main.ts with the version below.
Note: Lines highlighted in green indicate new or modified code compared to Part 2.
| 1 | import { |
| 2 | CronCapability, |
| 3 | HTTPClient, |
| 4 | EVMClient, |
| 5 | handler, |
| 6 | consensusMedianAggregation, |
| 7 | Runner, |
| 8 | type NodeRuntime, |
| 9 | type Runtime, |
| 10 | getNetwork, |
| 11 | LAST_FINALIZED_BLOCK_NUMBER, |
| 12 | encodeCallMsg, |
| 13 | bytesToHex, |
| 14 | } from "@chainlink/cre-sdk" |
| 15 | import { encodeFunctionData, decodeFunctionResult, zeroAddress } from "viem" |
| 16 | import { Storage } from "../contracts/abi" |
| 17 | |
| 18 | // EvmConfig defines the configuration for a single EVM chain. |
| 19 | type EvmConfig = { |
| 20 | storageAddress: string |
| 21 | chainName: string |
| 22 | } |
| 23 | |
| 24 | type Config = { |
| 25 | schedule: string |
| 26 | apiUrl: string |
| 27 | evms: EvmConfig[] |
| 28 | } |
| 29 | |
| 30 | type MyResult = { |
| 31 | finalResult: bigint |
| 32 | } |
| 33 | |
| 34 | const initWorkflow = (config: Config) => { |
| 35 | const cron = new CronCapability() |
| 36 | |
| 37 | return [handler(cron.trigger({ schedule: config.schedule }), onCronTrigger)] |
| 38 | } |
| 39 | |
| 40 | // fetchMathResult is the function passed to the runInNodeMode helper. |
| 41 | const fetchMathResult = (nodeRuntime: NodeRuntime<Config>): bigint => { |
| 42 | const httpClient = new HTTPClient() |
| 43 | |
| 44 | const req = { |
| 45 | url: nodeRuntime.config.apiUrl, |
| 46 | method: "GET" as const, |
| 47 | } |
| 48 | |
| 49 | const resp = httpClient.sendRequest(nodeRuntime, req).result() |
| 50 | const bodyText = new TextDecoder().decode(resp.body) |
| 51 | const val = BigInt(bodyText.trim()) |
| 52 | |
| 53 | return val |
| 54 | } |
| 55 | |
| 56 | const onCronTrigger = (runtime: Runtime<Config>): MyResult => { |
| 57 | // Step 1: Fetch offchain data (from Part 2) |
| 58 | const offchainValue = runtime.runInNodeMode(fetchMathResult, consensusMedianAggregation())().result() |
| 59 | |
| 60 | runtime.log(`Successfully fetched offchain value: ${offchainValue}`) |
| 61 | |
| 62 | // Get the first EVM configuration from the list. |
| 63 | const evmConfig = runtime.config.evms[0] |
| 64 | |
| 65 | // Step 2: Read onchain data using the EVM client |
| 66 | // Convert the human-readable chain name to a chain selector |
| 67 | const network = getNetwork({ |
| 68 | chainFamily: "evm", |
| 69 | chainSelectorName: evmConfig.chainName, |
| 70 | }) |
| 71 | if (!network) { |
| 72 | throw new Error(`Unknown chain name: ${evmConfig.chainName}`) |
| 73 | } |
| 74 | |
| 75 | const evmClient = new EVMClient(network.chainSelector.selector) |
| 76 | |
| 77 | // Encode the function call using the Storage ABI |
| 78 | const callData = encodeFunctionData({ |
| 79 | abi: Storage, |
| 80 | functionName: "get", |
| 81 | }) |
| 82 | |
| 83 | // Call the contract |
| 84 | const contractCall = evmClient |
| 85 | .callContract(runtime, { |
| 86 | call: encodeCallMsg({ |
| 87 | from: zeroAddress, |
| 88 | to: evmConfig.storageAddress as `0x${string}`, |
| 89 | data: callData, |
| 90 | }), |
| 91 | blockNumber: LAST_FINALIZED_BLOCK_NUMBER, |
| 92 | }) |
| 93 | .result() |
| 94 | |
| 95 | // Decode the result |
| 96 | const onchainValue = decodeFunctionResult({ |
| 97 | abi: Storage, |
| 98 | functionName: "get", |
| 99 | data: bytesToHex(contractCall.data), |
| 100 | }) as bigint |
| 101 | |
| 102 | runtime.log(`Successfully read onchain value: ${onchainValue}`) |
| 103 | |
| 104 | // Step 3: Combine the results |
| 105 | const finalResult = onchainValue + offchainValue |
| 106 | runtime.log(`Final calculated result: ${finalResult}`) |
| 107 | |
| 108 | return { |
| 109 | finalResult, |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | export async function main() { |
| 114 | const runner = await Runner.newRunner<Config>() |
| 115 | await runner.run(initWorkflow) |
| 116 | } |
| 117 | |
Key TypeScript SDK features:
getNetwork(): Converts a human-readable chain name to a numeric chain selectorEVMClient: The EVM capability client for interacting with blockchainsencodeFunctionData(): From Viem, encodes a function call with type-safe parameterscallContract(): EVMClient method for calling view/pure functions on a contractbytesToHex(): ConvertsUint8Arrayresponse data to hex string for ViemdecodeFunctionResult(): From Viem, decodes the contract call response with type inferenceLAST_FINALIZED_BLOCK_NUMBER: Constant for reading from the most recent finalized block- Native
bigint: TypeScript's built-in big integer type (no external library needed)
Step 5: Run the simulation and review the output
Run the simulation from your project root directory (the onchain-calculator/ folder). Because there is only one trigger defined, the simulator runs it automatically.
cre workflow simulate my-calculator-workflow --target staging-settings
The simulation logs will show the end-to-end execution of your workflow.
Workflow compiled
2025-11-03T19:06:56Z [SIMULATION] Simulator Initialized
2025-11-03T19:06:56Z [SIMULATION] Running trigger trigger=cron-trigger@1.0.0
2025-11-03T19:06:56Z [USER LOG] Successfully fetched offchain value: 55
2025-11-03T19:06:56Z [USER LOG] Successfully read onchain value: 22
2025-11-03T19:06:56Z [USER LOG] Final calculated result: 77
Workflow Simulation Result:
{
"finalResult": 77
}
2025-11-03T19:06:56Z [SIMULATION] Execution finished signal received
2025-11-03T19:06:56Z [SIMULATION] Skipping WorkflowEngineV2
[USER LOG]: You can now see all three of yourruntime.log()calls, showing the offchain value (55), the onchain value (22), and the final combined result (77).[SIMULATION]: These are system-level messages from the simulator showing its internal state.Workflow Simulation Result: This is the final, JSON-formatted return value of your workflow. ThefinalResultfield contains the sum of the offchain and onchain values (55 + 22 = 77).
You have successfully built a complete CRE workflow that combines offchain and onchain data.
Next Steps
You have successfully read a value from a smart contract and combined it with offchain data. The final step is to write this new result back to the blockchain.
- Part 4: Writing Onchain: Learn how to execute an onchain write transaction from your workflow to complete the project.