Onchain Write

This guide explains how to write data from your CRE workflow to a smart contract on the blockchain.

What you'll learn:

  • How CRE's secure write mechanism works (and why it's different from traditional web3)
  • What a consumer contract is and why you need one
  • Which approach to use based on your specific use case
  • How to construct Solidity-compatible types in Go

Understanding how CRE writes work

Before diving into code, it's important to understand how CRE handles onchain writes differently than traditional web3 applications.

Why CRE doesn't write directly to your contract

In a traditional web3 app, you'd create a transaction and send it directly to your smart contract. CRE uses a different, more secure approach for three key reasons:

  1. Decentralization: Multiple nodes in the Decentralized Oracle Network (DON) need to agree on what data to write
  2. Verification: The blockchain needs cryptographic proof that the data came from a trusted Chainlink network
  3. Accountability: There must be a verifiable trail showing which workflow and owner created the data

The secure write flow (4 steps)

Here's the journey your workflow's data takes to reach the blockchain:

  1. Report generation: Your workflow generates a report— your data is ABI-encoded and wrapped in a cryptographically signed "package"
  2. DON consensus: The DON reaches consensus on the report's contents
  3. Forwarder submission: A designated node submits the report to a Chainlink KeystoneForwarder contract
  4. Delivery to your contract: The Forwarder validates the report's signatures and calls your consumer contract's onReport() function with the data

Your workflow code handles this process using the evm.Client, which manages the interaction with the Forwarder contract. Depending on your approach (covered below), this can be fully automated via generated binding helpers or done manually with direct client calls.

Where reports can go after generation

The same signed report from runtime.GenerateReport() can be delivered in different ways:

DestinationGuideVerification
Smart contract (via Forwarder)This section + Submitting Reports OnchainOnchain in KeystoneForwarder
HTTP APISubmitting Reports via HTTPVerifying CRE Reports Offchain on the receiver

See API Interactions: CRE reports over HTTP for the sender → receiver flow.

What you need: A consumer contract

Before you can write data onchain, you need a consumer contract. This is the smart contract that will receive your workflow's data.

What is a consumer contract?

A consumer contract is your smart contract that implements the IReceiver interface. This interface defines an onReport() function that the Chainlink Forwarder calls to deliver your workflow's data.

Think of it as a mailbox that's designed to receive packages (reports) from Chainlink's secure delivery service (the Forwarder contract).

Key requirement:

Your contract must implement the IReceiver interface. This single requirement ensures your contract has the necessary onReport(bytes metadata, bytes report) function that the Chainlink Forwarder calls to deliver data.

Getting started:

  • Don't have a consumer contract yet? Follow the Building Consumer Contracts guide to create one.
  • Already have one deployed? Great! Make sure you have its address ready. Depending on which approach you choose (see below), you may also need the contract's ABI to generate bindings.

Choosing your approach: Which guide should you follow?

Now that you have a consumer contract, the next step depends on what type of data you're sending and what's available in your contract's ABI. This determines whether you can use the easy automated approach or need to encode data manually.

Use this table to find the guide that matches your needs:

Your scenario
What you have
Recommended approachWhere to go
Write a struct onchainStruct is in the ABI(*)Use the WriteReportFrom<Struct> binding helperUsing WriteReportFrom Helpers
Write a struct onchainStruct is NOT in the ABI(*)
  • Manual tuple encoding
  • Report generation
  • Report submission
Write a single value onchainNeed to send one uint256, address, bool, etc.
  • Manual ABI encoding
  • Report generation
  • Report submission
Already have a generated report and need to submit it onchainA report from runtime.GenerateReport()Manual submission with evm.ClientSubmitting Reports Onchain

(*) When is a struct included in the ABI?

Your contract's ABI includes a struct's definition if that struct is used anywhere in the signature (as a parameter or a return value) of a public or external function.

For example, this contract's ABI will include the CalculatorResult struct:

contract MyConsumerContract {
  struct CalculatorResult {
    uint256 offchainValue;
    int256 onchainValue;
    uint256 finalResult;
  }

  // The struct is used as a parameter in a public function - it WILL be in the ABI
  function isResultAnomalous(CalculatorResult memory _prospectiveResult) public view returns (bool) {
    // ...
  }

  // The struct is used as a return value in a public function - it WILL also be in the ABI
  function getSampleResult() public pure returns (CalculatorResult memory) {
    return CalculatorResult(1, 2, 3);
  }

  // ...
}

