Skip to content

Commit a33b225

Browse files
committed
refactor: change to list of rose feeds and average price
1 parent 2dd27c2 commit a33b225

19 files changed

+2635
-75
lines changed

contracts/.env.example

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,17 @@ PRIVATE_KEY=your_private_key_here
77

88
# External contracts
99
SHOYU_BASHI=0xShoyuBashiAddress
10-
ROSE_USD_FEED=0xRoseUsdAggregator
11-
TOKEN_USD_FEED=0xTokenUsdAggregator
10+
11+
# ROSE/USD Price Feeds (comma-separated for redundancy)
12+
# For production: configure 3+ feeds for median aggregation and outlier resistance
13+
# For testing: minimum 1 feed required
14+
# Example: ROSE_USD_FEEDS=0xFeed1Address,0xFeed2Address,0xFeed3Address
15+
ROSE_USD_FEEDS=0x47EFD60558012A64649c709b350f20C7a5f5e2Aa,0x666938f7FBC353227F98DA43C050C8252eBfC0f7
16+
17+
# Token price feed (for PaymasterVault)
18+
TOKEN_USD_FEED=0xd29802275E41449f675A2650629fBB268D2Ab52d
19+
20+
# Price staleness threshold in seconds (default: 3600 = 1 hour)
1221
PRICE_STALENESS_SECONDS=3600
1322

1423
# Distribution limits (ROSE units)

contracts/README.md

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ cp .env.example .env
4747
| `PRIVATE_KEY` | No | Deployer/signing key for Hardhat networks; defaults to test mnemonic if unset. |
4848
| `ALCHEMY_API_KEY` | No | Used for Hardhat mainnet fork and default RPC hints in `post-blockhash`. |
4949
| `OWNER` | Yes | Owner address for `deploy:cross-chain-paymaster`. |
50-
| `PRICE_ORACLE` | Yes | Price oracle address on Sapphire; used by deploy and oracle config tasks. |
51-
| `SHOYU_shellI` | Yes | Shoyushelli address on Sapphire. |
50+
| `SHOYU_BASHI` | Yes | ShoyuBashi address on Sapphire for block header verification. |
51+
| `ROSE_USD_FEEDS` | Yes | Comma-separated list of ROSE/USD price feed addresses (e.g., `0xFeed1,0xFeed2,0xFeed3`). Min 1 required, 3+ recommended for production (median aggregation). |
52+
| `PRICE_STALENESS_SECONDS` | No | Price staleness threshold in seconds (default `3600`). |
53+
| `TOKEN_USD_FEED` | Yes (vault config) | Token/USD price feed address for PaymasterVault token configuration. |
5254
| `DAILY_LIMIT_ROSE` | No | Daily ROSE distribution limit for CrossChainPaymaster (default `10000`). |
5355
| `PER_TX_LIMIT_ROSE` | No | Per-transaction ROSE limit for CrossChainPaymaster (default `100`). |
5456
| `LIMITS_ENABLED` | No | Enable/disable distribution limits (default `true`). |
@@ -84,8 +86,9 @@ bun hardhat deploy:cross-chain-paymaster --network sapphire-testnet
8486
# Custom params
8587
bun hardhat deploy:cross-chain-paymaster \
8688
--owner 0x... \
87-
--oracle 0x... \
88-
--shoyushelli 0x... \
89+
--shoyubashi 0x... \
90+
--roseusd 0xFeed1,0xFeed2,0xFeed3 \
91+
--stale 3600 \
8992
--daily 50000 \
9093
--pertx 500 \
9194
--enabled true \
@@ -107,12 +110,22 @@ bun hardhat deploy:paymaster-vault \
107110
--network eth-sepolia
108111
```
109112

110-
### 3) Deploy Mock Oracle (testing)
113+
### 3) Deploy Mock Price Feeds (testing)
114+
115+
Deploy Chainlink-compatible mock price feeds for testing ROSE/USD conversion:
111116

112117
```shell
118+
# Deploy mock ROSE/USD feed #1 (default: 18 decimals, price = 1)
119+
bun hardhat deploy:mock-oracle --network sapphire-testnet
120+
121+
# Deploy with custom price (e.g., 0.05 for $0.05/ROSE)
113122
bun hardhat deploy:mock-oracle \
114-
--paymaster 0x<PAYMASTER_PROXY> \
123+
--price 0.05 \
124+
--decimals 18 \
115125
--network sapphire-testnet
126+
127+
# For production-like testing, deploy 3+ feeds with varied prices
128+
# Then configure CrossChainPaymaster with --roseusd feed1,feed2,feed3
116129
```
117130

118131
## Configuration
@@ -156,23 +169,11 @@ bun hardhat configure:paymaster-vault \
156169
--network eth-sepolia
157170
```
158171

