Skip to content

Commit f3ab7b7

Browse files
authored
Merge pull request #250 from sprintertech/feat/paxos-oracle
feat: paxos oracle
2 parents 66aded9 + 9fa2fd3 commit f3ab7b7

7 files changed

Lines changed: 342 additions & 0 deletions

File tree

contracts/PaxosOracle.sol

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// SPDX-License-Identifier: LGPL-3.0-only
2+
pragma solidity 0.8.28;
3+
4+
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
5+
import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
6+
import {IOracle} from "./interfaces/IOracle.sol";
7+
8+
/// @title Oracle valuing Paxos-issued stablecoins (USDG, PYUSD) at a hardcoded 1:1 ratio to USDC.
9+
/// @author Sprinter
10+
/// @notice Implements {IOracle} for an admin-managed set of Paxos stablecoins, each pegged exactly 1:1 to USDC.
11+
/// `getAssetValue` returns the USDC-denominated value of a given amount, adjusting only for any
12+
/// decimal mismatch between the stablecoin and USDC. The price ratio is immutable at 1:1.
13+
/// @dev `assetId` is the token address left-padded to bytes32 to allow non evm tokens in the future
14+
contract PaxosOracle is IOracle, AccessControl {
15+
struct AssetConfig {
16+
bool supported;
17+
uint8 decimals;
18+
}
19+
20+
struct AssetParams {
21+
bytes32 assetId;
22+
uint8 decimals;
23+
}
24+
25+
/// @notice USDC token used as the value denomination.
26+
IERC20Metadata public immutable USDC;
27+
/// @notice Decimals of USDC, fetched once at deployment and used to scale returned values.
28+
uint8 public immutable USDC_DECIMALS;
29+
30+
uint8 internal constant MAX_DECIMALS = 18;
31+
32+
mapping(bytes32 assetId => AssetConfig) public assetConfig;
33+
34+
event AssetAdded(bytes32 indexed assetId, uint8 decimals);
35+
event AssetRemoved(bytes32 indexed assetId);
36+
37+
error ZeroAddress();
38+
error AssetAlreadySupported(bytes32 assetId);
39+
error AssetNotSupported(bytes32 assetId);
40+
error DecimalsTooLarge(uint8 decimals);
41+
42+
/// @param admin Super-admin able to add/remove supported stablecoins.
43+
/// @param usdc Address of the USDC token, used as the value denomination (its decimals).
44+
/// @param initialAssets Initial set of stablecoins to support (e.g. USDG, PYUSD).
45+
constructor(address admin, address usdc, AssetParams[] memory initialAssets) {
46+
require(admin != address(0), ZeroAddress());
47+
require(usdc != address(0), ZeroAddress());
48+
_grantRole(DEFAULT_ADMIN_ROLE, admin);
49+
USDC = IERC20Metadata(usdc);
50+
USDC_DECIMALS = IERC20Metadata(usdc).decimals();
51+
for (uint256 i = 0; i < initialAssets.length; ++i) {
52+
_addAsset(initialAssets[i].assetId, initialAssets[i].decimals);
53+
}
54+
}
55+
56+
/// @notice Registers a stablecoin to be valued 1:1 against USDC.
57+
/// @param assetId The asset identifier (an EVM token address left-padded to bytes32).
58+
/// @param decimals The number of decimals the stablecoin uses. Provided explicitly so that
59+
/// non-EVM assets, whose `decimals()` cannot be queried on-chain, can also be supported.
60+
function addAsset(bytes32 assetId, uint8 decimals) external onlyRole(DEFAULT_ADMIN_ROLE) {
61+
_addAsset(assetId, decimals);
62+
}
63+
64+
/// @notice Removes a previously registered stablecoin.
65+
/// @param assetId The asset identifier to remove.
66+
function removeAsset(bytes32 assetId) external onlyRole(DEFAULT_ADMIN_ROLE) {
67+
require(assetConfig[assetId].supported, AssetNotSupported(assetId));
68+
delete assetConfig[assetId];
69+
emit AssetRemoved(assetId);
70+
}
71+
72+
/// @notice Returns the USDC-denominated value of `amount` units of the supported stablecoin `assetId`.
73+
/// @dev Reverts for any asset that is not currently supported.
74+
function getAssetValue(bytes32 assetId, uint256 amount) external view returns (uint256) {
75+
AssetConfig memory config = assetConfig[assetId];
76+
require(config.supported, AssetNotSupported(assetId));
77+
if (config.decimals == USDC_DECIMALS) {
78+
return amount;
79+
}
80+
if (config.decimals > USDC_DECIMALS) {
81+
return amount / (10 ** (config.decimals - USDC_DECIMALS));
82+
}
83+
return amount * (10 ** (USDC_DECIMALS - config.decimals));
84+
}
85+
86+
/// @notice Returns true if `assetId` is currently valued by this oracle.
87+
function isSupported(bytes32 assetId) external view returns (bool) {
88+
return assetConfig[assetId].supported;
89+
}
90+
91+
function _addAsset(bytes32 assetId, uint8 decimals) internal {
92+
require(!assetConfig[assetId].supported, AssetAlreadySupported(assetId));
93+
require(decimals <= MAX_DECIMALS, DecimalsTooLarge(decimals));
94+
assetConfig[assetId] = AssetConfig({supported: true, decimals: decimals});
95+
emit AssetAdded(assetId, decimals);
96+
}
97+
}

