Skip to main content
Sigvex

API/Off-Chain Data Remediation

How to secure contracts that consume off-chain data by requiring cryptographic signatures, freshness timestamps, and multiple independent sources to prevent data injection and replay attacks.

API/Off-Chain Data Remediation

Overview

Contracts that consume external data — price feeds, sports results, random numbers, exchange rates, KYC status — must verify three properties of every data point before using it: that the data is authentic (came from a trusted source), recent (has not been replayed from a prior observation), and consensus-backed (not from a single source that can be bribed, DDoSed, or compromised).

Without these checks, off-chain data attacks are cheaper than on-chain oracle manipulation: a compromised API server, a man-in-the-middle interception, or a stale-data replay attack requires no flash loan capital and leaves no on-chain trace. Any party that can submit data to the contract — or intercept the data pipeline — can manipulate protocol behavior to their advantage.

Related Detector: Oracle Manipulation Detector

Before (Vulnerable)

contract VulnerableDataConsumer {
    address public dataProvider;
    uint256 public lastPrice;

    // VULNERABLE: No signature verification — any caller can inject any price.
    // No freshness check — old favourable prices can be replayed.
    function updatePrice(uint256 price, uint256 timestamp) external {
        // The dataProvider check is on msg.sender — this can be spoofed if
        // the provider's private key or server is compromised.
        // There is also no check that 'timestamp' is recent.
        require(msg.sender == dataProvider, "Not provider");
        lastPrice = price;
    }

    function executeTradeWithPrice(uint256 amount) external {
        uint256 value = amount * lastPrice / 1e18;
        balances[msg.sender] += value;
    }
}

After (Fixed)

import "@audited/utils/cryptography/ECDSA.sol";
import "@audited/utils/cryptography/EIP712.sol";

contract SecureDataConsumer is EIP712 {
    using ECDSA for bytes32;

    address public trustedOracle;
    uint256 public constant MAX_PRICE_AGE = 5 minutes;

    // EIP-712 typed data — signed off-chain, verified on-chain
    bytes32 private constant PRICE_UPDATE_TYPEHASH = keccak256(
        "PriceUpdate(uint256 price,uint256 timestamp,address consumer)"
    );

    // Track used timestamps to prevent replay of old signed data
    mapping(uint256 => bool) public usedTimestamps;

    constructor(address oracle) EIP712("SecureDataConsumer", "1") {
        trustedOracle = oracle;
    }

    struct SignedPrice {
        uint256 price;
        uint256 timestamp;
        uint8   v;
        bytes32 r;
        bytes32 s;
    }

    function executeTradeWithSignature(
        uint256 amount,
        SignedPrice calldata signedPrice
    ) external {
        // 1. Freshness: reject data older than MAX_PRICE_AGE
        require(
            signedPrice.timestamp > block.timestamp - MAX_PRICE_AGE,
            "Price data too old"
        );
        require(
            signedPrice.timestamp <= block.timestamp,
            "Price timestamp in future"
        );

        // 2. Replay protection: each timestamp can only be used once
        require(!usedTimestamps[signedPrice.timestamp], "Timestamp already used");
        usedTimestamps[signedPrice.timestamp] = true;

        // 3. Cryptographic verification via EIP-712
        bytes32 structHash = keccak256(abi.encode(
            PRICE_UPDATE_TYPEHASH,
            signedPrice.price,
            signedPrice.timestamp,
            address(this) // Bind to this contract — prevents cross-contract replay
        ));
        bytes32 digest = _hashTypedDataV4(structHash);
        address signer = digest.recover(signedPrice.v, signedPrice.r, signedPrice.s);
        require(signer == trustedOracle, "Invalid oracle signature");

        // 4. Sanity bounds on the signed value
        require(signedPrice.price > 0, "Price is zero");
        require(signedPrice.price < type(uint128).max, "Price overflow");

        uint256 value = amount * signedPrice.price / 1e18;
        balances[msg.sender] += value;
    }
}

Alternative Mitigations

