Building Consumer Contracts

When your workflow writes data to the blockchain, it doesn't call your contract directly. Instead, it submits a signed report to a Chainlink KeystoneForwarder contract, which then calls your contract.

This guide explains how to build a consumer contract that can securely receive and process data from a CRE workflow.

In this guide:

1. Core Concepts: The Onchain Data Flow

  1. Workflow Execution: Your workflow produces a final, signed report.
  2. EVM Write: The EVM capability sends this report to the Chainlink-managed KeystoneForwarder contract.
  3. Forwarder Validation: The KeystoneForwarder validates the report's signatures.
  4. Callback to Your Contract: If the report is valid, the forwarder calls a designated function (onReport) on your consumer contract to deliver the data.

2. The IReceiver Standard

To be a valid target for the KeystoneForwarder, your consumer contract must satisfy two main requirements:

2.1 Implement the IReceiver Interface

The KeystoneForwarder needs a standardized function to call. This is defined by the IReceiver interface, which mandates an onReport function.

interface IReceiver is IERC165 {
  function onReport(bytes calldata metadata, bytes calldata report) external;
}
  • metadata: Contains information about the workflow (ID, name, owner).
  • report: The raw, ABI-encoded data payload from your workflow.

2.2 Support ERC165 Interface Detection

ERC165 is a standard that allows contracts to publish the interfaces they support. The KeystoneForwarder uses this to check if your contract supports the IReceiver interface before sending a report.

3. Using IReceiverTemplate

3.1 Overview

While you can implement these standards manually, we provide an abstract contract, IReceiverTemplate.sol, that does the heavy lifting for you. Inheriting from it is the recommended best practice.

Key features:

  • Optional Permission Controls: Choose your security level—enable forwarder address checks, workflow ID validation, workflow owner verification, or any combination
  • Flexible and Updatable: All permission settings can be configured and updated via setter functions after deployment
  • Simplified Logic: You only need to implement _processReport(bytes calldata report) with your business logic
  • Built-in Access Control: Includes OpenZeppelin's Ownable for secure permission management
  • ERC165 Support: Includes the necessary supportsInterface function
  • Metadata Access: Helper function to decode workflow ID, name, and owner for custom validation logic

3.2 Contract Source Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {IERC165} from "./IERC165.sol";
import {IReceiver} from "./IReceiver.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

