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
viewandpurefunctions. - 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 bysolcor 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 testingindex.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>ABIconstant for use with viem utilities. - A
<ContractName>class with awriteReport(runtime, callData, gasConfig)base write method. - A
<ContractName>Mocktype with optional function fields for eachview/puremethod (e.g.,get?: () => bigint) plus awriteReportfield, and anew<ContractName>Mock(address, evmMock)factory.
- A typed
- For onchain reads (each
view/purefunction):- A method on the class (e.g.,
get(runtime)) that returns the decoded value directly as a native TypeScript type.
- A method on the class (e.g.,
- 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.
- A
- For events (each
eventdefinition):<EventName>Topicsand<EventName>Decodedtypes.- 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 rawEVMLogintoDecodedLog<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/purefunction - 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
- Name test files correctly: Use
<name>.test.ts(e.g.,workflow.test.ts) and place them next to the workflow file they cover. - Call handlers directly: Import and call your
on<Trigger>functions directly with anewTestRuntime()— you don't needinitWorkflowor the simulator to unit test handler logic. - Mock all external dependencies: Use generated contract mocks (
new<ContractName>Mock) for EVM calls, and assert onruntime.getLogs()instead of relying on console output. - Test different scenarios: Write separate
test(...)cases for success cases, error cases (e.g., a required config field missing), and edge cases. - Test
initWorkflowseparately: Assert thatinitWorkflow(config)returns the handlers you expect, wired to the right triggers (e.g., checkinghandlers[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:
- Run
cre initfrom your project directory - Select TypeScript as your language
- Choose the "Custom data feed: Updating on-chain data periodically using offchain API data" template
- After initialization completes, examine the generated
workflow.test.tsfile in your workflow directory
This generated test file demonstrates real-world patterns for testing complex workflows with multiple capabilities and mock contracts.
Best practices
- Regenerate when needed: Re-run
cre generate-bindings evmwhenever you update your contract ABIs. Do not edit generated files by hand. - 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.
- Use explicit
--languagein CI: If your project has bothgo.modandpackage.json, auto-detection may be ambiguous. Pass--language typescriptexplicitly in CI pipelines. - Organize ABIs: Keep your
.abifiles clearly named incontracts/evm/src/abi/. The file name determines the generated class name. - 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.