// SPDX-License-Identifier: MIT pragma solidity ^0.8.23; import "@openzeppelin/contracts/access/Ownable.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol"; /** * @title AerePyth * @notice Pyth-Network-compatible pull-oracle receiver for AERE Network. * * Implements Pyth's IPyth interface so applications written against the * canonical Pyth SDK work unchanged on AERE. Once AERE is added to the * public Pyth network (via PR to pyth-network/pyth-crosschain), the same * contract accepts updates signed by Pyth's Guardian set with no migration. * * In the bootstrap phase, prices are pushed by AERE Foundation's * publisher set (initially: our existing oracle reporter container, * later: extended to multiple publishers for redundancy). Publishers * sign price updates with ECDSA — same signature scheme as AereMessenger. * * Reference: https://docs.pyth.network/price-feeds/contract-addresses/evm * https://github.com/pyth-network/pyth-sdk-solidity */ struct PythPrice { int64 price; uint64 conf; int32 expo; uint256 publishTime; } struct PythPriceFeed { bytes32 id; PythPrice price; PythPrice emaPrice; } interface IPyth { function getPrice(bytes32 id) external view returns (PythPrice memory price); function getEmaPrice(bytes32 id) external view returns (PythPrice memory price); function getPriceUnsafe(bytes32 id) external view returns (PythPrice memory price); function getEmaPriceUnsafe(bytes32 id) external view returns (PythPrice memory price); function getPriceNoOlderThan(bytes32 id, uint256 age) external view returns (PythPrice memory price); function getEmaPriceNoOlderThan(bytes32 id, uint256 age) external view returns (PythPrice memory price); function updatePriceFeeds(bytes[] calldata updateData) external payable; function updatePriceFeedsIfNecessary(bytes[] calldata updateData, bytes32[] calldata priceIds, uint64[] calldata publishTimes) external payable; function getUpdateFee(bytes[] calldata updateData) external view returns (uint256 feeAmount); function getValidTimePeriod() external view returns (uint256); } contract AerePyth is IPyth, Ownable, ReentrancyGuard { /// Authorised publisher set. Each signs price updates with ECDSA. mapping(address => bool) public isPublisher; address[] public publishers; /// Threshold of distinct publishers required to accept an update. uint8 public threshold = 1; /// Stored price feed by id (e.g. keccak256("BTC/USD") or canonical Pyth feed ID). mapping(bytes32 => PythPrice) internal _prices; mapping(bytes32 => PythPrice) internal _emaPrices; /// Per-update fee in wei AERE. Default 0; owner can raise it later /// to fund Foundation infrastructure or distribute back to publishers. uint256 public updateFeePerUpdate = 0; /// Maximum age (seconds) a price can be without being considered stale. uint256 public validTimePeriod = 60; // ─────────────── Update payload framing (v1) ─────────────── // // Constatarea D-067. The first version of this contract inferred the length // of the encoded price feed from the total payload length: // head = data.length - ((data.length - 64) / 65) * 65 // For a payload of 288 + 65*k bytes that expression collapses to a constant // 93 for EVERY k, so `abi.decode(data[:head])` reverted on every valid // update that could ever be submitted. The write path was dead arithmetic. // // The replacement follows the framing discipline of the production Pyth // receiver (pyth-network/pyth-crosschain, PythAccumulator.sol): the payload // carries its own magic bytes and version, every variable-length segment is // announced by an explicit count, and the parser asserts that it consumed // the buffer EXACTLY. Nothing is inferred from the total length. // // bytes 0..3 magic "AEPU" // byte 4 version, must be 1 // byte 5 sigCount, number of 65-byte signatures that follow the feed // bytes 6..293 abi.encode(PythPriceFeed), fixed 288 bytes (nine static words) // bytes 294.. sigCount * 65 signature bytes // total length must equal 294 + 65*sigCount, no trailing bytes tolerated bytes4 internal constant UPDATE_MAGIC = 0x41455055; // "AEPU" uint8 internal constant UPDATE_VERSION = 1; uint256 internal constant FEED_ENCODED_LEN = 288; uint256 internal constant UPDATE_HEADER_LEN = 6; uint256 internal constant SIG_LEN = 65; event PriceUpdated(bytes32 indexed id, int64 price, uint64 conf, int32 expo, uint256 publishTime); event PublisherAdded(address indexed publisher); event PublisherRemoved(address indexed publisher); event ThresholdChanged(uint8 newThreshold); event FeeChanged(uint256 newFee); // ───────────────────── Admin ───────────────────── function addPublisher(address p) external onlyOwner { require(!isPublisher[p], "AerePyth: already"); isPublisher[p] = true; publishers.push(p); emit PublisherAdded(p); } function removePublisher(address p) external onlyOwner { require(isPublisher[p], "AerePyth: not found"); isPublisher[p] = false; for (uint256 i = 0; i < publishers.length; i++) { if (publishers[i] == p) { publishers[i] = publishers[publishers.length - 1]; publishers.pop(); break; } } emit PublisherRemoved(p); } function setThreshold(uint8 _t) external onlyOwner { require(_t > 0 && _t <= publishers.length, "AerePyth: bad threshold"); threshold = _t; emit ThresholdChanged(_t); } function setUpdateFee(uint256 _fee) external onlyOwner { updateFeePerUpdate = _fee; emit FeeChanged(_fee); } function setValidTimePeriod(uint256 _seconds) external onlyOwner { validTimePeriod = _seconds; } function withdrawFees(address payable to, uint256 amount) external onlyOwner { (bool ok, ) = to.call{ value: amount }(""); require(ok, "AerePyth: withdraw failed"); } function publisherCount() external view returns (uint256) { return publishers.length; } // ───────────────────── Reads (IPyth) ───────────────────── function getPrice(bytes32 id) external view override returns (PythPrice memory price) { price = _prices[id]; require(price.publishTime > 0, "AerePyth: feed unknown"); require(block.timestamp - price.publishTime <= validTimePeriod, "AerePyth: stale"); } function getPriceUnsafe(bytes32 id) external view override returns (PythPrice memory price) { price = _prices[id]; require(price.publishTime > 0, "AerePyth: feed unknown"); } function getEmaPrice(bytes32 id) external view override returns (PythPrice memory price) { price = _emaPrices[id]; require(price.publishTime > 0, "AerePyth: ema unknown"); require(block.timestamp - price.publishTime <= validTimePeriod, "AerePyth: stale"); } function getEmaPriceUnsafe(bytes32 id) external view override returns (PythPrice memory price) { price = _emaPrices[id]; require(price.publishTime > 0, "AerePyth: ema unknown"); } function getPriceNoOlderThan(bytes32 id, uint256 age) external view override returns (PythPrice memory price) { price = _prices[id]; require(price.publishTime > 0, "AerePyth: feed unknown"); require(block.timestamp - price.publishTime <= age, "AerePyth: older than allowed"); } function getEmaPriceNoOlderThan(bytes32 id, uint256 age) external view override returns (PythPrice memory price) { price = _emaPrices[id]; require(price.publishTime > 0, "AerePyth: ema unknown"); require(block.timestamp - price.publishTime <= age, "AerePyth: older than allowed"); } function getValidTimePeriod() external view override returns (uint256) { return validTimePeriod; } // ───────────────────── Updates ───────────────────── /** * @notice Pyth-style pull-oracle update. * @dev Each `updateData[i]` is a v1 framed payload: magic "AEPU", * version byte, signature count, the 288-byte abi-encoded * PythPriceFeed, then that many 65-byte ECDSA signatures over * `updateDigest(feed)`. The parser never infers a length. */ function updatePriceFeeds(bytes[] calldata updateData) external payable override nonReentrant { uint256 required = updateFeePerUpdate * updateData.length; require(msg.value >= required, "AerePyth: insufficient fee"); for (uint256 i = 0; i < updateData.length; i++) { (PythPriceFeed memory feed, bytes calldata sigs) = _parseUpdate(updateData[i]); _verifyAndStore(feed, sigs); } } function updatePriceFeedsIfNecessary( bytes[] calldata updateData, bytes32[] calldata priceIds, uint64[] calldata publishTimes ) external payable override nonReentrant { require(priceIds.length == publishTimes.length, "AerePyth: length mismatch"); // The fee is charged here too. The first version of this function took // no fee at all, so once `updateFeePerUpdate` was raised the same work // was free through this door. require(msg.value >= updateFeePerUpdate * updateData.length, "AerePyth: insufficient fee"); bool applied = false; // Each payload is parsed exactly once, then matched against the ids the // caller says it needs. The previous version decoded every payload once // per requested id inside the inner loop. for (uint256 j = 0; j < updateData.length; j++) { (PythPriceFeed memory feed, bytes calldata sigs) = _parseUpdate(updateData[j]); for (uint256 i = 0; i < priceIds.length; i++) { if (feed.id == priceIds[i] && _prices[priceIds[i]].publishTime < publishTimes[i]) { _verifyAndStore(feed, sigs); applied = true; break; } } } // Same choice the canonical Pyth receiver makes: say so instead of // taking the fee and silently doing nothing. require(applied, "AerePyth: no fresh update"); } /** * @notice The exact 32-byte hash a publisher must personal_sign for `feed`. * @dev Bound to this chain and this contract, so a publisher signature * cannot be replayed into another deployment, or onto another chain * that shares the same publisher key. The publisher signs THIS with * EIP-191 personal_sign; the contract adds the same prefix before * ecrecover. */ function updateSigningHash(PythPriceFeed calldata feed) external view returns (bytes32) { return _updatePayloadHash(feed); } function _updatePayloadHash(PythPriceFeed memory feed) internal view returns (bytes32) { return keccak256(abi.encode(UPDATE_MAGIC, UPDATE_VERSION, block.chainid, address(this), feed)); } function _updateDigest(PythPriceFeed memory feed) internal view returns (bytes32) { return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", _updatePayloadHash(feed))); } function _parseUpdate(bytes calldata data) internal pure returns (PythPriceFeed memory feed, bytes calldata sigs) { require(data.length >= UPDATE_HEADER_LEN + FEED_ENCODED_LEN, "AerePyth: update too short"); require(bytes4(data[0:4]) == UPDATE_MAGIC, "AerePyth: bad magic"); require(uint8(data[4]) == UPDATE_VERSION, "AerePyth: bad version"); uint256 sigCount = uint8(data[5]); require(sigCount > 0, "AerePyth: no signatures"); // Exact consumption. Nothing is inferred from data.length; the length // the header ANNOUNCES has to be the length that was sent. require( data.length == UPDATE_HEADER_LEN + FEED_ENCODED_LEN + sigCount * SIG_LEN, "AerePyth: bad payload length" ); feed = abi.decode(data[UPDATE_HEADER_LEN:UPDATE_HEADER_LEN + FEED_ENCODED_LEN], (PythPriceFeed)); sigs = data[UPDATE_HEADER_LEN + FEED_ENCODED_LEN:]; } function _verifyAndStore(PythPriceFeed memory feed, bytes calldata sigs) internal { uint256 sigCount = sigs.length / SIG_LEN; require(sigCount >= threshold, "AerePyth: below threshold"); bytes32 digest = _updateDigest(feed); address[] memory seen = new address[](sigCount); uint256 validCount = 0; for (uint256 i = 0; i < sigCount; i++) { bytes32 r; bytes32 s; uint8 v; assembly { let off := add(sigs.offset, mul(i, 65)) r := calldataload(off) s := calldataload(add(off, 32)) v := byte(0, calldataload(add(off, 64))) } address signer = ecrecover(digest, v, r, s); require(signer != address(0) && isPublisher[signer], "AerePyth: bad publisher"); for (uint256 j = 0; j < validCount; j++) { require(seen[j] != signer, "AerePyth: duplicate signer"); } seen[validCount] = signer; validCount++; } // Only apply if newer than what we have. if (feed.price.publishTime > _prices[feed.id].publishTime) { _prices[feed.id] = feed.price; _emaPrices[feed.id] = feed.emaPrice; emit PriceUpdated(feed.id, feed.price.price, feed.price.conf, feed.price.expo, feed.price.publishTime); } } function getUpdateFee(bytes[] calldata updateData) external view override returns (uint256 feeAmount) { return updateFeePerUpdate * updateData.length; } receive() external payable {} }