Skip to content

Commit dc27a02

Browse files
author
cwsnt
committed
SOV-5246: enable usdt0 lending pool
1 parent 57c1658 commit dc27a02

16 files changed

Lines changed: 2317 additions & 5 deletions

File tree

contracts/feeds/USDT0PriceFeed.sol

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
pragma solidity 0.5.17;
2+
3+
/**
4+
* @title USDT0 Price Feed Wrapper
5+
* @notice Wraps the Redstone USDT price feed and normalizes from 8 decimals to 18 decimals
6+
* for compatibility with Sovryn's PriceFeeds contract.
7+
*
8+
* Redstone USDT Price Feed on RSK Mainnet: 0x09639692ce6Ff12a06cA3AE9a24B3aAE4cD80dc8
9+
* Contract: RootstockPriceFeedUsdtWithoutRoundsV1
10+
* - latestRoundData() returns (roundId, answer, startedAt, updatedAt, answeredInRound)
11+
* - answer is in 8 decimals
12+
* - decimals() returns 8
13+
*
14+
* This wrapper:
15+
* 1. Validates price data is not stale or invalid
16+
* 2. Scales the price from 8 decimals to 18 decimals for Sovryn compatibility
17+
*/
18+
19+
interface IRedstoneOracle {
20+
function latestRoundData()
21+
external
22+
view
23+
returns (
24+
uint80 roundId,
25+
int256 answer,
26+
uint256 startedAt,
27+
uint256 updatedAt,
28+
uint80 answeredInRound
29+
);
30+
function decimals() external view returns (uint8);
31+
}
32+
33+
contract USDT0PriceFeed {
34+
IRedstoneOracle public oracle;
35+
36+
/// @dev Maximum acceptable age for price data (24 hours)
37+
/// After this time, price is considered stale (referred to the heartbet of redstone)
38+
uint256 public constant MAX_PRICE_AGE = 24 hours;
39+
40+
/**
41+
* @notice Constructor
42+
* @param _oracle The address of the Redstone USDT price feed (0x09639692ce6Ff12a06cA3AE9a24B3aAE4cD80dc8)
43+
*/
44+
constructor(address _oracle) public {
45+
require(_oracle != address(0), "Invalid oracle address");
46+
oracle = IRedstoneOracle(_oracle);
47+
}
48+
49+
/**
50+
* @notice Get the latest price from Redstone oracle and scale to 18 decimals
51+
* @dev Performs validation checks on the oracle data:
52+
* - Price must be greater than 0
53+
* - Price must not be stale (updated within MAX_PRICE_AGE)
54+
* - Round must be complete (answeredInRound >= roundId)
55+
*
56+
* Redstone returns 8 decimals (e.g., 99937000 = $0.99937)
57+
* We scale to 18 decimals (e.g., 999370000000000000 = $0.99937)
58+
*
59+
* @return The validated price with 18 decimals
60+
*/
61+
function latestAnswer() external view returns (uint256) {
62+
(uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound) = oracle
63+
.latestRoundData();
64+
65+
// Validate price data
66+
require(answer > 0, "Invalid price: answer <= 0");
67+
require(updatedAt > 0, "Invalid price: updatedAt = 0");
68+
require(answeredInRound >= roundId, "Stale price: round not complete");
69+
require(block.timestamp - updatedAt <= MAX_PRICE_AGE, "Stale price: too old");
70+
71+
uint256 price = uint256(answer);
72+
uint8 oracleDecimals = oracle.decimals();
73+
74+
// Scale from oracle decimals to 18 decimals
75+
// price * 10^(18 - oracleDecimals)
76+
if (oracleDecimals < 18) {
77+
return price * (10 ** (18 - uint256(oracleDecimals)));
78+
} else if (oracleDecimals > 18) {
79+
return price / (10 ** (uint256(oracleDecimals) - 18));
80+
} else {
81+
return price;
82+
}
83+
}
84+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* Mock Redstone Oracle for testing USDT0PriceFeed wrapper
3+
* Simulates Redstone's USDT price feed which returns 8 decimals
4+
* Implements latestRoundData() for realistic testing
5+
*/
6+
7+
pragma solidity 0.5.17;
8+
9+
contract MockRedstoneOracle {
10+
int256 private price;
11+
uint80 private currentRound;
12+
uint256 private lastUpdateTime;
13+
14+
constructor() public {
15+
price = 100000000; // $1.00 with 8 decimals
16+
currentRound = 1;
17+
lastUpdateTime = block.timestamp;
18+
}
19+
20+
function setPrice(int256 _price) external {
21+
price = _price;
22+
currentRound++;
23+
lastUpdateTime = block.timestamp;
24+
}
25+
26+
function setUpdatedAt(uint256 _timestamp) external {
27+
lastUpdateTime = _timestamp;
28+
}
29+
30+
function latestRoundData()
31+
external
32+
view
33+
returns (
34+
uint80 roundId,
35+
int256 answer,
36+
uint256 startedAt,
37+
uint256 updatedAt,
38+
uint80 answeredInRound
39+
)
40+
{
41+
return (currentRound, price, lastUpdateTime, lastUpdateTime, currentRound);
42+
}
43+
44+
/// @dev Always returns 8 decimals to match real Redstone oracle
45+
function decimals() external pure returns (uint8) {
46+
return 8;
47+
}
48+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
const hre = require("hardhat");
2+
const { setDeploymentMetaData } = require("../helpers/helpers");
3+
4+
/**
5+
* Deploy USDT0PriceFeed wrapper contract
6+
*
7+
* This wrapper:
8+
* 1. Normalizes Redstone USDT price feed from 8 decimals to 18 decimals
9+
* 2. Validates price data to prevent stale/invalid prices (security-critical)
10+
*
11+
* Security features:
12+
* - Uses latestRoundData() for full validation
13+
* - Checks answer > 0 (prevents zero price exploits)
14+
* - Validates freshness (24-hour staleness check)
15+
* - Verifies round completion (prevents incomplete data usage)
16+
*
17+
* Redstone USDT Price Feed on RSK Mainnet: 0x09639692ce6Ff12a06cA3AE9a24B3aAE4cD80dc8
18+
*/
19+
module.exports = async (hre) => {
20+
const { deployments, getNamedAccounts } = hre;
21+
const { deploy, get } = deployments;
22+
const { deployer } = await getNamedAccounts();
23+
24+
// Get Redstone USDT oracle from deployment
25+
const redstoneOracle = await get("RedStoneUSDT0Oracle");
26+
27+
console.log("\n--- Deploying USDT0PriceFeed Wrapper ---");
28+
console.log(`Deployer: ${deployer}`);
29+
console.log(`Redstone Oracle: ${redstoneOracle.address}`);
30+
31+
const usdt0PriceFeed = await deploy("USDT0PriceFeeds", {
32+
contract: "USDT0PriceFeed",
33+
from: deployer,
34+
args: [redstoneOracle.address],
35+
log: true,
36+
skipIfAlreadyDeployed: true,
37+
});
38+
39+
if (usdt0PriceFeed.newlyDeployed) {
40+
console.log(`✅ USDT0PriceFeed deployed at: ${usdt0PriceFeed.address}`);
41+
42+
// Verify the wrapper is working correctly
43+
const USDT0PriceFeed = await hre.ethers.getContractAt(
44+
"USDT0PriceFeed",
45+
usdt0PriceFeed.address
46+
);
47+
48+
try {
49+
const price = await USDT0PriceFeed.latestAnswer();
50+
console.log(` Price (18 decimals): ${hre.ethers.utils.formatEther(price)}`);
51+
console.log(` Raw value: ${price.toString()}`);
52+
53+
// Expected: ~999370000000000000 (0.99937 with 18 decimals)
54+
const expectedMin = hre.ethers.utils.parseEther("0.9"); // $0.90
55+
const expectedMax = hre.ethers.utils.parseEther("1.1"); // $1.10
56+
57+
if (price.gte(expectedMin) && price.lte(expectedMax)) {
58+
console.log(" ✅ Price feed is working correctly (within expected range)");
59+
} else {
60+
console.log(" ⚠️ Warning: Price is outside expected range ($0.90 - $1.10)");
61+
}
62+
} catch (error) {
63+
console.log(` ⚠️ Warning: Could not verify price feed: ${error.message}`);
64+
}
65+
66+
await setDeploymentMetaData("USDT0PriceFeeds", {
67+
contractAddress: usdt0PriceFeed.address,
68+
description: "USDT0 Price Feed Wrapper (Redstone -> 18 decimals)",
69+
redstoneOracle: redstoneOracle.address,
70+
});
71+
} else {
72+
console.log(`USDT0PriceFeed already deployed at: ${usdt0PriceFeed.address}`);
73+
}
74+
};
75+
76+
module.exports.tags = ["USDT0PriceFeed", "PriceFeeds"];
77+
module.exports.dependencies = ["RedStoneUSDT0Oracle"];

0 commit comments

Comments
 (0)