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

  • Generate bindings for a pre-deployed CalculatorConsumer contract
  • 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: Generate the consumer contract binding

You need to create a binding for the consumer contract so your workflow can interact with it.

  1. Add the consumer contract ABI: Create a new file for the consumer contract ABI. From your project root (onchain-calculator/), run:

    touch contracts/evm/src/abi/CalculatorConsumer.abi
    
  2. Add the ABI content: Open contracts/evm/src/abi/CalculatorConsumer.abi and paste the contract's ABI:

     [{"inputs":[],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[{"internalType":"address","name":"received","type":"address"},{"internalType":"address","name":"expected","type":"address"}],"name":"InvalidAuthor","type":"error"},{"inputs":[{"internalType":"address","name":"sender","type":"address"},{"internalType":"address","name":"expected","type":"address"}],"name":"InvalidSender","type":"error"},{"inputs":[{"internalType":"bytes32","name":"received","type":"bytes32"},{"internalType":"bytes32","name":"expected","type":"bytes32"}],"name":"InvalidWorkflowId","type":"error"},{"inputs":[{"internalType":"bytes10","name":"received","type":"bytes10"},{"internalType":"bytes10","name":"expected","type":"bytes10"}],"name":"InvalidWorkflowName","type":"error"},{"inputs":[{"internalType":"address","name":"owner","type":"address"}],"name":"OwnableInvalidOwner","type":"error"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"OwnableUnauthorizedAccount","type":"error"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"previousOwner","type":"address"},{"indexed":true,"internalType":"address","name":"newOwner","type":"address"}],"name":"OwnershipTransferred","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"uint256","name":"resultId","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"finalResult","type":"uint256"}],"name":"ResultUpdated","type":"event"},{"inputs":[],"name":"expectedAuthor","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"expectedWorkflowId","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"expectedWorkflowName","outputs":[{"internalType":"bytes10","name":"","type":"bytes10"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"forwarderAddress","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"components":[{"internalType":"uint256","name":"offchainValue","type":"uint256"},{"internalType":"int256","name":"onchainValue","type":"int256"},{"internalType":"uint256","name":"finalResult","type":"uint256"}],"internalType":"struct CalculatorConsumer.CalculatorResult","name":"_prospectiveResult","type":"tuple"}],"name":"isResultAnomalous","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"latestResult","outputs":[{"internalType":"uint256","name":"offchainValue","type":"uint256"},{"internalType":"int256","name":"onchainValue","type":"int256"},{"internalType":"uint256","name":"finalResult","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes","name":"metadata","type":"bytes"},{"internalType":"bytes","name":"report","type":"bytes"}],"name":"onReport","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"renounceOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"resultCount","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"","type":"uint256"}],"name":"results","outputs":[{"internalType":"uint256","name":"offchainValue","type":"uint256"},{"internalType":"int256","name":"onchainValue","type":"int256"},{"internalType":"uint256","name":"finalResult","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_author","type":"address"}],"name":"setExpectedAuthor","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"_id","type":"bytes32"}],"name":"setExpectedWorkflowId","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes10","name":"_name","type":"bytes10"}],"name":"setExpectedWorkflowName","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"_forwarder","type":"address"}],"name":"setForwarderAddress","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes4","name":"interfaceId","type":"bytes4"}],"name":"supportsInterface","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"pure","type":"function"},{"inputs":[{"internalType":"address","name":"newOwner","type":"address"}],"name":"transferOwnership","outputs":[],"stateMutability":"nonpayable","type":"function"}]
    
  3. Generate the bindings: Run the binding generator to create Go bindings for all ABI files in your project. From your project root (onchain-calculator/), run:

    cre generate-bindings evm
    

    This will generate bindings for both the Storage contract (from Part 3) and the new CalculatorConsumer contract. For each contract, you'll see two files: <ContractName>.go (main binding) and <ContractName>_mock.go (for testing).

    Generated method: The binding generator sees the CalculatorResult struct and creates a WriteReportFromCalculatorResult method in the CalculatorConsumer binding that automatically handles encoding the struct and submitting it onchain.

Step 3: Update your workflow configuration

Add the CalculatorConsumer contract address to your config.staging.json:

{
  "schedule": "0 */1 * * * *",
  "apiUrl": "https://api.mathjs.org/v4/?expr=randomInt(1,101)",
  "evms": [
    {
      "storageAddress": "0xa17CF997C28FF154eDBae1422e6a50BeF23927F4",
      "calculatorConsumerAddress": "0xF3abEAa889e46c6C5b9A0bD818cE54Cc4eAF8A54",
      "chainName": "ethereum-testnet-sepolia",
      "gasLimit": 500000
    }
  ]
}

Step 4: Update your workflow logic

Now modify your workflow to write the final result to the contract. The binding generator creates a WriteReportFrom<StructName> method that automatically handles encoding the CalculatorResult struct.

Replace the entire content of onchain-calculator/my-calculator-workflow/main.go with this final version.

Note: Lines highlighted in green indicate new or modified code compared to Part 3.