159-
### Mock Oracle (testing)
172+
### Price Feed Management
160173

161-
```shell
162-
# Add token feed at price=1 ROSE
163-
bun hardhat oracle:addtokenfeed \
164-
--oracle 0x<ORACLE_ADDRESS> \
165-
--token 0x<USDC_ADDRESS> \
166-
--decimals 6 \
167-
--price 1 \
168-
--network sapphire-testnet
174+
CrossChainPaymaster uses ROSE/USD price feeds configured at deployment. To update feeds after deployment, use the upgrade or configure tasks with new feed addresses.
169175

170-
# Remove token feed
171-
bun hardhat oracle:removetokenfeed \
172-
--oracle 0x<ORACLE_ADDRESS> \
173-
--token 0x<USDC_ADDRESS> \
174-
--network sapphire-testnet
175-
```
176+
For testing with mock feeds, deploy multiple `MockV3Aggregator` instances (see deployment section above) and configure them via the `--roseusd` parameter.
176177

177178
## Flow: Deposit → Proof → Relay
178179

contracts/contracts/sapphire/CrossChainPaymaster.sol

Lines changed: 164 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/I
55
import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
66
import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol";
77
import { ReentrancyGuardUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol";
8+
import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol";
89

910
import { RLPReader } from "@eth-optimism/contracts-bedrock/src/libraries/rlp/RLPReader.sol";
1011

@@ -14,6 +15,7 @@ import { ReceiptProof } from "../hashi/prover/HashiProverStructs.sol";
1415
import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
1516
import { ICrossChainPaymaster } from "./interfaces/ICrossChainPaymaster.sol";
1617
import { Math } from "@openzeppelin/contracts/utils/math/Math.sol";
18+
import { Arrays } from "@openzeppelin/contracts/utils/Arrays.sol";
1719
import { SapphireTypes } from "./libraries/SapphireTypes.sol";
1820

1921
/**
@@ -32,6 +34,8 @@ contract CrossChainPaymaster is
3234
{
3335
using RLPReader for RLPReader.RLPItem;
3436
using RLPReader for bytes;
37+
using EnumerableSet for EnumerableSet.AddressSet;
38+
using Arrays for uint256[];
3539

3640
// ---------------------------------------------------------------------
3741
// Constants
@@ -42,6 +46,12 @@ contract CrossChainPaymaster is
4246
)
4347
);
4448

49+
/**
50+
* @notice Standard decimal scale for normalizing ROSE/USD feed prices
51+
* @dev All feed prices are normalized to this scale before averaging
52+
*/
53+
uint8 internal constant NORMALIZED_DECIMALS = 18;
54+
4555
// ---------------------------------------------------------------------
4656
// Storage
4757
// ---------------------------------------------------------------------
@@ -53,9 +63,10 @@ contract CrossChainPaymaster is
5363
mapping(address => AggregatorV3Interface) public priceFeeds;
5464

5565
/**
56-
* @notice ROSE/USD price feed (USD per 1 ROSE)
66+
* @notice Set of ROSE/USD price feeds (USD per 1 ROSE) from multiple sources
67+
* @dev Using EnumerableSet to prevent duplicates and allow efficient add/remove
5768
*/
58-
AggregatorV3Interface public roseUsdFeed;
69+
EnumerableSet.AddressSet private _roseUsdFeeds;
5970

6071
/**
6172
* @notice Token decimal mappings (token => decimals)
@@ -97,14 +108,14 @@ contract CrossChainPaymaster is
97108
* @param _shoyuBashi Hashi ShoyuBashi contract address
98109
* @param _limits Distribution limits for payments
99110
* @param _stalenessThreshold Maximum age for price data in seconds
100-
* @param _roseUsdFeed Chainlink Aggregator for ROSE/USD
111+
* @param roseUsdFeeds Array of price feed addresses for ROSE/USD
101112
*/
102113
function initialize(
103114
address _owner,
104115
address _shoyuBashi,
105116
SapphireTypes.DistributionLimits memory _limits,
106117
uint256 _stalenessThreshold,
107-
address _roseUsdFeed
118+
address[] memory roseUsdFeeds
108119
) external initializer {
109120
__ReentrancyGuard_init();
110121
__HashiProverUpgradeable_init(_shoyuBashi);
@@ -114,8 +125,14 @@ contract CrossChainPaymaster is
114125
stalenessThreshold = _stalenessThreshold;
115126
limits = _limits;
116127

117-
if (_roseUsdFeed == address(0)) revert InvalidPriceFeed();
118-
roseUsdFeed = AggregatorV3Interface(_roseUsdFeed);
128+
// Require at least one ROSE/USD feed
129+
if (roseUsdFeeds.length == 0) revert NoRoseUsdFeeds();
130+
131+
// Add all provided feeds to the set
132+
for (uint256 i = 0; i < roseUsdFeeds.length; i++) {
133+
if (roseUsdFeeds[i] == address(0)) revert InvalidPriceFeed();
134+
if (!_roseUsdFeeds.add(roseUsdFeeds[i])) revert DuplicateRoseUsdFeed(roseUsdFeeds[i]);
135+
}
119136

120137
// Transfer ownership to requested owner if different
121138
if (_owner != owner()) {
@@ -141,10 +158,19 @@ contract CrossChainPaymaster is
141158
/**
142159
* @inheritdoc ICrossChainPaymaster
143160
*/
144-
function setRoseUsdFeed(address feed) external onlyOwner override {
161+
function addRoseUsdFeed(address feed) external onlyOwner override {
145162
if (feed == address(0)) revert InvalidPriceFeed();
146-
emit RoseUsdFeedUpdated(address(roseUsdFeed), feed);
147-
roseUsdFeed = AggregatorV3Interface(feed);
163+
if (!_roseUsdFeeds.add(feed)) revert DuplicateRoseUsdFeed(feed);
164+
emit RoseUsdFeedAdded(feed);
165+
}
166+
167+
/**
168+
* @inheritdoc ICrossChainPaymaster
169+
*/
170+
function removeRoseUsdFeed(address feed) external onlyOwner override {
171+
if (_roseUsdFeeds.length() <= 1) revert NoRoseUsdFeeds();
172+
if (!_roseUsdFeeds.remove(feed)) revert RoseUsdFeedNotFound(feed);
173+
emit RoseUsdFeedRemoved(feed);
148174
}
149175

150176
/**
@@ -304,6 +330,27 @@ contract CrossChainPaymaster is
304330
return processedPayments[paymentId];
305331
}
306332

333+
/**
334+
* @inheritdoc ICrossChainPaymaster
335+
*/
336+
function getRoseUsdFeeds() external view override returns (address[] memory) {
337+
return _roseUsdFeeds.values();
338+
}
339+
340+
/**
341+
* @inheritdoc ICrossChainPaymaster
342+
*/
343+
function getRoseUsdFeedCount() external view override returns (uint256) {
344+
return _roseUsdFeeds.length();
345+
}
346+
347+
/**
348+
* @inheritdoc ICrossChainPaymaster
349+
*/
350+
function getRoseUsdFeedAt(uint256 index) external view override returns (address) {
351+
return _roseUsdFeeds.at(index);
352+
}
353+
307354
// Decode PaymentInitiated log: [address, [topics...], data]
308355
/**
309356
* @notice Decodes a PaymentInitiated event log entry from RLP format
@@ -365,36 +412,133 @@ contract CrossChainPaymaster is
365412

366413
/**
367414
* @notice Converts token amount to ROSE amount using Chainlink-style price feeds
415+
* @dev Aggregates ROSE/USD price feeds as follows: uses the median value if
416+
* there are 3 or more valid feeds, the mean if there are 2 valid feeds, and
417+
* the direct value if there is only 1 valid feed. Skips invalid/stale feeds.
368418
* @param token The token address
369419
* @param tokenAmount The amount of tokens to convert
370420
* @return roseAmount The equivalent amount in ROSE
371421
*/
372422
function _convertToRose(address token, uint256 tokenAmount) internal view returns (uint256 roseAmount) {
373423
AggregatorV3Interface tokenUsd = priceFeeds[token];
374424
if (address(tokenUsd) == address(0)) revert NoPriceFeedForToken(token);
375-
if (address(roseUsdFeed) == address(0)) revert InvalidPriceFeed();
425+
if (_roseUsdFeeds.length() == 0) revert NoRoseUsdFeeds();
376426

427+
// Get token price data
377428
(uint80 tRound, int256 tPrice, , uint256 tUpdated, uint80 tAnsweredIn) = tokenUsd.latestRoundData();
378-
(uint80 rRound, int256 rPrice, , uint256 rUpdated, uint80 rAnsweredIn) = roseUsdFeed.latestRoundData();
379429

380-
// Validate prices & rounds
430+
// Validate token price
381431
if (tPrice <= 0) revert InvalidPrice(tPrice);
382-
if (rPrice <= 0) revert InvalidPrice(rPrice);
383-
if (tAnsweredIn < tRound || rAnsweredIn < rRound) revert StalePrice(0, 0);
432+
if (tAnsweredIn < tRound) revert StalePrice(0, 0);
384433
if (tUpdated == 0 || block.timestamp - tUpdated > stalenessThreshold) revert StalePrice(tUpdated, stalenessThreshold);
385-
if (rUpdated == 0 || block.timestamp - rUpdated > stalenessThreshold) revert StalePrice(rUpdated, stalenessThreshold);
386434

387435
uint8 tokenDec = tokenDecimals[token];
388436
if (tokenDec == 0) tokenDec = 18;
389-
390437
uint8 tDec = tokenUsd.decimals();
391-
uint8 rDec = roseUsdFeed.decimals();
438+
439+
// Collect valid ROSE/USD prices from all feeds, normalized to 18 decimals
440+
uint256[] memory normalizedPrices = new uint256[](_roseUsdFeeds.length());
441+
uint256 validFeedCount = 0;
442+
443+
uint256 roseUsdFeedCount = _roseUsdFeeds.length();
444+
for (uint256 i = 0; i < roseUsdFeedCount; i++) {
445+
address feedAddr = _roseUsdFeeds.at(i);
446+
AggregatorV3Interface roseFeed = AggregatorV3Interface(feedAddr);
447+
448+
try roseFeed.latestRoundData() returns (
449+
uint80 rRound,
450+
int256 rPrice,
451+
uint256,
452+
uint256 rUpdated,
453+
uint80 rAnsweredIn
454+
) {
455+
// Validate ROSE price from this feed
456+
if (rPrice <= 0) continue; // Skip invalid price
457+
if (rAnsweredIn < rRound) continue; // Skip stale round
458+
if (rUpdated == 0 || block.timestamp - rUpdated > stalenessThreshold) continue; // Skip stale data
459+
460+
// Normalize price to 18 decimals before storing
461+
uint8 feedDecimals = roseFeed.decimals();
462+
uint256 normalizedPrice;
463+
464+
if (feedDecimals < NORMALIZED_DECIMALS) {
465+
// Scale up: price * 10^(18 - feedDecimals)
466+
normalizedPrice = uint256(rPrice) * (10 ** (NORMALIZED_DECIMALS - feedDecimals));
467+
} else if (feedDecimals > NORMALIZED_DECIMALS) {
468+
// Scale down: price / 10^(feedDecimals - 18)
469+
normalizedPrice = uint256(rPrice) / (10 ** (feedDecimals - NORMALIZED_DECIMALS));
470+
} else {
471+
// Already 18 decimals
472+
normalizedPrice = uint256(rPrice);
473+
}
474+
475+
normalizedPrices[validFeedCount] = normalizedPrice;
476+
validFeedCount++;
477+
} catch {
478+
// Skip feeds that revert
479+
continue;
480+
}
481+
}
482+
483+
// Require at least one valid feed
484+
if (validFeedCount == 0) revert NoValidRoseUsdFeeds();
485+
486+
// Calculate aggregated ROSE/USD price using appropriate method
487+
uint256 avgRosePrice;
488+
if (validFeedCount == 1) {
489+
// Single feed: use directly
490+
avgRosePrice = normalizedPrices[0];
491+
} else if (validFeedCount == 2) {
492+
// Two feeds: calculate mean (equivalent to median for 2 values)
493+
avgRosePrice = Math.average(normalizedPrices[0], normalizedPrices[1]);
494+
} else {
495+
// Three or more feeds: use median for outlier resistance
496+
avgRosePrice = _calculateMedian(normalizedPrices, validFeedCount);
497+
}
392498

393499
// roseAmount = tokenAmount * (tokenUsd / roseUsd) adjusted to 18 decimals
394-
// = tokenAmount * tPrice * 10^rDec * 10^18 / (10^tokenDec * 10^tDec * rPrice)
500+
// = tokenAmount * tPrice * 10^18 * 10^18 / (10^tokenDec * 10^tDec * avgRosePrice)
395501
uint256 num = Math.mulDiv(tokenAmount, uint256(tPrice), 10 ** tokenDec);
396-
num = Math.mulDiv(num, 10 ** rDec, 10 ** tDec);
397-
roseAmount = Math.mulDiv(num, 1e18, uint256(rPrice));
502+
num = Math.mulDiv(num, 10 ** NORMALIZED_DECIMALS, 10 ** tDec);
503+
roseAmount = Math.mulDiv(num, 1e18, avgRosePrice);
504+
}
505+
506+
// ---------------------------------------------------------------------
507+
// Price Aggregation Helpers
508+
// ---------------------------------------------------------------------
509+
510+
/**
511+
* @notice Calculates the median of a price array
512+
* @dev Sorts the array using OpenZeppelin's Arrays.sort() and returns the middle value(s)
513+
* @param prices Array of normalized prices
514+
* @param count Number of valid prices in the array
515+
* @return The median price, if len(prices) is odd or mean of two median prices, if len(price) is even
516+
517+
*/
518+
function _calculateMedian(uint256[] memory prices, uint256 count) internal pure returns (uint256) {
519+
require(count > 0, "Empty price array");
520+
521+
// If count < array length, we need to create a new array with only valid elements
522+
uint256[] memory validPrices;
523+
if (count < prices.length) {
524+
validPrices = new uint256[](count);
525+
for (uint256 i = 0; i < count; i++) {
526+
validPrices[i] = prices[i];
527+
}
528+
} else {
529+
validPrices = prices;
530+
}
531+
532+
validPrices.sort();
533+
534+
// Calculate median
535+
if (count % 2 == 1) {
536+
// Odd count: return middle element
537+
return validPrices[count / 2];
538+
} else {
539+
// Even count: return average of two middle elements
540+
return Math.average(validPrices[count / 2 - 1], validPrices[count / 2]);
541+
}
398542
}
399543

400544
/// @notice Accepts ROSE funding

0 commit comments

Comments
 (0)