Generating Contract Bindings

To interact with a smart contract from your TypeScript workflow, you first need to create bindings. Bindings are type-safe TypeScript classes auto-generated from your contract's ABI. They handle all encoding and decoding—including base64 conversion for the CRE SDK wire format—so you can work directly with native TypeScript types.

How they work depends on whether you are reading from or writing to the chain:

  • For onchain reads, bindings provide TypeScript methods that directly mirror your contract's view and pure functions.
  • For onchain writes, bindings provide writeReportFrom<FunctionName>() helpers that ABI-encode your data and submit a signed report.
  • For event triggers, bindings provide logTrigger<EventName>() methods that handle topic encoding and return a typed trigger object. The handler receives fully decoded event data—no manual hex or base64 conversion needed.

This is a one-time code generation step performed using the CRE CLI.

The generation process

The CRE CLI reads your ABI files and generates a typed class with all the methods your workflow needs.

The target language is auto-detected from your project files (presence of package.json picks TypeScript). You can also force TypeScript explicitly with the --language flag:

cre generate-bindings evm --language typescript

Step 1: Add your contract ABI

Place your contract ABI file into the contracts/evm/src/abi/ directory. Two file formats are supported:

  • *.abi — A raw JSON array of ABI entries, as produced by solc or extracted from a compiled artifact
  • *.json — A compiled artifact file (Hardhat, Foundry, or similar) with a top-level "abi" field

For example, to generate bindings for a PriceUpdater contract, create either contracts/evm/src/abi/PriceUpdater.abi or contracts/evm/src/abi/PriceUpdater.json. Both formats can coexist in the same directory.

Step 2: Generate the bindings

From your project root, run:

cre generate-bindings evm

This scans all .abi and .json files in contracts/evm/src/abi/ and generates corresponding TypeScript files in contracts/evm/ts/generated/. For each contract, three files are generated:

  • <ContractName>.ts — The typed binding class with read, write, and event trigger methods.
  • <ContractName>_mock.ts — A mock implementation for testing your workflows without deploying contracts.
  • index.ts — A barrel file that re-exports everything from all generated bindings in the directory.

Each binding class is named after the contract and is imported directly into your workflow.

Using generated bindings

For onchain reads

For view or pure functions, the generator creates methods on the class that call the contract and return the decoded result. These methods do not return a Promise — they synchronously return the decoded value after the DON reaches consensus.

Example: A simple Storage contract

Create contracts/evm/src/abi/Storage.abi with the following content. This contract is already deployed on Sepolia at 0xa17CF997C28FF154eDBae1422e6a50BeF23927F4 with an initial value of 22, so you can run this example without deploying anything.

[
  {
    "inputs": [{ "internalType": "uint256", "name": "initialValue", "type": "uint256" }],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "get",
    "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "value",
    "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }],
    "stateMutability": "view",
    "type": "function"
  }
]

After running cre generate-bindings evm, use the generated Storage class in your workflow:

import { EVMClient, handler, CronCapability, Runner, type Runtime } from "@chainlink/cre-sdk"
import { Storage } from "../contracts/evm/ts/generated/Storage"

type Config = {
  schedule: string
  storageAddress: string
  chainSelector: string
}

const onCronTrigger = (runtime: Runtime<Config>): string => {
  const config = runtime.config

  // EVMClient takes the chain selector as a bigint directly
  const client = new EVMClient(BigInt(config.chainSelector))
  const storageContract = new Storage(client, config.storageAddress as `0x${string}`)

  // Call the view function — result is already a decoded bigint
  const value = storageContract.get(runtime)
  runtime.log(`Storage value: ${value}`)
  return value.toString()
}

export const initWorkflow = (config: Config) => {
  const cron = new CronCapability()
  return [handler(cron.trigger({ schedule: config.schedule }), onCronTrigger)]
}

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

config.staging.json:

{
  "schedule": "*/30 * * * * *",
  "storageAddress": "0xa17CF997C28FF154eDBae1422e6a50BeF23927F4",
  "chainSelector": "16015286601757825753"
}

Run the simulation from your project root:

cre workflow simulate my-workflow

Expected output:

✓ Workflow compiled
[SIMULATION] Simulator Initialized
[SIMULATION] Running trigger trigger=cron-trigger@1.0.0
[USER LOG] Storage value: 22

✓ Workflow Simulation Result:
"22"

For onchain writes

For write functions, the generator creates a writeReportFrom<FunctionName>() method that handles ABI encoding, report generation, and submission in one step.

Signaling the generator

To generate write helpers, your ABI must include at least one public or external non-view function. The generated method is named after the function name in your ABI.

Example: A PriceUpdater contract

// contracts/evm/src/abi/PriceUpdater.abi
contract PriceUpdater {
  struct PriceData {
    uint256 ethPrice;
    uint256 btcPrice;
  }

  function updatePrices(PriceData memory) public {}
}

After running cre generate-bindings evm, you can use the generated class:

