aere-contracts/contracts/oracle/AerePyth.sol
Aere Network acac2f00a6
Some checks failed
contracts-ci / Install (lockfile) → compile → full test suite (push) Has been cancelled
contracts-ci / Ethereum interop (EIP-2537 BLS, prague hardfork) (push) Has been cancelled
contracts-ci / PQC known-answer tests (NIST vectors) (push) Has been cancelled
contracts-ci / Coverage (scoped, with artifacts) (push) Has been cancelled
The unpublished line of work joins the sanitized public line
The published line and the local line had no common ancestor: the public one
carried the redaction pass, the local one carried three weeks of corrections
that never shipped. This commit ports the local work onto the public line,
keeps every public redaction, and extends the same discretion to seven client
mentions that were still named in published comments.

Carried: LICENSE year and LICENSING.md; the measured burn figures replacing
the deflation claim (the vault holds ~0.137 AERE of 2.8 billion, and burn is
a share of validator coinbase revenue, which is zero today); 'audited' removed
from next to Bouncy Castle; citation paths rewritten to published form with
CITATIONS-UNRESOLVED.md remeasured 2026-08-11; VERIFY-POLICY.md; slashing and
ownership comments brought down to what the code does; the AerePyth repair;
the shutter test helper the tests cite; runnable package.json entries; the CI
file split into a GitHub/Gitea twin pair with a real measured test-run status;
and the .gitignore hardening written after a compiled artifact leaked a local
path in a sibling repository. A false '2-of-3 multisig' description of the
owner account is corrected to what the chain measures: an externally owned
account. The self-audit findings catalog stays unpublished pending an explicit
decision.
2026-08-15 13:59:30 +03:00

317 lines
14 KiB
Solidity

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