onchain-calculator/my-calculator-workflow/main.go
Go
1 //go:build wasip1
2
3 package main
4
5 import (
6 "fmt"
7 "log/slog"
8 "math/big"
9
10 "onchain-calculator/contracts/evm/src/generated/calculator_consumer"
11 "onchain-calculator/contracts/evm/src/generated/storage"
12
13 "github.com/ethereum/go-ethereum/common"
14 "github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm"
15 "github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http"
16 "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron"
17 "github.com/smartcontractkit/cre-sdk-go/cre"
18 "github.com/smartcontractkit/cre-sdk-go/cre/wasm"
19 )
20
21 // The EvmConfig is updated from Part 3 with new fields for the write operation.
22 type EvmConfig struct {
23 ChainName string `json:"chainName"`
24 StorageAddress string `json:"storageAddress"`
25 CalculatorConsumerAddress string `json:"calculatorConsumerAddress"`
26 GasLimit uint64 `json:"gasLimit"`
27 }
28
29 type Config struct {
30 Schedule string `json:"schedule"`
31 ApiUrl string `json:"apiUrl"`
32 Evms []EvmConfig `json:"evms"`
33 }
34
35 // MyResult struct now holds all the outputs of our workflow.
36 type MyResult struct {
37 OffchainValue *big.Int
38 OnchainValue *big.Int
39 FinalResult *big.Int
40 TxHash string
41 }
42
43 func InitWorkflow(config *Config, logger *slog.Logger, secretsProvider cre.SecretsProvider) (cre.Workflow[*Config], error) {
44 return cre.Workflow[*Config]{
45 cre.Handler(cron.Trigger(&cron.Config{Schedule: config.Schedule}), onCronTrigger),
46 }, nil
47 }
48
49 func onCronTrigger(config *Config, runtime cre.Runtime, trigger *cron.Payload) (*MyResult, error) {
50 logger := runtime.Logger()
51 evmConfig := config.Evms[0]
52
53 // Convert the human-readable chain name to a numeric chain selector
54 chainSelector, err := evm.ChainSelectorFromName(evmConfig.ChainName)
55 if err != nil {
56 return nil, fmt.Errorf("invalid chain name: %w", err)
57 }
58
59 // Step 1: Fetch offchain data
60 client := &http.Client{}
61 mathPromise := http.SendRequest(config, runtime, client, fetchMathResult, cre.ConsensusMedianAggregation[*big.Int]())
62 offchainValue, err := mathPromise.Await()
63 if err != nil {
64 return nil, err
65 }
66 logger.Info("Successfully fetched offchain value", "result", offchainValue)
67
68 // Step 2: Read onchain data using the binding for the Storage contract
69 evmClient := &evm.Client{
70 ChainSelector: chainSelector,
71 }
72
73 storageAddress := common.HexToAddress(evmConfig.StorageAddress)
74
75 storageContract, err := storage.NewStorage(evmClient, storageAddress, nil)
76 if err != nil {
77 return nil, fmt.Errorf("failed to create contract instance: %w", err)
78 }
79
80 onchainValue, err := storageContract.Get(runtime, big.NewInt(-3)).Await()
81 if err != nil {
82 return nil, fmt.Errorf("failed to read onchain value: %w", err)
83 }
84 logger.Info("Successfully read onchain value", "result", onchainValue)
85
86 // Step 3: Calculate the final result
87 finalResultInt := new(big.Int).Add(onchainValue, offchainValue)
88
89 logger.Info("Final calculated result", "result", finalResultInt)
90
91 // Step 4: Write the result to the consumer contract
92 txHash, err := updateCalculatorResult(config, runtime, chainSelector, evmConfig, offchainValue, onchainValue, finalResultInt)
93 if err != nil {
94 return nil, fmt.Errorf("failed to update calculator result: %w", err)
95 }
96
97 // Step 5: Log and return the final, consolidated result.
98 finalWorkflowResult := &MyResult{
99 OffchainValue: offchainValue,
100 OnchainValue: onchainValue,
101 FinalResult: finalResultInt,
102 TxHash: txHash,
103 }
104
105 logger.Info("Workflow finished successfully!", "result", finalWorkflowResult)
106
107 return finalWorkflowResult, nil
108 }
109
110 func fetchMathResult(config *Config, logger *slog.Logger, sendRequester *http.SendRequester) (*big.Int, error) {
111 req := &http.Request{Url: config.ApiUrl, Method: "GET"}
112 resp, err := sendRequester.SendRequest(req).Await()
113 if err != nil {
114 return nil, fmt.Errorf("failed to get API response: %w", err)
115 }
116 // The mathjs.org API returns the result as a raw string in the body.
117 // We need to parse it into a number.
118 val, ok := new(big.Int).SetString(string(resp.Body), 10)
119 if !ok {
120 return nil, fmt.Errorf("failed to parse API response into big.Int")
121 }
122 return val, nil
123 }
124
125 // updateCalculatorResult handles the logic for writing data to the CalculatorConsumer contract.
126 func updateCalculatorResult(config *Config, runtime cre.Runtime, chainSelector uint64, evmConfig EvmConfig, offchainValue *big.Int, onchainValue *big.Int, finalResult *big.Int) (string, error) {
127 logger := runtime.Logger()
128 logger.Info("Updating calculator result", "consumerAddress", evmConfig.CalculatorConsumerAddress)
129
130 evmClient := &evm.Client{
131 ChainSelector: chainSelector,
132 }
133
134 // Create a contract binding instance pointed at the CalculatorConsumer address.
135 consumerAddress := common.HexToAddress(evmConfig.CalculatorConsumerAddress)
136
137 consumerContract, err := calculator_consumer.NewCalculatorConsumer(evmClient, consumerAddress, nil)
138 if err != nil {
139 return "", fmt.Errorf("failed to create consumer contract instance: %w", err)
140 }
141
142 gasConfig := &evm.GasConfig{
143 GasLimit: evmConfig.GasLimit,
144 }
145
146 logger.Info("Writing report to consumer contract", "offchainValue", offchainValue, "onchainValue", onchainValue, "finalResult", finalResult)
147 // Call the `WriteReport` method on the binding. This sends a secure report to the consumer.
148 writeReportPromise := consumerContract.WriteReportFromCalculatorResult(runtime, calculator_consumer.CalculatorResult{
149 OffchainValue: offchainValue,
150 OnchainValue: onchainValue,
151 FinalResult: finalResult,
152 }, gasConfig)
153
154 logger.Info("Waiting for write report response")
155 resp, err := writeReportPromise.Await()
156 if err != nil {
157 logger.Error("WriteReport await failed", "error", err)
158 return "", fmt.Errorf("failed to await write report: %w", err)
159 }
160 txHash := fmt.Sprintf("0x%x", resp.TxHash)
161 logger.Info("Write report transaction succeeded", "txHash", txHash)
162 logger.Info("View transaction at", "url", fmt.Sprintf("https://sepolia.etherscan.io/tx/%s", txHash))
163 return txHash, nil
164 }
165
166 func main() {
167 wasm.NewRunner(cre.ParseJSON[Config]).Run(InitWorkflow)
168 }