import { EVMClient, handler, CronCapability, Runner, type Runtime } from "@chainlink/cre-sdk"
import { PriceUpdater } from "../contracts/evm/ts/generated/PriceUpdater"

type Config = {
  schedule: string
  proxyAddress: string
  chainSelector: string
}

const onCronTrigger = (runtime: Runtime<Config>) => {
  const config = runtime.config

  const client = new EVMClient(BigInt(config.chainSelector))
  const contract = new PriceUpdater(client, config.proxyAddress as `0x${string}`)

  // Pass the function arguments directly — types are derived from the ABI
  return contract.writeReportFromUpdatePrices(runtime, { ethPrice: 4000_000000n, btcPrice: 60000_000000n })
}

export const initWorkflow = (config: Config) => {
  const cron = new CronCapability()
  return [handler(cron.trigger({ schedule: config.schedule }), onCronTrigger)]
}

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

For event logs

The binding generator creates strongly-typed trigger and decoder methods for each event in your ABI. This replaces all manual hexToBase64 and topic encoding — the generated method handles it automatically.

Example: A contract with a UserAdded event

contract UserDirectory {
  event UserAdded(address indexed userAddress, string userName);

  function addUser(string calldata userName) external {
    emit UserAdded(msg.sender, userName);
  }
}

Generated types

For each event, the generator creates two types:

  • <EventName>Topics — Optional filter params (indexed fields only). Pass one or more of these to filter events by specific values.
  • <EventName>Decoded — All event fields, decoded to their TypeScript types.
// Generated in contracts/evm/ts/generated/UserDirectory.ts

export type UserAddedTopics = {
  userAddress?: `0x${string}` // indexed field — can be used for filtering
}

export type UserAddedDecoded = {
  userAddress: `0x${string}`
  userName: string
}

Triggering and decoding events