Why does this matter? When you compile your contract, only public and external functions and their signatures are included in the ABI file. If a struct is part of that signature, its definition is also included so that external applications know how to encode and decode it. The CRE binding generator reads the ABI and creates helper methods for any structs it finds there.

What if my struct is only used internally? If your struct is only used in internal/private functions, or only used via abi.decode inside functions that take bytes, it won't be in the ABI. In that case, use the Generating Reports: Structs guide for manual encoding.

Working with Solidity input types

Before writing data to a contract, you often need to convert or construct values from your workflow's configuration and logic into the types that Solidity expects. This section explains the common type conversions you'll encounter when preparing your data.

Converting strings to addresses

Contract addresses are typically stored as strings in your config.json file. To use them with generated bindings, convert them to common.Address:

import "github.com/ethereum/go-ethereum/common"

// From a config string
contractAddress := common.HexToAddress(config.ProxyAddress)
// contractAddress is now a common.Address

// Use it directly with bindings
contract, err := my_contract.NewMyContract(evmClient, contractAddress, nil)

Creating big.Int values

All Solidity integer types (uint8, uint256, int8, int256, etc.) map to Go's *big.Int. Here are the common ways to create them:

From an integer literal:

import "math/big"

// For small values, use big.NewInt()
gasLimit := big.NewInt(1000000)
amount := big.NewInt(100)

From a string (for large numbers):

// For values too large for int64, parse from a string
largeAmount := new(big.Int)
largeAmount.SetString("1000000000000000000000000", 10) // Base 10

// Or in one line
value, ok := new(big.Int).SetString("123456789", 10)
if !ok {
    return fmt.Errorf("failed to parse big.Int")
}

From calculations:

// Arithmetic with big.Int
a := big.NewInt(100)
b := big.NewInt(50)

sum := new(big.Int).Add(a, b)
product := new(big.Int).Mul(a, b)

From random numbers:

// Get the runtime's random generator
rnd, err := runtime.Rand()
if err != nil {
    return err
}

// Generate a random big.Int in range [0, max)
max := big.NewInt(1000)
randomValue := new(big.Int).Rand(rnd, max)

Note: For a complete understanding of how randomness works in CRE, including the difference between DON mode and Node mode randomness, see Using Randomness in Workflows.

Constructing input structs

When your contract method takes parameters, you'll need to construct the input struct generated by the bindings. The binding generator creates a struct type for each method that has parameters.

// Example: For a method that takes (address owner, address spender)
// The generator creates an AllowanceInput struct
allowanceInput := ierc20.AllowanceInput{
    Owner:   common.HexToAddress("0xOwnerAddress"),
    Spender: common.HexToAddress("0xSpenderAddress"),
}
// This struct can now be passed to the corresponding method

Working with bytes

Solidity types like bytes and bytes32 map to []byte in Go.

Inspecting onchain transactions

When your workflow submits a report onchain, the transaction can fail in two distinct ways: the transaction itself can revert (for example, out of gas or an invalid receiver), or the transaction can succeed but your consumer contract's onReport() function can revert during execution. You should inspect both outcomes and decide how to respond.

Understanding the response

evm.Client.WriteReport() returns an evm.WriteReportReply with two status fields you should check:

FieldTypeMeaning
TxStatusTxStatusWhether the transaction itself succeeded: SUCCESS, REVERTED, or FATAL.
ReceiverContractExecutionStatus*ReceiverContractExecutionStatusWhether your consumer contract's onReport() executed successfully: SUCCESS or REVERTED.
TxHash[]byteThe 32-byte transaction hash, useful for looking up the transaction on a block explorer.
ErrorMessage*stringAn error message if the transaction failed.

Important: TxStatus and ReceiverContractExecutionStatus are independent. A transaction can succeed (TxStatus == SUCCESS) while the consumer contract's onReport() reverts (ReceiverContractExecutionStatus == REVERTED). Always check both.

How to know if onReport() succeeded

The ReceiverContractExecutionStatus field tells you whether your consumer contract's onReport() function executed successfully. Check it after every write and log the result so you can monitor and troubleshoot deliveries:

resp, err := writePromise.Await()
if err != nil {
    return fmt.Errorf("failed to write report: %w", err)
}

