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
  2. The IReceiver Standard
  3. Using ReceiverTemplate
  4. Working with Simulation
  5. Advanced Usage
  6. Complete Examples
  7. Security Considerations

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.

// 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 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

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

// 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 pure 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:

// 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.

Configuration examples:

// 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

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.

Usage:

// 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).

Deploying for Simulation

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

// 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 Supported Networks page.

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

// 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

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

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

See 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:

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:

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

6. Complete Examples

Example 1: Simple Consumer Contract

This example inherits from ReceiverTemplate to store a temperature value.

// 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:

// 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:

// 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.

// 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.

// 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:

// Additional validation can be added after deployment
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 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:
    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

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.

Replay protection

The KeystoneForwarder contract includes built-in replay protection that prevents successful reports from being executed multiple times. By requiring the forwarder address at construction time, ReceiverTemplate ensures your consumer benefits from this protection automatically.

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 below.

Workflow name validation

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 Supported Networks 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. Keep your owner key secure - The owner can update all permission settings
  4. Test permission configurations - Verify your security settings work as expected before production deployment
  5. Workflow name validation - Can be used with setExpectedWorkflowName() but requires setExpectedAuthor() to also be configured for security

Get the latest Chainlink content straight to your inbox.