/// @title IReceiverTemplate - Abstract receiver with optional permission controls
/// @notice Provides flexible, updatable security checks for receiving workflow reports
/// @dev All permission fields default to zero (disabled). Use setter functions to enable checks.
abstract contract IReceiverTemplate is IReceiver, Ownable {
    // Optional permission fields (all default to zero = disabled)
    address public forwarderAddress; // If set, only this address can call onReport
    address public expectedAuthor; // If set, only reports from this workflow owner are accepted
    bytes10 public expectedWorkflowName; // If set, only reports with this workflow name are accepted
    bytes32 public expectedWorkflowId; // If set, only reports from this specific workflow ID are accepted

    // Custom errors
    error InvalidSender(address sender, address expected);
    error InvalidAuthor(address received, address expected);
    error InvalidWorkflowName(bytes10 received, bytes10 expected);
    error InvalidWorkflowId(bytes32 received, bytes32 expected);

    /// @notice Constructor sets msg.sender as the owner
    /// @dev All permission fields are initialized to zero (disabled by default)
    constructor() Ownable(msg.sender) {}

    /// @inheritdoc IReceiver
    /// @dev Performs optional validation checks based on which permission fields are set
    function onReport(bytes calldata metadata, bytes calldata report) external override {
        // Security Check 1: Verify caller is the trusted Chainlink Forwarder (if configured)
        if (forwarderAddress != address(0) && msg.sender != forwarderAddress) {
            revert InvalidSender(msg.sender, forwarderAddress);
        }

        // Security Checks 2-4: Verify workflow identity - ID, owner, and/or name (if any are configured)
        if (expectedWorkflowId != bytes32(0) || expectedAuthor != address(0) || expectedWorkflowName != bytes10(0)) {
            (bytes32 workflowId, bytes10 workflowName, address workflowOwner) = _decodeMetadata(metadata);

            if (expectedWorkflowId != bytes32(0) && workflowId != expectedWorkflowId) {
                revert InvalidWorkflowId(workflowId, expectedWorkflowId);
            }
            if (expectedAuthor != address(0) && workflowOwner != expectedAuthor) {
                revert InvalidAuthor(workflowOwner, expectedAuthor);
            }
            if (expectedWorkflowName != bytes10(0) && workflowName != expectedWorkflowName) {
                revert InvalidWorkflowName(workflowName, expectedWorkflowName);
            }
        }

        _processReport(report);
    }

    /// @notice Updates the forwarder address that is allowed to call onReport
    /// @param _forwarder The new forwarder address (use address(0) to disable this check)
    function setForwarderAddress(address _forwarder) external onlyOwner {
        forwarderAddress = _forwarder;
    }

    /// @notice Updates the expected workflow owner address
    /// @param _author The new expected author address (use address(0) to disable this check)
    function setExpectedAuthor(address _author) external onlyOwner {
        expectedAuthor = _author;
    }

    /// @notice Updates the expected workflow name
    /// @param _name The new expected workflow name (use bytes10(0) to disable this check)
    function setExpectedWorkflowName(bytes10 _name) external onlyOwner {
        expectedWorkflowName = _name;
    }

    /// @notice Updates the expected workflow ID
    /// @param _id The new expected workflow ID (use bytes32(0) to disable this check)
    function setExpectedWorkflowId(bytes32 _id) external onlyOwner {
        expectedWorkflowId = _id;
    }

    /// @notice Extracts all metadata fields from the onReport metadata parameter
    /// @param metadata The metadata in bytes format
    /// @return workflowId The unique identifier of the workflow (bytes32)
    /// @return workflowName The name of the workflow (bytes10)
    /// @return workflowOwner The owner address of the workflow
    function _decodeMetadata(bytes memory metadata)
        internal
        pure
        returns (bytes32 workflowId, bytes10 workflowName, address workflowOwner)
    {
        // Metadata structure:
        // - First 32 bytes: length of the byte array (standard for dynamic bytes)
        // - Offset 32, size 32: workflow_id (bytes32)
        // - Offset 64, size 10: workflow_name (bytes10)
        // - Offset 74, size 20: workflow_owner (address)
        assembly {
            workflowId := mload(add(metadata, 32))
            workflowName := mload(add(metadata, 64))
            workflowOwner := shr(mul(12, 8), mload(add(metadata, 74)))
        }
    }

    /// @notice Abstract function to process the report data
    /// @param report The report calldata containing your workflow's encoded data
    /// @dev Implement this function with your contract's business logic
    function _processReport(bytes calldata report) internal virtual;

    /// @inheritdoc IERC165
    function supportsInterface(bytes4 interfaceId) public pure virtual override returns (bool) {
        return interfaceId == type(IReceiver).interfaceId || interfaceId == type(IERC165).interfaceId;
    }
}

3.3 Quick Start

Step 1: Inherit and implement your business logic

The simplest way to use IReceiverTemplate is to inherit from it and implement the _processReport function:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import { IReceiverTemplate } from "./IReceiverTemplate.sol";

contract MyConsumer is IReceiverTemplate {
  uint256 public storedValue;
  event ValueUpdated(uint256 newValue);

  // Simple constructor - no parameters needed
  constructor() IReceiverTemplate() {}

  // Implement your business logic here
  function _processReport(bytes calldata report) internal override {
    uint256 newValue = abi.decode(report, (uint256));
    storedValue = newValue;
    emit ValueUpdated(newValue);
  }
}

3.4 Configuring Security

Step 2: Configure permissions (optional)

After deploying your contract, the owner can enable any combination of security checks using the setter functions.

Configuration examples:

// Example: Enable forwarder check only
myConsumer.setForwarderAddress(0xF8344CFd5c43616a4366C34E3EEE75af79a74482); // Ethereum Sepolia

// Example: Enable workflow ID check
myConsumer.setExpectedWorkflowId(0x1234...); // Your specific workflow ID

// Example: Enable workflow owner and name checks
myConsumer.setExpectedAuthor(0xYourAddress...);
myConsumer.setExpectedWorkflowName(0x6d795f776f726b666c6f77); // "my_workflo" in hex (bytes10 = 10 chars max)

// Example: Disable a check later (set to zero)
myConsumer.setExpectedWorkflowName(bytes10(0));