// Always log the transaction hash and both statuses
txHash := fmt.Sprintf("0x%x", resp.TxHash)
logger.Info("Write report response",
    "txHash", txHash,
    "txStatus", resp.TxStatus.String(),
    "receiverStatus", resp.ReceiverContractExecutionStatus.String(),
)

// Check the transaction status first
if resp.TxStatus != evm.TxStatus_TX_STATUS_SUCCESS {
    errorMsg := "unknown error"
    if resp.ErrorMessage != nil {
        errorMsg = *resp.ErrorMessage
    }
    return fmt.Errorf("transaction failed with status %v: %s", resp.TxStatus, errorMsg)
}

// Then check whether onReport() executed successfully
if resp.ReceiverContractExecutionStatus != nil &&
    *resp.ReceiverContractExecutionStatus != evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_SUCCESS {
    return fmt.Errorf("onReport() reverted with status %v", *resp.ReceiverContractExecutionStatus)
}

Retry and reporting example

The following example shows a complete pattern for inspecting a write, logging the outcome, and retrying when the transaction or the consumer contract execution fails:

const maxRetries = 3

func submitReport(runtime cre.Runtime, evmClient *evm.Client, receiver common.Address, report *cre.Report, attempt int) error {
    logger := runtime.Logger()

    writePromise := evmClient.WriteReport(runtime, &evm.WriteCreReportRequest{
        Receiver: receiver.Bytes(),
        Report:   report,
    })

    resp, err := writePromise.Await()
    if err != nil {
        logger.Error("WriteReport await failed", "error", err)
        return err
    }

    txHash := fmt.Sprintf("0x%x", resp.TxHash)
    logger.Info("Write report response",
        "txHash", txHash,
        "txStatus", resp.TxStatus.String(),
        "receiverStatus", resp.ReceiverContractExecutionStatus.String(),
    )

    // Retry on transaction failure
    if resp.TxStatus != evm.TxStatus_TX_STATUS_SUCCESS {
        errorMsg := "unknown error"
        if resp.ErrorMessage != nil {
            errorMsg = *resp.ErrorMessage
        }
        logger.Error("Transaction failed, retrying", "status", resp.TxStatus.String(), "error", errorMsg)
        return retrySubmit(runtime, evmClient, receiver, report, attempt)
    }

    // Retry if onReport() reverted even though the transaction succeeded
    if resp.ReceiverContractExecutionStatus != nil &&
        *resp.ReceiverContractExecutionStatus != evm.ReceiverContractExecutionStatus_RECEIVER_CONTRACT_EXECUTION_STATUS_SUCCESS {
        logger.Error("onReport() reverted, retrying", "status", resp.ReceiverContractExecutionStatus.String())
        return retrySubmit(runtime, evmClient, receiver, report, attempt)
    }

    logger.Info("Report delivered successfully", "txHash", txHash)
    return nil
}

func retrySubmit(runtime cre.Runtime, evmClient *evm.Client, receiver common.Address, report *cre.Report, attempt int) error {
    if attempt >= maxRetries {
        return fmt.Errorf("report delivery failed after %d attempts", maxRetries)
    }
    logger := runtime.Logger()
    logger.Info("Retrying submission", "attempt", attempt+1, "maxRetries", maxRetries)
    // Add a delay here if your runtime supports it. Be careful about replay attacks — see below.
    return submitReport(runtime, evmClient, receiver, report, attempt+1)
}

Inspecting executions with the CLI

Once your workflow is deployed, you can inspect its executions programmatically with the CRE CLI. All commands support --output json for scripting. You can also view the same information in the CRE Workflows dashboard.

Workflow-level inspection:

# List all workflows for your organization
cre workflow list

# Deployment health + most recent execution for a workflow
cre workflow get ./my-workflow --target production-settings

Execution-level inspection:

# List executions (optionally filtered by workflow, status, or time range)
cre execution list evm-write-inspection
cre execution list evm-write-inspection --status FAILURE
cre execution list evm-write-inspection --limit 50 --output json

# Detailed status of a single execution (incl. top-level errors)
cre execution status <execution-uuid>

# Capability event timeline (per-event status, method, duration, errors)
cre execution events <execution-uuid>

# User-emitted log lines (e.g. your "Write report response" logs)
cre execution logs <execution-uuid>

Control commands:

# Pause / resume a workflow to stop or start trigger execution
cre workflow pause ./my-workflow --target production-settings --yes
cre workflow activate ./my-workflow --target production-settings --yes

Learn more

Get the latest Chainlink content straight to your inbox.