Onchain Read

This guide explains how to read data from a smart contract from within your CRE workflow. The TypeScript SDK uses viem for ABI handling and the SDK's EVMClient to create a type-safe developer experience.

The read pattern

Reading from a contract follows this pattern:

  1. Define your contract ABI: Create a TypeScript file with your contract's ABI using viem's parseAbi (inline) or store it in contracts/abi/ for complex workflows
  2. Get network information: Use the SDK's getNetwork() helper to look up chain selector and other network details
  3. Instantiate the EVM Client: Create an EVMClient instance with the chain selector
  4. Encode the function call: Use viem's encodeFunctionData() to ABI-encode your function call
  5. Encode the call message: Use encodeCallMsg() to create a properly formatted call message with from, to, and data
  6. Call the contract: Use callContract(runtime, {...}) to execute the read operation
  7. Decode the result: Use viem's decodeFunctionResult() to decode the returned data
  8. Await the result: Call .result() on the returned object to get the consensus-verified result

Step-by-step example

Let's read a value from a simple Storage contract with a get() view returns (uint256) function.

1. Define the contract ABI

For simple contracts, you can define the ABI inline using viem's parseAbi:

import { parseAbi } from "viem"

const storageAbi = parseAbi(["function get() view returns (uint256)"])

For complex workflows with multiple contracts, it's recommended to create separate ABI files in a contracts/abi/ directory. See Part 3 of the Getting Started guide for an example of this pattern.

2. The workflow logic

Here's a complete example of reading from a Storage contract:

import {
  cre,
  getNetwork,
  encodeCallMsg,
  bytesToHex,
  LAST_FINALIZED_BLOCK_NUMBER,
  type Runtime,
  Runner,
} from "@chainlink/cre-sdk"
import { type Address, encodeFunctionData, decodeFunctionResult, parseAbi, zeroAddress } from "viem"
import { z } from "zod"

// Define config schema with Zod
const configSchema = z.object({
  contractAddress: z.string(),
  chainSelectorName: z.string(),
})

type Config = z.infer<typeof configSchema>

// Define the Storage contract ABI
const storageAbi = parseAbi(["function get() view returns (uint256)"])

const onCronTrigger = (runtime: Runtime<Config>): string => {
  // Get network information
  const network = getNetwork({
    chainFamily: "evm",
    chainSelectorName: runtime.config.chainName,
    isTestnet: true,
  })

  if (!network) {
    throw new Error(`Network not found: ${runtime.config.chainSelectorName}`)
  }

  // Create EVM client with chain selector
  const evmClient = new cre.capabilities.EVMClient(network.chainSelector.selector)

  // Encode the function call
  const callData = encodeFunctionData({
    abi: storageAbi,
    functionName: "get",
    args: [], // No arguments for this function
  })

  // Call the contract
  const contractCall = evmClient
    .callContract(runtime, {
      call: encodeCallMsg({
        from: zeroAddress,
        to: runtime.config.contractAddress as Address,
        data: callData,
      }),
      blockNumber: LAST_FINALIZED_BLOCK_NUMBER,
    })
    .result()

  // Decode the result (convert Uint8Array to hex string for viem)
  const storedValue = decodeFunctionResult({
    abi: storageAbi,
    functionName: "get",
    data: bytesToHex(contractCall.data),
  })

  runtime.log(`Successfully read storage value: ${storedValue.toString()}`)
  return storedValue.toString()
}

const initWorkflow = (config: Config) => {
  return [
    cre.handler(
      new cre.capabilities.CronCapability().trigger({
        schedule: "*/10 * * * * *", // Every 10 seconds
      }),
      onCronTrigger
    ),
  ]
}

export async function main() {
  const runner = await Runner.newRunner<Config>()
  await runner.run(initWorkflow)
}

main()

Understanding the components

Network lookup with getNetwork()

The SDK provides a getNetwork() helper that looks up network information by name:

const network = getNetwork({
  chainFamily: "evm",
  chainSelectorName: "ethereum-testnet-sepolia",
  isTestnet: true,
})

// Returns network info including:
// - chainSelector.selector (numeric ID)
// - name
// - chainType

See the EVM Client SDK Reference for all available networks.

Block number options

When calling callContract(), you can specify which block to read from:

  • LAST_FINALIZED_BLOCK_NUMBER: Read from the last finalized block (recommended for production)
  • LATEST_BLOCK_NUMBER: Read from the latest block
  • Custom block number: Use a BigIntJson object for custom finality depths or historical queries
import { LAST_FINALIZED_BLOCK_NUMBER, LATEST_BLOCK_NUMBER } from "@chainlink/cre-sdk"

// Read from finalized block (most common)
const contractCall = evmClient.callContract(runtime, {
  call: encodeCallMsg({...}),
  blockNumber: LAST_FINALIZED_BLOCK_NUMBER,
}).result()

// Or read from latest block
const contractCall = evmClient.callContract(runtime, {
  call: encodeCallMsg({...}),
  blockNumber: LATEST_BLOCK_NUMBER,
}).result()

Custom block depths

For use cases requiring fixed confirmation thresholds (e.g., regulatory compliance) or historical state verification, you can specify an exact block number.

Example 1 - Read from a specific historical block:

import { blockNumber } from '@chainlink/cre-sdk'

const historicalBlock = 9767655n
const contractCall = evmClient.callContract(runtime, {
  call: encodeCallMsg({...}),
  blockNumber: blockNumber(historicalBlock),
}).result()

Example 2 - Read from 500 blocks ago for custom finality:

import { protoBigIntToBigint, blockNumber } from '@chainlink/cre-sdk'