Use an established decentralized oracle network — the best practice is to eliminate the custom off-chain data pipeline entirely. multiple off-chain price-oracle services provide cryptographic verification, freshness guarantees, and multiple independent node operators. A contract consuming such a feed inherits all three security properties without any custom signing logic:

import "./interfaces/AggregatorV3Interface.sol";

contract OffchainFeedConsumer {
    AggregatorV3Interface public immutable priceFeed;
    uint256 public constant MAX_STALENESS = 1 hours;

    function getVerifiedPrice() public view returns (uint256) {
        (
            uint80 roundId,
            int256 price,
            ,
            uint256 updatedAt,
            uint80 answeredInRound
        ) = priceFeed.latestRoundData();

        require(price > 0, "Invalid price");
        require(block.timestamp - updatedAt <= MAX_STALENESS, "Stale price");
        require(answeredInRound >= roundId, "Incomplete round");

        return uint256(price);
    }
}

Threshold multi-oracle signing — require M-of-N independent oracles to sign each data update. No single oracle server can inject malicious data, and DoSing one server does not halt the protocol:

contract MultiSignedDataConsumer {
    address[] public oracles;
    uint256   public constant MIN_SIGNATURES = 3; // 3-of-5

    function updatePrice(
        uint256 price,
        uint256 timestamp,
        bytes[] calldata signatures
    ) external {
        require(signatures.length >= MIN_SIGNATURES, "Insufficient signatures");

        bytes32 messageHash = keccak256(abi.encodePacked(price, timestamp, address(this)));
        bytes32 ethHash = ECDSA.toEthSignedMessageHash(messageHash);

        uint256 validCount;
        address lastSigner = address(0);

        for (uint256 i = 0; i < signatures.length; i++) {
            address signer = ECDSA.recover(ethHash, signatures[i]);
            require(signer > lastSigner, "Signatures out of order or duplicate");
            if (_isOracle(signer)) validCount++;
            lastSigner = signer;
        }

        require(validCount >= MIN_SIGNATURES, "Insufficient valid oracle signatures");
        require(timestamp > block.timestamp - MAX_PRICE_AGE, "Data too old");

        lastPrice = price;
    }
}

Commit-reveal for randomness and prediction markets — when the off-chain data has not yet occurred (future randomness, game outcomes), use a commit-reveal scheme to prevent an oracle from choosing a favorable value after observing the outcome:

contract CommitRevealOracle {
    mapping(uint256 => bytes32) public commitments;
    mapping(uint256 => uint256) public revealed;
    uint256 public constant REVEAL_DELAY = 1 hours;

    // Phase 1: Oracle commits to a hash of the future value
    function commit(uint256 roundId, bytes32 valueHash) external onlyOracle {
        commitments[roundId] = valueHash;
    }

    // Phase 2: Oracle reveals the value after the commit delay
    function reveal(uint256 roundId, uint256 value, bytes32 salt) external onlyOracle {
        require(
            block.timestamp >= commitmentTime[roundId] + REVEAL_DELAY,
            "Reveal too early"
        );
        require(
            keccak256(abi.encodePacked(value, salt)) == commitments[roundId],
            "Hash mismatch"
        );
        revealed[roundId] = value;
    }
}

Common Mistakes

Signing only the price value, not the target contract address — a signed price payload without a bound address(this) can be replayed against a different contract that shares the same oracle key. Include address(this) and block.chainid in every signed message.

Using block.timestamp as the anti-replay nonce — two messages in the same block share the same block.timestamp. Use a sequential nonce or a very high-resolution timestamp that cannot collide.

Trusting msg.sender as the oracle identity — private key theft, phishing, or compromised server infrastructure changes who controls the oracle address. Cryptographic signatures allow oracle key rotation without contract upgrades; msg.sender checks do not.

Setting MAX_PRICE_AGE too long — a 24-hour freshness window allows an attacker to wait for a historically favorable price observation, collect a valid signed payload, and replay it 23 hours later. For price data, 5 minutes is a reasonable maximum; for less time-sensitive data, tune accordingly.

References