What the template handles for you:

  • Validates the caller address (if forwarderAddress is set)
  • Validates the workflow ID (if expectedWorkflowId is set)
  • Validates the workflow owner (if expectedAuthor is set)
  • Validates the workflow name (if expectedWorkflowName is set)
  • Validates the ERC165 interface detection
  • Validates the Access control via OpenZeppelin's Ownable
  • Calls your _processReport function with validated data

What you implement:

  • Your business logic in _processReport
  • (Optional) Configure permissions after deployment using setter functions

4. Advanced Usage (Optional)

4.1 Custom Validation Logic

You can override onReport to add your own validation logic before or after the standard checks:

import { IReceiverTemplate } from "./IReceiverTemplate.sol";

contract AdvancedConsumer is IReceiverTemplate {
  uint256 public minReportInterval = 1 hours;
  uint256 public lastReportTime;

  error ReportTooFrequent(uint256 timeSinceLastReport, uint256 minInterval);

  // Add custom validation before parent's checks
  function onReport(bytes calldata metadata, bytes calldata report) external override {
    // Custom check: Rate limiting
    if (block.timestamp < lastReportTime + minReportInterval) {
      revert ReportTooFrequent(block.timestamp - lastReportTime, minReportInterval);
    }

    // Call parent implementation for standard permission checks
    super.onReport(metadata, report);

    lastReportTime = block.timestamp;
  }

  function _processReport(bytes calldata report) internal override {
    // Your business logic here
    uint256 value = abi.decode(report, (uint256));
    // ... store or process the value ...
  }

  // Allow owner to update rate limit
  function setMinReportInterval(uint256 _interval) external onlyOwner {
    minReportInterval = _interval;
  }
}

4.2 Using Metadata Fields in Your Logic

The _decodeMetadata helper function is available for use in your _processReport implementation. This allows you to access workflow metadata for custom business logic:

contract MetadataAwareConsumer is IReceiverTemplate {
  mapping(bytes32 => uint256) public reportCountByWorkflow;

  function _processReport(bytes calldata report) internal override {
    // Access the metadata to get workflow ID
    bytes calldata metadata = msg.data[4:]; // Skip function selector
    (bytes32 workflowId, , ) = _decodeMetadata(metadata);

    // Use workflow ID in your business logic
    reportCountByWorkflow[workflowId]++;

    // Process the report data
    uint256 value = abi.decode(report, (uint256));
    // ... your logic here ...
  }
}

4.3 Working with Simulation

When you run cre workflow simulate, your workflow interacts with a MockForwarder contract that does not provide the workflow_name or workflow_owner metadata. This means consumer contracts with IReceiverTemplate's default validation will fail during simulation.

To test your consumer contract with simulation:

Override the onReport function to bypass validation checks:

function onReport(bytes calldata, bytes calldata report) external override {
  _processReport(report); // Skips validation checks
}

For deployed workflows:

Deployed workflows use the real KeystoneForwarder contract, which provides full metadata. You can enable all permission checks (forwarder address, workflow ID, owner, name) for production deployments.

5. Complete Examples

Example 1: Simple Consumer Contract

This example inherits from IReceiverTemplate to store a temperature value.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import { IReceiverTemplate } from "./IReceiverTemplate.sol";

contract TemperatureConsumer is IReceiverTemplate {
  int256 public currentTemperature;
  event TemperatureUpdated(int256 newTemperature);

  // Simple constructor - no parameters needed
  constructor() IReceiverTemplate() {}

  function _processReport(bytes calldata report) internal override {
    int256 newTemperature = abi.decode(report, (int256));
    currentTemperature = newTemperature;
    emit TemperatureUpdated(newTemperature);
  }
}

Configuring permissions after deployment:

// Enable forwarder check for production
temperatureConsumer.setForwarderAddress(0xF8344CFd5c43616a4366C34E3EEE75af79a74482); // Ethereum Sepolia

// Enable workflow ID check for highest security
temperatureConsumer.setExpectedWorkflowId(0xYourWorkflowId...);

Example 2: The Proxy Pattern

For more complex scenarios, it's best to separate your Chainlink-aware code from your core business logic. The Proxy Pattern is a robust architecture that uses two contracts to achieve this:

  • A Logic Contract: Holds the state and the core functions of your application. It knows nothing about the Forwarder contract or the onReport function.
  • A Proxy Contract: Acts as the secure entry point. It inherits from IReceiverTemplate and forwards validated reports to the Logic Contract.