Step 5: Sync your dependencies

Because the main.go file has been updated to import new packages for the CalculatorConsumer binding, you must sync your dependencies.

Run go mod tidy to automatically download the new dependencies and update your go.mod and go.sum files.

go mod tidy

Step 6: 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 struct containing the transaction hash.

Workflow compiled
2025-11-03T22:48:41Z [SIMULATION] Simulator Initialized

2025-11-03T22:48:41Z [SIMULATION] Running trigger trigger=[email protected]
2025-11-03T22:48:41Z [USER LOG] msg="Successfully fetched offchain value" result=56
2025-11-03T22:48:41Z [USER LOG] msg="Successfully read onchain value" result=22
2025-11-03T22:48:41Z [USER LOG] msg="Final calculated result" result=78
2025-11-03T22:48:41Z [USER LOG] msg="Updating calculator result" consumerAddress=0xF3abEAa889e46c6C5b9A0bD818cE54Cc4eAF8A54
2025-11-03T22:48:41Z [USER LOG] msg="Writing report to consumer contract" offchainValue=56 onchainValue=22 finalResult=78
2025-11-03T22:48:41Z [USER LOG] msg="Waiting for write report response"
2025-11-03T22:48:48Z [USER LOG] msg="Write report transaction succeeded" txHash=0x86a26f848c83f37b8eace8123ec275a0af9d21b23b1fbba9cc7664b7e474314f
2025-11-03T22:48:48Z [USER LOG] msg="View transaction at" url=https://sepolia.etherscan.io/tx/0x86a26f848c83f37b8eace8123ec275a0af9d21b23b1fbba9cc7664b7e474314f
2025-11-03T22:48:48Z [USER LOG] msg="Workflow finished successfully!" result="&{OffchainValue:+56 OnchainValue:+22 FinalResult:+78 TxHash:0x86a26f848c83f37b8eace8123ec275a0af9d21b23b1fbba9cc7664b7e474314f}"

Workflow Simulation Result:
 {
  "FinalResult": 78,
  "OffchainValue": 56,
  "OnchainValue": 22,
  "TxHash": "0x86a26f848c83f37b8eace8123ec275a0af9d21b23b1fbba9cc7664b7e474314f"
}

2025-11-03T22:48:48Z [SIMULATION] Execution finished signal received
2025-11-03T22:48:48Z [SIMULATION] Skipping WorkflowEngineV2
  • [USER LOG]: You can see all of your logger.Info() calls showing the complete workflow execution, including the offchain value (result=56), onchain value (result=22), final calculation (result=78), and the transaction hash.
  • [SIMULATION]: These are system-level messages from the simulator showing its internal state.
  • Workflow Simulation Result: This is the final return value of your workflow. The MyResult struct contains all the values (56 + 22 = 78) and the transaction hash confirming the write operation succeeded.

Step 7: 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] msg="View transaction at" url=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 CalculatorConsumer contract address: 0xF3abEAa889e46c6C5b9A0bD818cE54Cc4eAF8A54.
  • Expand the latestResult function and click Query. The values should match the finalResult, offchainValue, and onchainValue from 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:

Next steps

You've now mastered the complete CRE development workflow!

Get the latest Chainlink content straight to your inbox.