// Get the latest block number
const latestHeader = evmClient.headerByNumber(runtime, {}).result()
if (!latestHeader.header?.blockNumber) {
  throw new Error("Failed to get latest block number")
}

// Convert protobuf BigInt to native bigint and calculate custom block
const latestBlockNum = protoBigIntToBigint(latestHeader.header.blockNumber)
const customBlock = latestBlockNum - 500n

// Call the contract at the custom block height
const contractCall = evmClient.callContract(runtime, {
  call: encodeCallMsg({...}),
  blockNumber: blockNumber(customBlock),
}).result()

Helper functions:

The SDK provides two helper functions for working with block numbers:

  • protoBigIntToBigint(pb) — Converts a protobuf BigInt (returned by SDK methods like headerByNumber) to a native JavaScript bigint. Use this when you need to perform arithmetic on block numbers.

  • blockNumber(n) — Converts a native bigint, number, or string to the protobuf BigInt JSON format required by SDK methods. This is an alias for bigintToProtoBigInt.

See Finality and Confidence Levels for more details on when to use custom block depths.

Encoding call messages with encodeCallMsg()

The encodeCallMsg() helper converts your hex-formatted call data into the base64 format required by the EVM capability:

import { encodeCallMsg } from "@chainlink/cre-sdk"
import { zeroAddress } from "viem"

const callMsg = encodeCallMsg({
  from: zeroAddress, // Caller address (typically zeroAddress for view functions)
  to: "0xYourContractAddress", // Contract address
  data: callData, // ABI-encoded function call from encodeFunctionData()
})

This helper is required because the underlying EVM capability expects addresses and data in base64 format, not hex.

ABI encoding/decoding with viem

The TypeScript SDK relies on viem for all ABI operations:

  • encodeFunctionData(): Encodes a function call into bytes
  • decodeFunctionResult(): Decodes the returned bytes into TypeScript types
  • parseAbi(): Parses human-readable ABI strings into typed ABI objects

The .result() pattern

All CRE capability calls return objects with a .result() method. Calling .result() blocks execution synchronously (within the WASM environment) and waits for the consensus-verified result.

// This returns an object with a .result() method
const callObject = evmClient.callContract(runtime, {
  call: encodeCallMsg({...}),
  blockNumber: LAST_FINALIZED_BLOCK_NUMBER,
})

// This blocks and returns the actual result
const contractCall = callObject.result()

This pattern is consistent across all SDK capabilities (EVM, HTTP, etc.).

Solidity-to-TypeScript type mappings

Viem automatically handles type conversions:

Solidity TypeTypeScript Type
uint8, uint256, etc.bigint
int8, int256, etc.bigint
addressstring
boolboolean
stringstring
bytes, bytes32, etc.Uint8Array

Complete example with configuration

Here's a full runnable workflow with external configuration:

Main workflow file (main.ts)

import {
  cre,
  getNetwork,
  encodeCallMsg,
  bytesToHex,
  LAST_FINALIZED_BLOCK_NUMBER,
  type Runtime,
  Runner,
} from "@chainlink/cre-sdk"
import { type Address, encodeFunctionData, decodeFunctionResult, parseAbi, zeroAddress } from "viem"
import { z } from "zod"

const configSchema = z.object({
  contractAddress: z.string(),
  chainSelectorName: z.string(),
})

type Config = z.infer<typeof configSchema>

const storageAbi = parseAbi(["function get() view returns (uint256)"])

const onCronTrigger = (runtime: Runtime<Config>): string => {
  const network = getNetwork({
    chainFamily: "evm",
    chainSelectorName: runtime.config.chainSelectorName,
    isTestnet: true,
  })

  if (!network) {
    throw new Error(`Network not found: ${runtime.config.chainSelectorName}`)
  }

  const evmClient = new cre.capabilities.EVMClient(network.chainSelector.selector)

  const callData = encodeFunctionData({
    abi: storageAbi,
    functionName: "get",
    args: [],
  })

  const contractCall = evmClient
    .callContract(runtime, {
      call: encodeCallMsg({
        from: zeroAddress,
        to: runtime.config.contractAddress as Address,
        data: callData,
      }),
      blockNumber: LAST_FINALIZED_BLOCK_NUMBER,
    })
    .result()

  const storedValue = decodeFunctionResult({
    abi: storageAbi,
    functionName: "get",
    data: bytesToHex(contractCall.data),
  })

  runtime.log(`Storage value: ${storedValue.toString()}`)
  return storedValue.toString()
}

const initWorkflow = (config: Config) => {
  return [
    cre.handler(
      new cre.capabilities.CronCapability().trigger({
        schedule: "*/10 * * * * *",
      }),
      onCronTrigger
    ),
  ]
}

export async function main() {
  const runner = await Runner.newRunner<Config>()
  await runner.run(initWorkflow)
}

main()

Configuration file (config.json)

{
  "contractAddress": "0xa17CF997C28FF154eDBae1422e6a50BeF23927F4",
  "chainSelectorName": "ethereum-testnet-sepolia"
}

Working with complex ABIs

For workflows with multiple contracts or complex ABIs, organize them in separate files:

Contract ABI file (contracts/abi/Storage.ts)

import { parseAbi } from "viem"

export const Storage = parseAbi(["function get() view returns (uint256)", "function set(uint256 value) external"])

Export file (contracts/abi/index.ts)

export { Storage } from "./Storage"

Import in workflow

import { Storage } from "../contracts/abi"

const callData = encodeFunctionData({
  abi: Storage,
  functionName: "get",
  args: [],
})

This pattern provides better organization, reusability, and type safety across your workflow.

Next steps

Get the latest Chainlink content straight to your inbox.