This separation makes your business logic more modular and reusable.

The Logic Contract (ReserveManager.sol)

This contract, our "vault", holds the state and the updateReserves function. For security, it only accepts calls from its trusted Proxy. It also includes an owner-only function to update the proxy address, making the system upgradeable without requiring a migration.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol";

contract ReserveManager is Ownable {
  struct UpdateReserves {
    uint256 ethPrice;
    uint256 btcPrice;
  }

  address public proxyAddress;
  uint256 public lastEthPrice;
  uint256 public lastBtcPrice;
  uint256 public lastUpdateTime;

  event ReservesUpdated(uint256 ethPrice, uint256 btcPrice, uint256 updateTime);

  modifier onlyProxy() {
    require(msg.sender == proxyAddress, "Caller is not the authorized proxy");
    _;
  }

  constructor() Ownable(msg.sender) {}

  function setProxyAddress(address _proxyAddress) external onlyOwner {
    proxyAddress = _proxyAddress;
  }

  function updateReserves(UpdateReserves memory data) external onlyProxy {
    lastEthPrice = data.ethPrice;
    lastBtcPrice = data.btcPrice;
    lastUpdateTime = block.timestamp;
    emit ReservesUpdated(data.ethPrice, data.btcPrice, block.timestamp);
  }
}

The Proxy Contract (UpdateReservesProxy.sol)

This contract, our "bouncer", is the only contract that interacts with the Chainlink platform. It inherits IReceiverTemplate to validate incoming reports and then calls the ReserveManager.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import { ReserveManager } from "./ReserveManager.sol";
import { IReceiverTemplate } from "./keystone/IReceiverTemplate.sol";

contract UpdateReservesProxy is IReceiverTemplate {
  ReserveManager public s_reserveManager;

  constructor(address reserveManagerAddress) {
    s_reserveManager = ReserveManager(reserveManagerAddress);
  }

  /// @inheritdoc IReceiverTemplate
  function _processReport(bytes calldata report) internal override {
    ReserveManager.UpdateReserves memory updateReservesData = abi.decode(report, (ReserveManager.UpdateReserves));
    s_reserveManager.updateReserves(updateReservesData);
  }
}

Configuring permissions after deployment:

// Enable forwarder check (recommended)
updateReservesProxy.setForwarderAddress(0xF8344CFd5c43616a4366C34E3EEE75af79a74482); // Ethereum Sepolia

// Enable workflow ID check for production (highest security)
updateReservesProxy.setExpectedWorkflowId(0xYourWorkflowId...);

How it Works

The deployment and configuration process involves these steps:

  1. Deploy the Logic Contract: Deploy ReserveManager.sol. The wallet that deploys this contract becomes its owner.
  2. Deploy the Proxy Contract: Deploy UpdateReservesProxy.sol, passing the address of the deployed ReserveManager contract to its constructor.
  3. Link the Contracts: The owner of the ReserveManager contract must call its setProxyAddress function, passing in the address of the UpdateReservesProxy contract. This authorizes the proxy to call the logic contract.
  4. Configure Permissions (Recommended): The owner of the proxy should call setter functions to enable security checks:
    updateReservesProxy.setForwarderAddress(0xF8344CFd5c43616a4366C34E3EEE75af79a74482);
    updateReservesProxy.setExpectedWorkflowId(0xYourWorkflowId...);
    
  5. Configure Workflow: In your workflow's config.json, use the address of the Proxy Contract as the receiver address.
  6. Execution Flow: When your workflow runs:
    • The Chainlink Forwarder calls onReport on your Proxy
    • The Proxy validates the report (forwarder address, workflow ID, etc.)
    • The Proxy's _processReport function calls the updateReserves function on your Logic Contract
    • Because the caller is the trusted proxy, the onlyProxy check passes, and your state is securely updated
  7. (Optional) Upgrade: If you later need to deploy a new proxy, the owner can:
    • Deploy the new proxy contract
    • Call setProxyAddress on the ReserveManager to point it to the new proxy's address
    • Update the workflow configuration to use the new proxy address

End-to-End Sequence

Where to go next?

Now that you know how to build a consumer contract, the next step is to call it from your workflow.

  • Onchain Write: Learn how to use the EVMClient to send data to your new consumer contract.

Get the latest Chainlink content straight to your inbox.