Part 4: Writing Onchain
In the previous parts, you successfully fetched offchain data and read from a smart contract. Now, you'll complete the "Onchain Calculator" by writing your computed result back to the blockchain.
What you'll do
- Use the
CalculatorConsumercontract to receive workflow results - Modify your workflow to write data to the blockchain using the EVM capability
- Execute your first onchain write transaction through CRE
- Verify your result on the blockchain
Step 1: The consumer contract
To write data onchain, your workflow needs a target smart contract (a "consumer contract"). For this guide, we have pre-deployed a simple CalculatorConsumer contract on the Sepolia testnet. This contract is designed to receive and store the calculation results from your workflow.
Here is the source code for the contract so you can see how it works:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import { IReceiverTemplate } from "./keystone/IReceiverTemplate.sol";
/**
* @title CalculatorConsumer (Testing Version)
* @notice This contract receives reports from a CRE workflow and stores the results of a calculation onchain.
* @dev This version uses IReceiverTemplate without configuring any security checks, making it compatible
* with the mock Forwarder used during simulation. All permission fields remain at their default zero
* values (disabled).
*/
contract CalculatorConsumer is IReceiverTemplate {
// Struct to hold the data sent in a report from the workflow
struct CalculatorResult {
uint256 offchainValue;
int256 onchainValue;
uint256 finalResult;
}
// --- State Variables ---
CalculatorResult public latestResult;
uint256 public resultCount;
mapping(uint256 => CalculatorResult) public results;
// --- Events ---
event ResultUpdated(uint256 indexed resultId, uint256 finalResult);
/**
* @dev The constructor doesn't set any security checks.
* The IReceiverTemplate parent constructor will initialize all permission fields to zero (disabled).
*/
constructor() {}
/**
* @notice Implements the core business logic for processing reports.
* @dev This is called automatically by IReceiverTemplate's onReport function after security checks.
*/
function _processReport(bytes calldata report) internal override {
// Decode the report bytes into our CalculatorResult struct
CalculatorResult memory calculatorResult = abi.decode(report, (CalculatorResult));
// --- Core Logic ---
// Update contract state with the new result
resultCount++;
results[resultCount] = calculatorResult;
latestResult = calculatorResult;
emit ResultUpdated(resultCount, calculatorResult.finalResult);
}
// This function is a "dry-run" utility. It allows an offchain system to check
// if a prospective result is an outlier before submitting it for a real onchain update.
// It is also used to guide the binding generator to create a method that accepts the CalculatorResult struct.
function isResultAnomalous(CalculatorResult memory _prospectiveResult) public view returns (bool) {
// A result is not considered anomalous if it's the first one.
if (resultCount == 0) {
return false;
}
// Business logic: Define an anomaly as a new result that is more than double the previous result.
// This is just one example of a validation rule you could implement.
return _prospectiveResult.finalResult > (latestResult.finalResult * 2);
}
}
The contract is already deployed for you on Sepolia at the following address: 0xF3abEAa889e46c6C5b9A0bD818cE54Cc4eAF8A54. You will use this address in your configuration file.
Step 2: Update your workflow configuration
Add the CalculatorConsumer contract address to your config.staging.json:
{
"schedule": "*/30 * * * * *",
"apiUrl": "https://api.mathjs.org/v4/?expr=randomInt(1,101)",
"evms": [
{
"storageAddress": "0xa17CF997C28FF154eDBae1422e6a50BeF23927F4",
"calculatorConsumerAddress": "0xF3abEAa889e46c6C5b9A0bD818cE54Cc4eAF8A54",
"chainName": "ethereum-testnet-sepolia",
"gasLimit": "500000"
}
]
}
Step 3: Update your workflow logic
Now modify your workflow to write the final result to the contract. Writing onchain involves a two-step process:
- Generate a signed report: Use
runtime.report()to create a cryptographically signed report from your workflow data - Submit the report: Use
evmClient.writeReport()to submit the signed report to the consumer contract
The TypeScript SDK uses Viem's encodeAbiParameters to properly encode the struct data according to the contract's ABI before generating the report.
Replace the entire content of onchain-calculator/my-calculator-workflow/main.ts with this final version.
Note: Lines highlighted in green indicate new or modified code compared to Part 3.
| 1 | import { |
| 2 | cre, |
| 3 | consensusMedianAggregation, |
| 4 | Runner, |
| 5 | type NodeRuntime, |
| 6 | type Runtime, |
| 7 | getNetwork, |
| 8 | LAST_FINALIZED_BLOCK_NUMBER, |
| 9 | encodeCallMsg, |
| 10 | bytesToHex, |
| 11 | hexToBase64, |
| 12 | } from "@chainlink/cre-sdk" |
| 13 | import { encodeAbiParameters, parseAbiParameters, encodeFunctionData, decodeFunctionResult, zeroAddress } from "viem" |
| 14 | import { Storage } from "../contracts/abi" |
| 15 | |
| 16 | type EvmConfig = { |
| 17 | chainName: string |
| 18 | storageAddress: string |
| 19 | calculatorConsumerAddress: string |
| 20 | gasLimit: string |
| 21 | } |
| 22 | |
| 23 | type Config = { |
| 24 | schedule: string |
| 25 | apiUrl: string |
| 26 | evms: EvmConfig[] |
| 27 | } |
| 28 | |
| 29 | // MyResult struct now holds all the outputs of our workflow. |
| 30 | type MyResult = { |
| 31 | offchainValue: bigint |
| 32 | onchainValue: bigint |
| 33 | finalResult: bigint |
| 34 | txHash: string |
| 35 | } |
| 36 | |
| 37 | const initWorkflow = (config: Config) => { |
| 38 | const cron = new cre.capabilities.CronCapability() |
| 39 | |
| 40 | return [cre.handler(cron.trigger({ schedule: config.schedule }), onCronTrigger)] |
| 41 | } |
| 42 | |
| 43 | const onCronTrigger = (runtime: Runtime<Config>): MyResult => { |
| 44 | const evmConfig = runtime.config.evms[0] |
| 45 | |
| 46 | // Convert the human-readable chain name to a chain selector |
| 47 | const network = getNetwork({ |
| 48 | chainFamily: "evm", |
| 49 | chainSelectorName: evmConfig.chainName, |
| 50 | isTestnet: true, |
| 51 | }) |
| 52 | if (!network) { |
| 53 | throw new Error(`Unknown chain name: ${evmConfig.chainName}`) |
| 54 | } |
| 55 | |
| 56 | // Step 1: Fetch offchain data |
| 57 | const offchainValue = runtime.runInNodeMode(fetchMathResult, consensusMedianAggregation())().result() |
| 58 | |
| 59 | runtime.log(`Successfully fetched offchain value: ${offchainValue}`) |
| 60 | |
| 61 | // Step 2: Read onchain data using the EVM client |
| 62 | const evmClient = new cre.capabilities.EVMClient(network.chainSelector.selector) |
| 63 | |
| 64 | const callData = encodeFunctionData({ |
| 65 | abi: Storage, |
| 66 | functionName: "get", |
| 67 | }) |
| 68 | |
| 69 | const contractCall = evmClient |
| 70 | .callContract(runtime, { |
| 71 | call: encodeCallMsg({ |
| 72 | from: zeroAddress, |
| 73 | to: evmConfig.storageAddress as `0x${string}`, |
| 74 | data: callData, |
| 75 | }), |
| 76 | blockNumber: LAST_FINALIZED_BLOCK_NUMBER, |
| 77 | }) |
| 78 | .result() |
| 79 | |
| 80 | const onchainValue = decodeFunctionResult({ |
| 81 | abi: Storage, |
| 82 | functionName: "get", |
| 83 | data: bytesToHex(contractCall.data), |
| 84 | }) as bigint |
| 85 | |
| 86 | runtime.log(`Successfully read onchain value: ${onchainValue}`) |
| 87 | |
| 88 | // Step 3: Calculate the final result |
| 89 | const finalResultValue = onchainValue + offchainValue |
| 90 | |
| 91 | runtime.log(`Final calculated result: ${finalResultValue}`) |
| 92 | |
| 93 | // Step 4: Write the result to the consumer contract |
| 94 | const txHash = updateCalculatorResult( |
| 95 | runtime, |
| 96 | network.chainSelector.selector, |
| 97 | evmConfig, |
| 98 | offchainValue, |
| 99 | onchainValue, |
| 100 | finalResultValue |
| 101 | ) |
| 102 | |
| 103 | // Step 5: Log and return the final, consolidated result. |
| 104 | const finalWorkflowResult: MyResult = { |
| 105 | offchainValue, |
| 106 | onchainValue, |
| 107 | finalResult: finalResultValue, |
| 108 | txHash, |
| 109 | } |
| 110 | |
| 111 | runtime.log( |
| 112 | `Workflow finished successfully! offchainValue: ${offchainValue}, onchainValue: ${onchainValue}, finalResult: ${finalResultValue}, txHash: ${txHash}` |
| 113 | ) |
| 114 | |
| 115 | return finalWorkflowResult |
| 116 | } |
| 117 | |
| 118 | const fetchMathResult = (nodeRuntime: NodeRuntime<Config>): bigint => { |
| 119 | const httpClient = new cre.capabilities.HTTPClient() |
| 120 | |
| 121 | const req = { |
| 122 | url: nodeRuntime.config.apiUrl, |
| 123 | method: "GET" as const, |
| 124 | } |
| 125 | |
| 126 | const resp = httpClient.sendRequest(nodeRuntime, req).result() |
| 127 | const bodyText = new TextDecoder().decode(resp.body) |
| 128 | const val = BigInt(bodyText.trim()) |
| 129 | |
| 130 | return val |
| 131 | } |
| 132 | |
| 133 | // updateCalculatorResult handles the logic for writing data to the CalculatorConsumer contract. |
| 134 | function updateCalculatorResult( |
| 135 | runtime: Runtime<Config>, |
| 136 | chainSelector: bigint, |
| 137 | evmConfig: EvmConfig, |
| 138 | offchainValue: bigint, |
| 139 | onchainValue: bigint, |
| 140 | finalResult: bigint |
| 141 | ): string { |
| 142 | runtime.log(`Updating calculator result for consumer: ${evmConfig.calculatorConsumerAddress}`) |
| 143 | |
| 144 | const evmClient = new cre.capabilities.EVMClient(chainSelector) |
| 145 | |
| 146 | // Encode the CalculatorResult struct according to the contract's ABI |
| 147 | const reportData = encodeAbiParameters( |
| 148 | parseAbiParameters("uint256 offchainValue, int256 onchainValue, uint256 finalResult"), |
| 149 | [offchainValue, onchainValue, finalResult] |
| 150 | ) |
| 151 | |
| 152 | runtime.log( |
| 153 | `Writing report to consumer contract - offchainValue: ${offchainValue}, onchainValue: ${onchainValue}, finalResult: ${finalResult}` |
| 154 | ) |
| 155 | |
| 156 | // Step 1: Generate a signed report using the consensus capability |
| 157 | const reportResponse = runtime |
| 158 | .report({ |
| 159 | encodedPayload: hexToBase64(reportData), |
| 160 | encoderName: "evm", |
| 161 | signingAlgo: "ecdsa", |
| 162 | hashingAlgo: "keccak256", |
| 163 | }) |
| 164 | .result() |
| 165 | |
| 166 | // Step 2: Submit the report to the consumer contract |
| 167 | const writeReportResult = evmClient |
| 168 | .writeReport(runtime, { |
| 169 | receiver: evmConfig.calculatorConsumerAddress, |
| 170 | report: reportResponse, |
| 171 | gasConfig: { |
| 172 | gasLimit: evmConfig.gasLimit, |
| 173 | }, |
| 174 | }) |
| 175 | .result() |
| 176 | |
| 177 | runtime.log("Waiting for write report response") |
| 178 | |
| 179 | const txHash = bytesToHex(writeReportResult.txHash || new Uint8Array(32)) |
| 180 | runtime.log(`Write report transaction succeeded: ${txHash}`) |
| 181 | runtime.log(`View transaction at https://sepolia.etherscan.io/tx/${txHash}`) |
| 182 | return txHash |
| 183 | } |
| 184 | |
| 185 | export async function main() { |
| 186 | const runner = await Runner.newRunner<Config>() |
| 187 | await runner.run(initWorkflow) |
| 188 | } |
| 189 | |
| 190 | main() |
| 191 | |
Key TypeScript SDK features for writing:
encodeAbiParameters(): From Viem, encodes structured data according to a contract's ABIparseAbiParameters(): From Viem, defines the parameter types for encodingruntime.report(): Generates a signed report using the consensus capabilitywriteReport(): EVMClient method for submitting the signed report to a consumer contracttxHash: The transaction hash returned after a successful write operation
Step 4: 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, the simulator runs it automatically.
cre workflow simulate my-calculator-workflow --target staging-settings --broadcast
Your workflow will now show the complete end-to-end execution, including the final log of the MyResult object containing the transaction hash.
Workflow compiled
2025-11-03T19:09:22Z [SIMULATION] Simulator Initialized
2025-11-03T19:09:22Z [SIMULATION] Running trigger trigger=[email protected]
2025-11-03T19:09:22Z [USER LOG] Successfully fetched offchain value: 39
2025-11-03T19:09:22Z [USER LOG] Successfully read onchain value: 22
2025-11-03T19:09:22Z [USER LOG] Final calculated result: 61
2025-11-03T19:09:22Z [USER LOG] Updating calculator result for consumer: 0xF3abEAa889e46c6C5b9A0bD818cE54Cc4eAF8A54
2025-11-03T19:09:22Z [USER LOG] Writing report to consumer contract - offchainValue: 39, onchainValue: 22, finalResult: 61
2025-11-03T19:09:25Z [USER LOG] Waiting for write report response
2025-11-03T19:09:25Z [USER LOG] Write report transaction succeeded: 0xcc99cf4fcdc1262162762f747eeb660b52cc117754c953fdb72842414fcecdc4
2025-11-03T19:09:25Z [USER LOG] View transaction at https://sepolia.etherscan.io/tx/0xcc99cf4fcdc1262162762f747eeb660b52cc117754c953fdb72842414fcecdc4
2025-11-03T19:09:25Z [USER LOG] Workflow finished successfully! offchainValue: 39, onchainValue: 22, finalResult: 61, txHash: 0xcc99cf4fcdc1262162762f747eeb660b52cc117754c953fdb72842414fcecdc4
Workflow Simulation Result:
{
"finalResult": 61,
"offchainValue": 39,
"onchainValue": 22,
"txHash": "0xcc99cf4fcdc1262162762f747eeb660b52cc117754c953fdb72842414fcecdc4"
}
2025-11-03T19:09:25Z [SIMULATION] Execution finished signal received
2025-11-03T19:09:25Z [SIMULATION] Skipping WorkflowEngineV2
Step 5: Verify the result onchain
1. Check the Transaction
In your terminal output, you'll see a clickable URL to view the transaction on Sepolia Etherscan:
[USER LOG] View transaction at https://sepolia.etherscan.io/tx/0x...
Click the URL (or copy and paste it into your browser) to see the full details of the transaction your workflow submitted.
What are you seeing on a blockchain explorer?
You'll notice the transaction's to address is not the CalculatorConsumer contract you intended to call. Instead, it's to a Forwarder contract. Your workflow sends a secure report to the Forwarder, which then verifies the request and makes the final call to the CalculatorConsumer on your workflow's behalf. To learn more, see the Onchain Write guide.
2. Check the contract state
While your wallet interacted with the Forwarder, the CalculatorConsumer contract's state was still updated. You can verify this change directly on Etherscan:
- Navigate to the
CalculatorConsumercontract address:0xF3abEAa889e46c6C5b9A0bD818cE54Cc4eAF8A54. - Expand the
latestResultfunction and click Query. The values should match thefinalResult,offchainValue, andonchainValuefrom your workflow logs.
This completes the end-to-end loop: triggering a workflow, fetching data, reading onchain state, and verifiably writing the result back to a public blockchain.
To learn more about implementing consumer contracts and the secure write process, see these guides:
- Building Consumer Contracts: Learn how to create your own secure consumer contracts with proper validation.
- Onchain Write Guide: Dive deeper into the write patterns.
Next steps
You've now mastered the complete CRE development workflow!
- Conclusion & Next Steps: Review what you've learned and find resources for advanced topics.