contracts/interfaces/IOracle.sol

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// SPDX-License-Identifier: LGPL-3.0-only
2+
pragma solidity 0.8.28;
3+
4+
/// @title Price oracle interface.
5+
/// @author Oleksii Matiiasevych <oleksii@sprinter.tech>
6+
interface IOracle {
7+
function getAssetValue(bytes32 assetId, uint256 amount) external view returns (uint256);
8+
}

network.config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,8 @@ export enum Token {
123123
WETH = "WETH",
124124
WBTC = "WBTC",
125125
EURe = "EURe",
126+
USDG = "USDG",
127+
PYUSD = "PYUSD",
126128
}
127129

128130
interface CCTPConfig {
@@ -230,6 +232,8 @@ export interface NetworkConfig {
230232
[Token.WETH]?: TokenInfo;
231233
[Token.WBTC]?: TokenInfo;
232234
[Token.EURe]?: TokenInfo;
235+
[Token.USDG]?: TokenInfo;
236+
[Token.PYUSD]?: TokenInfo;
233237
};
234238
WrappedNativeToken: string;
235239
RebalancerRoutes?: RebalancerRoutesConfig;
@@ -287,6 +291,8 @@ export const networkConfig: NetworksConfig = {
287291
DAI: tokenInfo("0x6B175474E89094C44Da98b954EedeAC495271d0F", 18),
288292
WETH: tokenInfo("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 18),
289293
WBTC: tokenInfo("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", 8),
294+
USDG: tokenInfo("0xe343167631d89B6Ffc58B88d6b7fB0228795491D", 6),
295+
PYUSD: tokenInfo("0x6c3ea9036406852006290770BEdFcAbA0e23A0e8", 6),
290296
},
291297
WrappedNativeToken: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
292298
IsTest: false,
@@ -423,6 +429,8 @@ export const networkConfig: NetworksConfig = {
423429
DAI: tokenInfo("0x6B175474E89094C44Da98b954EedeAC495271d0F", 18),
424430
WETH: tokenInfo("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", 18),
425431
WBTC: tokenInfo("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599", 8),
432+
USDG: tokenInfo("0xe343167631d89B6Ffc58B88d6b7fB0228795491D", 6),
433+
PYUSD: tokenInfo("0x6c3ea9036406852006290770BEdFcAbA0e23A0e8", 6),
426434
},
427435
WrappedNativeToken: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
428436
IsTest: false,

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@
7171
"deploy-spark-stage-repayer-opmainnet": "STANDALONE_REPAYER_ENV=SparkStage hardhat run ./scripts/deployStandaloneRepayer.ts --network OP_MAINNET",
7272
"deploy-usdc-processor-ethereum": "hardhat run ./scripts/deployUSDCProcessor.ts --network ETHEREUM",
7373
"deploy-usdc-processor-ethereum-stage": "DEPLOY_TYPE=STAGE hardhat run ./scripts/deployUSDCProcessor.ts --network ETHEREUM",
74+
"deploy-paxosoracle-ethereum": "hardhat run ./scripts/deployPaxosOracle.ts --network ETHEREUM",
75+
"deploy-paxosoracle-ethereum-stage": "DEPLOY_TYPE=STAGE hardhat run ./scripts/deployPaxosOracle.ts --network ETHEREUM",
7476
"upgrade-liquidityhub-basesepolia": "hardhat run ./scripts/upgradeLiquidityHub.ts --network BASE_SEPOLIA",
7577
"upgrade-liquidityhub-base": "hardhat run ./scripts/upgradeLiquidityHub.ts --network BASE",
7678
"upgrade-liquidityhub-base-stage": "DEPLOY_TYPE=STAGE hardhat run ./scripts/upgradeLiquidityHub.ts --network BASE",
@@ -213,6 +215,8 @@
213215
"dry:deploy-spark-stage-repayer-opmainnet": "DRY_RUN=OP_MAINNET STANDALONE_REPAYER_ENV=SparkStage VERIFY=false ts-node --files ./scripts/deployStandaloneRepayer.ts",
214216
"dry:deploy-usdc-processor-ethereum-stage": "DRY_RUN=ETHEREUM VERIFY=false DEPLOY_TYPE=STAGE ts-node --files ./scripts/deployUSDCProcessor.ts",
215217
"dry:deploy-usdc-processor-ethereum": "DRY_RUN=ETHEREUM VERIFY=false ts-node --files ./scripts/deployUSDCProcessor.ts",
218+
"dry:deploy-paxosoracle-ethereum": "DRY_RUN=ETHEREUM VERIFY=false ts-node --files ./scripts/deployPaxosOracle.ts",
219+
"dry:deploy-paxosoracle-ethereum-stage": "DRY_RUN=ETHEREUM DEPLOY_TYPE=STAGE VERIFY=false ts-node --files ./scripts/deployPaxosOracle.ts",
216220
"dry:upgrade-liquidityhub-basesepolia": "DRY_RUN=BASE_SEPOLIA VERIFY=false ts-node --files ./scripts/upgradeLiquidityHub.ts",
217221
"dry:upgrade-liquidityhub-base": "DRY_RUN=BASE VERIFY=false ts-node --files ./scripts/upgradeLiquidityHub.ts",
218222
"dry:upgrade-liquidityhub-base-stage": "DRY_RUN=BASE DEPLOY_TYPE=STAGE VERIFY=false ts-node --files ./scripts/upgradeLiquidityHub.ts",

scripts/deployPaxosOracle.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import dotenv from "dotenv";
2+
dotenv.config();
3+
import hre from "hardhat";
4+
import {getVerifier, getHardhatNetworkConfig, getNetworkConfig, logDeployers} from "./helpers";
5+
import {isSet, assert, addressToBytes32} from "./common";
6+
import {PaxosOracle} from "../typechain-types";
7+
import {Network, NetworkConfig, TokenInfo} from "../network.config";
8+
9+
export async function main() {
10+
const [deployer] = await hre.ethers.getSigners();
11+
12+
assert(isSet(process.env.DEPLOY_ID), "DEPLOY_ID must be set");
13+
const verifier = getVerifier(process.env.DEPLOY_ID);
14+
console.log(`Deployment ID: ${process.env.DEPLOY_ID}`);
15+
let id = "PaxosOracle";
16+
17+
let network: Network;
18+
let config: NetworkConfig;
19+
console.log("Deploying PaxosOracle");
20+
({network, config} = await getNetworkConfig());
21+
if (!network) {
22+
({network, config} = await getHardhatNetworkConfig());
23+
id += "-DeployTest";
24+
}
25+
await logDeployers();
26+
27+
const usdc = config.Tokens.USDC.Address;
28+
// Only the Paxos stablecoins configured for this network are registered as initial assets.
29+
const paxosStablecoins: TokenInfo[] = [config.Tokens.USDG, config.Tokens.PYUSD]
30+
.filter((token): token is TokenInfo => Boolean(token));
31+
console.log(`USDC: ${usdc}`);
32+
console.log(
33+
`Paxos stablecoins (1:1 to USDC): ${paxosStablecoins.map(t => t.Address).join(", ") || "none configured"}`
34+
);
35+
36+
const initialAssets = paxosStablecoins.map(t => ({
37+
assetId: addressToBytes32(t.Address),
38+
decimals: t.Decimals,
39+
}));
40+
41+
const paxosOracle: PaxosOracle = (await verifier.deployX(
42+
"PaxosOracle",
43+
deployer,
44+
{},
45+
[
46+
config.Admin,
47+
usdc,
48+
initialAssets,
49+
],
50+
id
51+
)) as PaxosOracle;
52+
console.log(`${id}: ${paxosOracle.target}`);
53+
54+
await verifier.verify(process.env.VERIFY === "true");
55+
}
56+
57+
if (process.env.SCRIPT_ENV !== "CI") {
58+
main();
59+
}

scripts/test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {main as deployUSDCPublicPool} from "./deployUSDCPublicPool";
1717
import {main as deployERC4626Adapter} from "./deployERC4626Adapter";
1818
import {main as deployUSDCProcessor} from "./deployUSDCProcessor";
1919
import {main as upgradeUSDCProcessor} from "./upgradeUSDCProcessor";
20+
import {main as deployPaxosOracle} from "./deployPaxosOracle";
2021

2122
async function main() {
2223
console.log("Test deploy.");
@@ -47,6 +48,8 @@ async function main() {
4748
await deployUSDCProcessor();
4849
console.log("Test upgradeUSDCProcessor.");
4950
await upgradeUSDCProcessor();
51+
console.log("Test deployPaxosOracle.");
52+
await deployPaxosOracle();
5053
console.log("Test deployStandaloneRepayer.");
5154
process.env.STANDALONE_REPAYER_ENV = "SparkStage";
5255
await deployStandaloneRepayer();

0 commit comments

Comments
 (0)