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
Recommended Fix
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 "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/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. Chainlink, Pyth Network, and Band Protocol all provide cryptographic verification, freshness guarantees, and multiple independent node operators. A contract consuming Chainlink inherits all three security properties without any custom signing logic:
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
contract ChainlinkConsumer {
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.