# Building Consumer Contracts
Source: https://docs.chain.link/cre/guides/workflow/using-evm-client/onchain-write/building-consumer-contracts
Last Updated: 2026-02-03

> For the complete documentation index, see [llms.txt](/llms.txt).

When your workflow [writes data to the blockchain](/cre/guides/workflow/using-evm-client/onchain-write), 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-core-concepts-the-onchain-data-flow)
2. [The IReceiver Standard](#2-the-ireceiver-standard)
3. [Using ReceiverTemplate](#3-using-receivertemplate)
4. [Working with Simulation](#4-working-with-simulation)
5. [Advanced Usage](#5-advanced-usage-optional)
6. [Complete Examples](#6-complete-examples)
7. [Security Considerations](#7-security-considerations)

## 1. Core Concepts: The Onchain Data Flow

1. **Workflow Execution**: Your workflow [produces a final, signed report](/cre/guides/workflow/using-evm-client/onchain-write/writing-data-onchain).
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.

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

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

/// @title IReceiver - receives keystone reports
/// @notice Implementations must support the IReceiver interface through ERC165.
interface IReceiver is IERC165 {
  /// @notice Handles incoming keystone reports.
  /// @dev If this function call reverts, it can be retried with a higher gas
  /// limit. The receiver is responsible for discarding stale reports.
  /// @param metadata Report's metadata.
  /// @param report Workflow report.
  function onReport(
    bytes calldata metadata,
    bytes calldata report
  ) external;
}
```

- `metadata`: Contains information about the workflow (ID, name, owner). This is encoded by the Forwarder using `abi.encodePacked` with the following structure: `bytes32 workflowId`, `bytes10 workflowName`, `address workflowOwner`.
- `report`: The raw, ABI-encoded data payload from your workflow.

### 2.2 Support ERC165 Interface Detection

[ERC165](https://eips.ethereum.org/EIPS/eip-165) 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.

Link to the `IERC165` interface: [IERC165.sol](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/introspection/IERC165.sol)

## 3. Using `ReceiverTemplate`

### 3.1 Overview

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

**Key features:**

- **Secure by Default**: Requires forwarder address at deployment, ensuring your contract is protected from the start
- **Layered Security**: Add optional workflow ID validation, workflow owner verification, or any combination for defense-in-depth
- **Flexible Configuration**: All permission settings can be 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

```sol
// 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 ReceiverTemplate - Abstract receiver with optional permission controls
/// @notice Provides flexible, updatable security checks for receiving workflow reports
/// @dev The forwarder address is required at construction time for security.
///      Additional permission fields can be configured using setter functions.
abstract contract ReceiverTemplate is IReceiver, Ownable {
  // Required permission field at deployment, configurable after
  address private s_forwarderAddress; // If set, only this address can call onReport

  // Optional permission fields (all default to zero = disabled)
  address private s_expectedAuthor; // If set, only reports from this workflow owner are accepted
  bytes10 private s_expectedWorkflowName; // Only validated when s_expectedAuthor is also set
  bytes32 private s_expectedWorkflowId; // If set, only reports from this specific workflow ID are accepted

  // Hex character lookup table for bytes-to-hex conversion
  bytes private constant HEX_CHARS = "0123456789abcdef";

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

  // Events
  event ForwarderAddressUpdated(address indexed previousForwarder, address indexed newForwarder);
  event ExpectedAuthorUpdated(address indexed previousAuthor, address indexed newAuthor);
  event ExpectedWorkflowNameUpdated(bytes10 indexed previousName, bytes10 indexed newName);
  event ExpectedWorkflowIdUpdated(bytes32 indexed previousId, bytes32 indexed newId);
  event SecurityWarning(string message);

  /// @notice Constructor sets msg.sender as the owner and configures the forwarder address
  /// @param _forwarderAddress The address of the Chainlink Forwarder contract (cannot be address(0))
  /// @dev The forwarder address is required for security - it ensures only verified reports are processed
  constructor(
    address _forwarderAddress
  ) Ownable(msg.sender) {
    if (_forwarderAddress == address(0)) {
      revert InvalidForwarderAddress();
    }
    s_forwarderAddress = _forwarderAddress;
    emit ForwarderAddressUpdated(address(0), _forwarderAddress);
  }

  /// @notice Returns the configured forwarder address
  /// @return The forwarder address (address(0) if disabled)
  function getForwarderAddress() external view returns (address) {
    return s_forwarderAddress;
  }

  /// @notice Returns the expected workflow author address
  /// @return The expected author address (address(0) if not set)
  function getExpectedAuthor() external view returns (address) {
    return s_expectedAuthor;
  }

  /// @notice Returns the expected workflow name
  /// @return The expected workflow name (bytes10(0) if not set)
  function getExpectedWorkflowName() external view returns (bytes10) {
    return s_expectedWorkflowName;
  }

  /// @notice Returns the expected workflow ID
  /// @return The expected workflow ID (bytes32(0) if not set)
  function getExpectedWorkflowId() external view returns (bytes32) {
    return s_expectedWorkflowId;
  }

  /// @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 (s_forwarderAddress != address(0) && msg.sender != s_forwarderAddress) {
      revert InvalidSender(msg.sender, s_forwarderAddress);
    }

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

      if (s_expectedWorkflowId != bytes32(0) && workflowId != s_expectedWorkflowId) {
        revert InvalidWorkflowId(workflowId, s_expectedWorkflowId);
      }
      if (s_expectedAuthor != address(0) && workflowOwner != s_expectedAuthor) {
        revert InvalidAuthor(workflowOwner, s_expectedAuthor);
      }

      // ================================================================
      // WORKFLOW NAME VALIDATION - REQUIRES AUTHOR VALIDATION
      // ================================================================
      // Do not rely on workflow name validation alone. Workflow names are unique
      // per owner, but not across owners.
      // Furthermore, workflow names use 40-bit truncation (bytes10), making collisions possible.
      // Therefore, workflow name validation REQUIRES author (workflow owner) validation.
      // The code enforces this dependency at runtime.
      // ================================================================
      if (s_expectedWorkflowName != bytes10(0)) {
        // Author must be configured if workflow name is used
        if (s_expectedAuthor == address(0)) {
          revert WorkflowNameRequiresAuthorValidation();
        }
        // Validate workflow name matches (author already validated above)
        if (workflowName != s_expectedWorkflowName) {
          revert InvalidWorkflowName(workflowName, s_expectedWorkflowName);
        }
      }
    }

    _processReport(report);
  }

  /// @notice Updates the forwarder address that is allowed to call onReport
  /// @param _forwarder The new forwarder address
  /// @dev WARNING: Setting to address(0) disables forwarder validation.
  ///      This makes your contract INSECURE - anyone can call onReport() with arbitrary data.
  ///      Only use address(0) if you fully understand the security implications.
  function setForwarderAddress(
    address _forwarder
  ) external onlyOwner {
    address previousForwarder = s_forwarderAddress;

    // Emit warning if disabling forwarder check
    if (_forwarder == address(0)) {
      emit SecurityWarning("Forwarder address set to zero - contract is now INSECURE");
    }

    s_forwarderAddress = _forwarder;
    emit ForwarderAddressUpdated(previousForwarder, _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 {
    address previousAuthor = s_expectedAuthor;
    s_expectedAuthor = _author;
    emit ExpectedAuthorUpdated(previousAuthor, _author);
  }

  /// @notice Updates the expected workflow name from a plaintext string
  /// @param _name The workflow name as a string (use empty string "" to disable this check)
  /// @dev IMPORTANT: Workflow name validation REQUIRES author validation to be enabled.
  ///      The workflow name uses only 40-bit truncation, making collision attacks feasible
  ///      when used alone. However, since workflow names are unique per owner, validating
  ///      both the name AND the author address provides adequate security.
  ///      You must call setExpectedAuthor() before or after calling this function.
  ///      The name is hashed using SHA256 and truncated to bytes10.
  function setExpectedWorkflowName(
    string calldata _name
  ) external onlyOwner {
    bytes10 previousName = s_expectedWorkflowName;

    if (bytes(_name).length == 0) {
      s_expectedWorkflowName = bytes10(0);
      emit ExpectedWorkflowNameUpdated(previousName, bytes10(0));
      return;
    }

    // Convert workflow name to bytes10:
    // SHA256 hash → hex encode → take first 10 chars → hex encode those chars
    bytes32 hash = sha256(bytes(_name));
    bytes memory hexString = _bytesToHexString(abi.encodePacked(hash));
    bytes memory first10 = new bytes(10);
    for (uint256 i = 0; i < 10; i++) {
      first10[i] = hexString[i];
    }
    s_expectedWorkflowName = bytes10(first10);
    emit ExpectedWorkflowNameUpdated(previousName, s_expectedWorkflowName);
  }

  /// @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 {
    bytes32 previousId = s_expectedWorkflowId;
    s_expectedWorkflowId = _id;
    emit ExpectedWorkflowIdUpdated(previousId, _id);
  }

  /// @notice Helper function to convert bytes to hex string
  /// @param data The bytes to convert
  /// @return The hex string representation
  function _bytesToHexString(
    bytes memory data
  ) private pure returns (bytes memory) {
    bytes memory hexString = new bytes(data.length * 2);

    for (uint256 i = 0; i < data.length; i++) {
      hexString[i * 2] = HEX_CHARS[uint8(data[i] >> 4)];
      hexString[i * 2 + 1] = HEX_CHARS[uint8(data[i] & 0x0f)];
    }

    return hexString;
  }

  /// @notice Extracts all metadata fields from the onReport metadata parameter
  /// @param metadata The metadata bytes encoded using abi.encodePacked(workflowId, workflowName, workflowOwner)
  /// @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 (encoded using abi.encodePacked by the Forwarder):
    // - 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)))
    }
    return (workflowId, workflowName, workflowOwner);
  }

  /// @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 view virtual override returns (bool) {
    return interfaceId == type(IReceiver).interfaceId || interfaceId == type(IERC165).interfaceId;
  }
}
```

### 3.3 Quick Start

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

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

contract MyConsumer is ReceiverTemplate {
  uint256 public s_storedValue;
  event ValueUpdated(uint256 newValue);

  // Constructor requires forwarder address
  constructor(
    address _forwarderAddress
  ) ReceiverTemplate(_forwarderAddress) {}

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

### 3.4 Configuring Permissions

The forwarder address is configured at deployment via the constructor and provides your first line of defense. After deploying your contract, the owner can configure additional security checks or update the forwarder address if needed.

> **CAUTION: For simulation**
>
> When using `cre workflow simulate`, **do not configure metadata-based validation checks** (`setExpectedWorkflowId`, `setExpectedAuthor`, `setExpectedWorkflowName`). The simulation uses a `MockForwarder` that doesn't provide this metadata. See [Working with Simulation](#4-working-with-simulation) for details.

> **TIP: Finding forwarder addresses**
>
> For a complete list of `KeystoneForwarder` and `MockForwarder` contract addresses on all supported networks, see [Forwarder Directory](/cre/guides/workflow/using-evm-client/forwarder-directory).

**Configuration examples:**

```solidity
// Example: Update forwarder address (e.g., when moving from simulation to production)
myConsumer.setForwarderAddress(0xF8344CFd5c43616a4366C34E3EEE75af79a74482); // Ethereum Sepolia KeystoneForwarder

// Example: Add workflow ID check for additional security
myConsumer.setExpectedWorkflowId(0x1234...); // Your specific workflow ID

// Example: Add workflow owner check
myConsumer.setExpectedAuthor(0xYourAddress...);

// Example: Add workflow name check (requires author validation to be set)
myConsumer.setExpectedWorkflowName("my_workflow");

// Example: Disable a check later
myConsumer.setExpectedWorkflowName(""); // Empty string disables the check
```

> **TIP: Recommended production setup**
>
> The forwarder address is required at deployment and provides basic security. For production contracts, we strongly recommend adding additional validation:

- Use `setExpectedWorkflowId()` if only one workflow writes to your contract (highest security)
- Use `setExpectedAuthor()` if multiple workflows from the same owner write to your contract

**What the template handles for you:**

- Validates the caller address against the configured forwarder (required at deployment)
- Validates the workflow ID (if `expectedWorkflowId` is configured)
- Validates the workflow owner (if `expectedAuthor` is configured)
- Validates the workflow name (if both `expectedWorkflowName` AND `expectedAuthor` are configured)
- Implements ERC165 interface detection
- Provides access control via OpenZeppelin's `Ownable`
- Calls your `_processReport` function with validated data

**What you implement:**

- Pass the forwarder address to the constructor during deployment
- Your business logic in `_processReport`
- (Optional) Configure additional permissions after deployment using setter functions

#### How workflow names are encoded

The `workflowName` field in the metadata uses the **`bytes10`** type rather than plaintext strings. When you call `setExpectedWorkflowName("my_workflow")`, the `ReceiverTemplate` automatically encodes it using the same algorithm as the CRE engine:

1. Compute SHA256 hash of the workflow name
2. Convert hash to hex string (64 characters)
3. Take the first 10 hex characters (e.g., `"b76f3ae1de"`)
4. Hex-encode those 10 ASCII characters to get `bytes10` (20 hex characters / 10 bytes)

**Example:** `"my_workflow"` → SHA256 → `"b76f3ae1de..."` → hex-encode → `0x62373666336165316465`

This encoding ensures consistent, fixed-size representation regardless of the original workflow name length.

> **CAUTION: Workflow name validation requires author validation**
>
> Workflow name validation is **only performed when author validation is also configured**. The code enforces this at runtime: if you set `expectedWorkflowName`, you must also set `expectedAuthor`, otherwise the validation will revert with `WorkflowNameRequiresAuthorValidation()`. This prevents the 40-bit collision attack by ensuring workflow names are validated in combination with the owner address. See [Security Considerations](#7-security-considerations) for details.

**Usage:**

```solidity
// Set the expected author first (required)
myConsumer.setExpectedAuthor(0xYourAddress...);

// Then set the expected workflow name (only works with author validation)
myConsumer.setExpectedWorkflowName("my_workflow");

// To disable the workflow name check
myConsumer.setExpectedWorkflowName(""); // Empty string clears the stored value
```

## 4. Working with Simulation

When you run `cre workflow simulate`, your workflow interacts with a **`MockKeystoneForwarder`** contract that does not provide workflow metadata (`workflow_name`, `workflow_owner`).

> **CAUTION: Temporary limitation**
>
> This is a **temporary limitation** until the `MockKeystoneForwarder` is updated to provide full metadata.

### Deploying for Simulation

When deploying your consumer contract for simulation, pass the **Mock Forwarder address** to the constructor:

```solidity
// Deploy with MockForwarder address for Ethereum Sepolia simulation
address mockForwarder = 0x15fC6ae953E024d975e77382eEeC56A9101f9F88; // Ethereum Sepolia MockForwarder
MyConsumer myConsumer = new MyConsumer(mockForwarder);
```

Find Mock Forwarder addresses for all networks in the [Forwarder Directory](/cre/guides/workflow/using-evm-client/forwarder-directory) page.

> **CAUTION: Important: Different addresses for simulation vs production**
>
> The `MockKeystoneForwarder` address used during simulation is **different** from the `KeystoneForwarder` address used by deployed workflows. After testing with simulation, deploy a new instance with the production `KeystoneForwarder` address, or update the forwarder address using `setForwarderAddress()`. See [Forwarder Directory](/cre/guides/workflow/using-evm-client/forwarder-directory) for forwarder addresses.

### Metadata-based validation

**Do not configure these validation checks** during simulation - they require metadata that `MockKeystoneForwarder` doesn't provide:

- `setExpectedWorkflowId()`
- `setExpectedAuthor()`
- `setExpectedWorkflowName()`

Setting any of these will cause your simulation to fail.

### Transitioning to Production

Once you're ready to deploy your workflow to production:

**Option 1: Deploy a new contract instance**

```solidity
// Deploy with production KeystoneForwarder address
address keystoneForwarder = 0xF8344CFd5c43616a4366C34E3EEE75af79a74482; // Ethereum Sepolia
MyConsumer myConsumer = new MyConsumer(keystoneForwarder);

// Configure additional security checks
myConsumer.setExpectedWorkflowId(0xYourWorkflowId);
```

**Option 2: Update existing contract's forwarder**

```solidity
// Update forwarder to production KeystoneForwarder
myConsumer.setForwarderAddress(0xF8344CFd5c43616a4366C34E3EEE75af79a74482); // Ethereum Sepolia

// Add metadata-based validation
myConsumer.setExpectedWorkflowId(0xYourWorkflowId);
```

See [Configuring Permissions](#34-configuring-permissions) for complete details.

## 5. Advanced Usage (Optional)

### 5.1 Custom Validation Logic

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

```solidity
import { ReceiverTemplate } from "./ReceiverTemplate.sol";

contract AdvancedConsumer is ReceiverTemplate {
  uint256 private s_minReportInterval = 1 hours;
  uint256 private s_lastReportTime;

  error ReportTooFrequent(uint256 timeSinceLastReport, uint256 minInterval);

  event MinReportIntervalUpdated(uint256 previousInterval, uint256 newInterval);

  constructor(address _forwarderAddress) ReceiverTemplate(_forwarderAddress) {}

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

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

    s_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 ...
  }

  /// @notice Returns the minimum interval between reports
  /// @return The minimum interval in seconds
  function getMinReportInterval() external view returns (uint256) {
    return s_minReportInterval;
  }

  /// @notice Returns the timestamp of the last report
  /// @return The last report timestamp
  function getLastReportTime() external view returns (uint256) {
    return s_lastReportTime;
  }

  /// @notice Updates the minimum interval between reports
  /// @param _interval The new minimum interval in seconds
  function setMinReportInterval(uint256 _interval) external onlyOwner {
    uint256 previousInterval = s_minReportInterval;
    s_minReportInterval = _interval;
    emit MinReportIntervalUpdated(previousInterval, _interval);
  }
}
```

### 5.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:

```solidity
contract MetadataAwareConsumer is ReceiverTemplate {
  mapping(bytes32 => uint256) public s_reportCountByWorkflow;

  constructor(address _forwarderAddress) ReceiverTemplate(_forwarderAddress) {}

  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
    s_reportCountByWorkflow[workflowId]++;

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

> **NOTE: Advanced access control**
>
> For production systems requiring even more sophisticated access control (such as role-based permissions or two-step ownership transfer), consider extending the template to use OpenZeppelin's `AccessControl` instead of `Ownable`, or implementing a custom ownership transfer pattern.

## 6. Complete Examples

### Example 1: Simple Consumer Contract

This example inherits from `ReceiverTemplate` to store a temperature value.

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

contract TemperatureConsumer is ReceiverTemplate {
  int256 public s_currentTemperature;
  event TemperatureUpdated(int256 newTemperature);

  // Constructor requires forwarder address
  constructor(address _forwarderAddress) ReceiverTemplate(_forwarderAddress) {}

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

**Deployment:**

```solidity
// For simulation: Use MockForwarder address
address mockForwarder = 0x15fC6ae953E024d975e77382eEeC56A9101f9F88; // e.g. Ethereum Sepolia
TemperatureConsumer temperatureConsumer = new TemperatureConsumer(mockForwarder);

// For production: Use KeystoneForwarder address
address keystoneForwarder = 0xF8344CFd5c43616a4366C34E3EEE75af79a74482; // e.g. Ethereum Sepolia
TemperatureConsumer temperatureConsumer = new TemperatureConsumer(keystoneForwarder);
```

**Adding additional security after deployment:**

```solidity
// Add 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 `ReceiverTemplate` 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.

```solidity
// 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 private s_proxyAddress;
  uint256 private s_lastEthPrice;
  uint256 private s_lastBtcPrice;
  uint256 private s_lastUpdateTime;

  event ReservesUpdated(uint256 ethPrice, uint256 btcPrice, uint256 updateTime);
  event ProxyAddressUpdated(address indexed previousProxy, address indexed newProxy);

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

  constructor() Ownable(msg.sender) {}

  /// @notice Returns the proxy address
  /// @return The authorized proxy address
  function getProxyAddress() external view returns (address) {
    return s_proxyAddress;
  }

  /// @notice Returns the last ETH price
  /// @return The last recorded ETH price
  function getLastEthPrice() external view returns (uint256) {
    return s_lastEthPrice;
  }

  /// @notice Returns the last BTC price
  /// @return The last recorded BTC price
  function getLastBtcPrice() external view returns (uint256) {
    return s_lastBtcPrice;
  }

  /// @notice Returns the last update timestamp
  /// @return The timestamp of the last update
  function getLastUpdateTime() external view returns (uint256) {
    return s_lastUpdateTime;
  }

  /// @notice Updates the authorized proxy address
  /// @param _proxyAddress The new proxy address
  function setProxyAddress(address _proxyAddress) external onlyOwner {
    address previousProxy = s_proxyAddress;
    s_proxyAddress = _proxyAddress;
    emit ProxyAddressUpdated(previousProxy, _proxyAddress);
  }

  /// @notice Updates the reserve prices
  /// @param data The new reserve data containing ETH and BTC prices
  function updateReserves(UpdateReserves memory data) external onlyProxy {
    s_lastEthPrice = data.ethPrice;
    s_lastBtcPrice = data.btcPrice;
    s_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 `ReceiverTemplate` to validate incoming reports and then calls the `ReserveManager`.

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

import { ReserveManager } from "./ReserveManager.sol";
import { ReceiverTemplate } from "./ReceiverTemplate.sol";

contract UpdateReservesProxy is ReceiverTemplate {
  ReserveManager private s_reserveManager;

  constructor(address _forwarderAddress, address reserveManagerAddress) ReceiverTemplate(_forwarderAddress) {
    s_reserveManager = ReserveManager(reserveManagerAddress);
  }

  /// @notice Returns the reserve manager contract address
  /// @return The ReserveManager contract instance
  function getReserveManager() external view returns (ReserveManager) {
    return s_reserveManager;
  }

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

**Configuring permissions after deployment:**

```solidity
// Additional validation can be added after deployment
updateReservesProxy.setExpectedWorkflowId(0xYourWorkflowId...);
```

> **NOTE: KeystoneForwarder address shown**
>
> The examples above use the Ethereum Sepolia forwarder address. For other networks, see [Forwarder Directory](/cre/guides/workflow/using-evm-client/forwarder-directory).

#### 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 forwarder address and 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:
   ```solidity
   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 is verified automatically; additional checks like workflow ID can be added)
   - 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 with the appropriate forwarder address
   - 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

(Image: Image)

## 7. Security Considerations

### Forwarder address

**The forwarder address is the foundation of your contract's security.** The `KeystoneForwarder` contract performs cryptographic verification of DON signatures before calling your consumer. By requiring the forwarder address in the constructor, `ReceiverTemplate` ensures your contract is secure from deployment.

> **CAUTION: Never set forwarder to address(0) in production**
>
> While the `setForwarderAddress()` function allows updating to `address(0)`, this disables the critical security check and allows **anyone** to call your `onReport()` function with arbitrary data. The function emits a `SecurityWarning` event if you attempt this. Only use `address(0)` for testing if you fully understand the implications.

### Replay attacks

CRE reports carry DON signatures that any compatible `KeystoneForwarder` will accept. This creates two distinct replay vectors that workflow authors must explicitly protect against by embedding protective metadata in their report payloads and verifying it in their consumer contracts.

#### Cross-chain replay

**The risk**: While publishing a single signed report to multiple chains simultaneously enables patterns like [Proof of Reserve (PoR)](/data-feeds/smartdata#proof-of-reserve-feeds) or feed-style publish-once-post-many, it also means **anyone holding a valid report can replay it on any chain that recognizes the DON's signing keys**.

The forwarder validates cryptographic signatures but those signatures do not commit to a specific chain—without additional protection in your consumer contract, a replayed report can land on an unintended chain.

(Image: Image)

**The mitigation**: Embed the target chain selector in the report payload. The consumer contract decodes this value and rejects reports not intended for the current chain. Chain selectors are `uint64` identifiers used throughout the CRE platform to identify blockchain networks — see [Chain Selectors](/cre/reference/sdk/evm-client-ts#chain-selectors) for the full list of constants and the `ChainSelectorFromName` helper.

**Workflow (embed chain selector in the report payload):**

```go
// Define your report struct with a ChainSelector field.
// ChainSelector is a uint64 — the same type used when instantiating evm.Client.
type PaymentReport struct {
  Recipient     common.Address
  Amount        *big.Int
  ChainSelector uint64 // Target chain — used by the consumer to reject cross-chain replays
}

paymentReport := PaymentReport{
  Recipient:     common.HexToAddress(config.Recipient),
  Amount:        big.NewInt(100_000_000), // e.g., 100 USDC (6 decimals)
  ChainSelector: config.ChainSelector,   // e.g., 16015286601757825753 for Ethereum Sepolia
}

// ABI-encode paymentReport and pass to runtime.GenerateReport() as normal
```

**Consumer contract (verify the embedded chain selector):**

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import { ReceiverTemplate } from "./ReceiverTemplate.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract ChainRestrictedConsumer is ReceiverTemplate {
  IERC20 public immutable i_token;
  uint64 public immutable i_expectedChainSelector;

  error UnexpectedChainSelector(uint64 received, uint64 expected);

  constructor(
    address _forwarderAddress,
    address _token,
    uint64 _expectedChainSelector
  ) ReceiverTemplate(_forwarderAddress) {
    i_token = IERC20(_token);
    i_expectedChainSelector = _expectedChainSelector;
  }

  function _processReport(bytes calldata report) internal override {
    (address recipient, uint256 amount, uint64 chainSelector) = abi.decode(
      report,
      (address, uint256, uint64)
    );

    if (chainSelector != i_expectedChainSelector) {
      revert UnexpectedChainSelector(chainSelector, i_expectedChainSelector);
    }

    i_token.transfer(recipient, amount);
  }
}
```

#### Same-chain replay on failure

**The risk**: While allowing failed deliveries to be retried without requiring a new signed report enables permissionless recovery from transient failures, it also means the forwarder does not mark reverted transmissions as used.

**A malicious actor can exploit this window**: after your workflow has already reacted to a failure (for example, scheduled a corrective action), an attacker can replay the original signed report once conditions recover, causing double-execution.

(Image: Image)

> **CAUTION: Attack scenario: double payment**
>
> 1. A cron workflow attempts to pay a wallet $100 USDC. The consumer contract **reverts** (insufficient funds).

1. CRE returns a reverted transaction hash. The workflow records the failure and plans to correct the balance on the next run.
2. Funds are replenished — by the owner, another workflow, or a user deposit.
3. An attacker (or a bot) replays the original signed report. The consumer now has funds and the payment **executes again** — the recipient is paid twice.

**The mitigation**: Embed the scheduled execution timestamp in the report payload. The consumer contract stores the last accepted timestamp and rejects any report with a timestamp equal to or earlier than the stored value. Once a later execution has been accepted, earlier failed reports can never land.

> **NOTE: Use the trigger's scheduled time, not wall-clock time**
>
> The timestamp must be deterministic across all DON nodes so they agree during consensus. Use the cron trigger's scheduled execution slot time rather than `time.Now()`. Refer to the [cron trigger reference](/cre/reference/sdk/triggers/cron-trigger-go) for the exact field name on the trigger payload.

**Workflow (embed scheduled execution timestamp in the report payload):**

```go
// Use the trigger's scheduled slot time — deterministic across all DON nodes.
// Refer to the cron trigger reference for the exact field name on cron.Payload.
scheduledAt := trigger.ScheduledAt.Unix()

// Define your report struct with a ScheduledAt field
type PaymentReport struct {
  Recipient   common.Address
  Amount      *big.Int
  ScheduledAt *big.Int // Monotonic execution timestamp — used to reject stale replays
}

paymentReport := PaymentReport{
  Recipient:   common.HexToAddress(config.Recipient),
  Amount:      big.NewInt(100_000_000), // e.g., 100 USDC (6 decimals)
  ScheduledAt: big.NewInt(scheduledAt),
}

// ABI-encode paymentReport and pass to runtime.GenerateReport() as normal
```

**Consumer contract (reject reports from earlier executions):**

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import { ReceiverTemplate } from "./ReceiverTemplate.sol";
import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract ScheduledPaymentConsumer is ReceiverTemplate {
  IERC20 public immutable i_token;
  uint256 public s_lastAcceptedTimestamp;

  error ReportTooOld(uint256 reportTimestamp, uint256 lastAccepted);

  event PaymentProcessed(address indexed recipient, uint256 amount, uint256 scheduledAt);

  constructor(address _forwarderAddress, address _token) ReceiverTemplate(_forwarderAddress) {
    i_token = IERC20(_token);
  }

  function _processReport(bytes calldata report) internal override {
    (address recipient, uint256 amount, uint256 scheduledAt) = abi.decode(
      report,
      (address, uint256, uint256)
    );

    if (scheduledAt <= s_lastAcceptedTimestamp) {
      revert ReportTooOld(scheduledAt, s_lastAcceptedTimestamp);
    }

    s_lastAcceptedTimestamp = scheduledAt;

    i_token.transfer(recipient, amount);
    emit PaymentProcessed(recipient, amount, scheduledAt);
  }
}
```

> **TIP: Combining both protections**
>
> For maximum safety, embed both `chainSelector` and `scheduledAt` in a single report struct. This protects against cross-chain and same-chain replay simultaneously with one encoding step.

### Additional validation layers

The forwarder address provides baseline security, but you can add additional validation for defense-in-depth:

- **`expectedWorkflowId`**: Ensures only one specific workflow can update your contract. Use this when a single workflow writes to your consumer (highest security for single-workflow scenarios).
- **`expectedAuthor`**: Restricts to workflows owned by a specific address. Use this when multiple workflows from the same owner should access your contract.
- **`expectedWorkflowName`**: Can be used in combination with `expectedAuthor` for additional validation. Requires author validation to be configured. See [Workflow name validation](#workflow-name-validation) below.

### Workflow name validation

> **CAUTION: Workflow name validation requires author validation**
>
> The `expectedWorkflowName` check in `ReceiverTemplate.onReport()` **requires author validation** to be configured:

- **Collision Risk**: Workflow names use only 40-bit truncation (bytes10), making collision attacks computationally feasible when used alone
- **Unique per owner**: Workflow names are unique per owner but not across different owners
- **Runtime enforcement**: The code enforces that if `expectedWorkflowName` is set, `expectedAuthor` must also be set, otherwise it reverts with `WorkflowNameRequiresAuthorValidation()`

By combining workflow name (40-bit) with author validation (160-bit address), the contract achieves adequate collision resistance. You can safely use workflow name validation as long as author validation is also enabled.

### Best practices

1. **Always deploy with a valid forwarder address** - The constructor requires this for security. Use `MockForwarder` for simulation, `KeystoneForwarder` for production. Forwarder addresses are available in the [Forwarder Directory](/cre/guides/workflow/using-evm-client/forwarder-directory) page.
2. **Add additional validation for production**:
   - **Single workflow**: Use `setExpectedWorkflowId()` to restrict to one specific workflow (highest security)
   - **Multiple workflows from same owner**: Use `setExpectedAuthor()` to restrict to workflows you own
   - **Multiple workflows from different owners**: Implement custom validation logic in your `onReport()` override
3. **Protect against replay attacks** - For any workflow that performs state-changing actions (payments, minting, position updates):
   - Embed the **target chain selector** in the report payload and verify it in `_processReport` to prevent cross-chain replay
   - Embed a **monotonic execution timestamp** from the cron trigger and reject reports with a timestamp ≤ the last accepted value to prevent same-chain replay on failure
   - See [Replay attacks](#replay-attacks) for complete code examples
4. **Keep your owner key secure** - The owner can update all permission settings
5. **Test permission configurations** - Verify your security settings work as expected before production deployment
6. **Workflow name validation** - Can be used with `setExpectedWorkflowName()` but requires `setExpectedAuthor()` to also be configured for security