Use the logTrigger<EventName>() method in your initWorkflow function to create a trigger, and the decode<EventName>() method (accessed via the trigger's adapt property) to decode the log data in your handler.

import { EVMClient, handler, Runner, type Runtime } from "@chainlink/cre-sdk"
import { UserDirectory } from "../contracts/evm/ts/generated/UserDirectory"
import type { DecodedLog, UserAddedDecoded } from "../contracts/evm/ts/generated/UserDirectory"

type Config = {
  contractAddress: string
  chainSelector: string
}

export const initWorkflow = (config: Config) => {
  const client = new EVMClient(BigInt(config.chainSelector))
  const userDirectory = new UserDirectory(client, config.contractAddress as `0x${string}`)

  // Create a trigger for all UserAdded events (no filter)
  const userAddedTrigger = userDirectory.logTriggerUserAdded()

  // To filter for a specific user address:
  // const userAddedTrigger = userDirectory.logTriggerUserAdded([
  //   { userAddress: "0xabc..." }
  // ])

  return [handler(userAddedTrigger, onUserAdded)]
}

const onUserAdded = (runtime: Runtime<Config>, log: DecodedLog<UserAddedDecoded>) => {
  // log.data is already the typed UserAddedDecoded object — no manual decoding needed
  runtime.log(`New user added! address=${log.data.userAddress} name=${log.data.userName}`)
}

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

What the CLI generates

For each ABI file, the generator creates three files in contracts/evm/ts/generated/:

  • <ContractName>.ts — The main binding class
  • <ContractName>_mock.ts — A mock implementation for testing
  • index.ts — A barrel re-exporting everything, so you can import from a single path

What's inside depends on your ABI:

  • For all contracts:
    • A typed <ContractName>ABI constant for use with viem utilities.
    • A <ContractName> class with a writeReport(runtime, callData, gasConfig) base write method.
    • A <ContractName>Mock type with optional function fields for each view/pure method (e.g., get?: () => bigint) plus a writeReport field, and a new<ContractName>Mock(address, evmMock) factory.
  • For onchain reads (each view/pure function):
    • A method on the class (e.g., get(runtime)) that returns the decoded value directly as a native TypeScript type.
  • For onchain writes (each non-view function):
    • A writeReportFrom<FunctionName>(runtime, args, gasConfig?) method that handles ABI encoding, report generation, and submission in one step.
  • For events (each event definition):
    • <EventName>Topics and <EventName>Decoded types.
    • A logTrigger<EventName>(filters?) method that returns a typed trigger object with OR semantics for multiple filters.
    • A decode<EventName>(log) method that decodes a raw EVMLog into DecodedLog<EventDecoded>.

You can import from the barrel file to keep imports clean:

import { Storage, newStorageMock } from "../contracts/evm/ts/generated"

Using mock bindings for testing

The <ContractName>_mock.ts files allow you to test your workflows without deploying or interacting with real contracts. Each mock provides:

  • Test-friendly factory: new<ContractName>Mock(address, evmMock) creates a mock instance
  • Mockable methods: Set custom function implementations for each contract view/pure function
  • Type safety: The same input/output types as the real binding

Mocks are used together with the @chainlink/cre-sdk/test module, which provides newTestRuntime() (a Runtime you can call your handlers with directly, outside the WASM sandbox) and EvmMock (a fake EVM client you attach contract mocks to). Tests run with Bun's built-in test runner, using describe/expect from bun:test and the test helper from @chainlink/cre-sdk/test.

Complete example: Testing a workflow with mocks

Let's say you have a workflow in my-workflow/workflow.ts that reads from a Storage contract. Create a test file named workflow.test.ts in the same directory.

// File: my-workflow/workflow.test.ts
import { describe, expect } from "bun:test"
import { EvmMock, newTestRuntime, test } from "@chainlink/cre-sdk/test"
import type { Address } from "viem"

import { newStorageMock } from "../contracts/evm/ts/generated/Storage_mock"
import { onCronTrigger } from "./workflow"
import type { Config } from "./workflow"

const CHAIN_SELECTOR = 16015286601757825753n // ethereum-testnet-sepolia
const STORAGE_ADDRESS = "0xa17CF997C28FF154eDBae1422e6a50BeF23927F4" as Address

describe("onCronTrigger", () => {
  test("reads the storage value and logs it", async () => {
    // 1. Set up your config
    const config: Config = {
      chainSelector: CHAIN_SELECTOR.toString(),
      storageAddress: STORAGE_ADDRESS,
    }

    // 2. Create a mock EVM client for the given chain
    const evmMock = EvmMock.testInstance(CHAIN_SELECTOR)

    // 3. Create a mock Storage contract and set up mock behavior
    const storageMock = newStorageMock(STORAGE_ADDRESS, evmMock)
    storageMock.get = () => 42n

    // 4. Create a test runtime and attach your config to it
    const runtime = newTestRuntime()
    runtime.config = config

    // 5. Call your handler directly — no simulator or WASM build required.
    //    Because your workflow constructs its EVMClient from `runtime`, and the
    //    mock is registered on that chain selector, the handler transparently
    //    uses the mocked Get() function.
    const result = onCronTrigger(runtime)

    expect(result).toBe("42")
    expect(runtime.getLogs()).toContain("Storage value: 42")
  })
})

Running your tests

From your workflow directory (or your project root, if you point Bun at the workflow path), run:

# Run every *.test.ts file discovered by Bun
bun test

# Run a specific test file
bun test workflow.test.ts

# Run tests matching a name pattern
bun test --test-name-pattern "reads the storage value"

Expected output:

bun test v1.2.x

workflow.test.ts:
✓ onCronTrigger > reads the storage value and logs it [1.20ms]

 1 pass
 0 fail
 1 expect() calls
Ran 1 test across 1 file. [12.00ms]

The test passes, confirming your mock contract is set up correctly and your handler produces the expected result using the mocked contract — all without running the simulator or deploying anything.

Best practices for workflow testing

  1. Name test files correctly: Use <name>.test.ts (e.g., workflow.test.ts) and place them next to the workflow file they cover.
  2. Call handlers directly: Import and call your on<Trigger> functions directly with a newTestRuntime() — you don't need initWorkflow or the simulator to unit test handler logic.
  3. Mock all external dependencies: Use generated contract mocks (new<ContractName>Mock) for EVM calls, and assert on runtime.getLogs() instead of relying on console output.
  4. Test different scenarios: Write separate test(...) cases for success cases, error cases (e.g., a required config field missing), and edge cases.
  5. Test initWorkflow separately: Assert that initWorkflow(config) returns the handlers you expect, wired to the right triggers (e.g., checking handlers[0].trigger.config.schedule), independent of testing the handler logic itself.

Complete reference example

For a comprehensive example showing how to test workflows with multiple triggers (cron, EVM log) and multiple mock contracts, see the Custom Data Feed demo workflow's workflow.test.ts file.

To generate this example:

  1. Run cre init from your project directory
  2. Select TypeScript as your language
  3. Choose the "Custom data feed: Updating on-chain data periodically using offchain API data" template
  4. After initialization completes, examine the generated workflow.test.ts file in your workflow directory

This generated test file demonstrates real-world patterns for testing complex workflows with multiple capabilities and mock contracts.

Best practices

  1. Regenerate when needed: Re-run cre generate-bindings evm whenever you update your contract ABIs. Do not edit generated files by hand.
  2. Handle errors: The write and trigger methods will throw if encoding or network calls fail. Wrap them in try/catch blocks in your workflow handlers.
  3. Use explicit --language in CI: If your project has both go.mod and package.json, auto-detection may be ambiguous. Pass --language typescript explicitly in CI pipelines.
  4. Organize ABIs: Keep your .abi files clearly named in contracts/evm/src/abi/. The file name determines the generated class name.
  5. Use mocks in tests: Leverage the generated mock bindings to test your workflows in isolation without needing deployed contracts or the simulator.

Where to go next

Now that you know how to generate bindings, you can use them to read data from or write data to your contracts, or trigger workflows from events.

Get the latest Chainlink content straight to your inbox.