diff --git a/common/configuration.ts b/common/configuration.ts index 7627b43df..5989f9bae 100644 --- a/common/configuration.ts +++ b/common/configuration.ts @@ -99,6 +99,7 @@ export interface ITokens { yvCurveUSDCcrvUSD?: string wsuperOETHb?: string wOETH?: string + wOUSD?: string pyUSD?: string aEthPyUSD?: string @@ -331,6 +332,7 @@ export const networkConfig: { [key: string]: INetworkConfig } = { USDS: '0xdC035D45d973E3EC169d2276DDab16f1e407384F', sUSDS: '0xa3931d71877C0E7a3148CB7Eb4463524FEc27fbD', wOETH: '0xDcEe70654261AF21C44c093C300eD3Bb97b78192', + wOUSD: '0xD2af830E8CBdFed6CC11Bab697bB25496ed6FA62', wcvx3Pool: '0x24CDc6b4Edd3E496b7283D94D93119983A61056a', // our wrapper wcvxPayPool: '0x511daB8150966aFfE15F0a5bFfBa7F4d2b62DEd4', // our wrapper wcvxCrvUSDUSDC: '0x6ad24C0B8fD4B594C6009A7F7F48450d9F56c6b8', // our wrapper diff --git a/common/ousd.ts b/common/ousd.ts new file mode 100644 index 000000000..01aeea579 --- /dev/null +++ b/common/ousd.ts @@ -0,0 +1,19 @@ +import { utils } from 'ethers' +import { networkConfig } from './configuration' +import { fp } from './numbers' + +// Ethereum mainnet only. Shared by deployment, verification, and tests. +export const ousdCollateralConfig = { + erc20: networkConfig['1'].tokens.wOUSD!, + targetName: utils.formatBytes32String('USD'), + priceTimeout: 604800, + // Required by Asset's constructor, but never read by OUSDCollateral. + chainlinkFeed: networkConfig['1'].chainlinkFeeds.USDC!, + oracleTimeout: 1, // Delay before inherited price decay, plus ORACLE_TIMEOUT_BUFFER + oracleError: fp('0.005'), // Pricing margin only; does not cover an OUSD depeg + maxTradeVolume: fp('1e6'), + defaultThreshold: 0, // No depeg detection + delayUntilDefault: 86400, +} + +export const ousdRevenueHiding = fp('1e-4') diff --git a/contracts/plugins/assets/origin/OUSD.md b/contracts/plugins/assets/origin/OUSD.md new file mode 100644 index 000000000..0ae16e523 --- /dev/null +++ b/contracts/plugins/assets/origin/OUSD.md @@ -0,0 +1,56 @@ +# Origin Dollar collateral + +`OUSDCollateral` holds **wOUSD** on Ethereum mainnet. Its reference is OUSD; its target +and unit of account are USD. It inherits the linear ERC-4626 conversion and revenue +hiding from `ERC4626FiatCollateral`. + +## Pricing and defaults + +The plugin **assumes 1 OUSD = 1 USD**. Its central USD/wOUSD price is the current +OUSD/wOUSD conversion ratio; `oracleError` widens this into low/high price estimates. +It reads no external price feed and cannot detect an OUSD depeg. The pricing margin +is not a bound on possible depeg losses. + +A decrease below the exposed reference ratio causes an immediate, irreversible hard +default. Small decreases within revenue hiding are tolerated. Backing losses that +do not lower the wrapper ratio are not detected by this mechanism. + +`chainlinkFeed` is retained because the inherited constructor requires a nonzero +address, but is never called. `oracleTimeout` must also be positive: it still enters +the inherited saved-price decay delay (`oracleTimeout + 300 seconds`) if wrapper +pricing fails. A wrapper conversion revert with a reason causes a hard default; +empty reverts propagate, following the parent contract's handling of possible +out-of-gas failures. + +The shared configuration in `common/ousd.ts` uses a 0.5% pricing margin, 0.01% revenue +hiding, zero default threshold, one-second oracle timeout, seven-day price decay, +24-hour delay until default and $1m maximum trade volume. These are deployment +defaults, not a collateral risk assessment. + +## Integration + +Reserve transfers wOUSD directly on RToken redemption and sells wOUSD during +recollateralization. This plugin does not call Origin's withdrawal queue or monitor +capital pauses. Exit liquidity and the underlying strategies remain economic risks. + +The [Origin contract registry](https://docs.originprotocol.com/registry/contracts/ousd-registry) +identifies wOUSD at `0xD2af830E8CBdFed6CC11Bab697bB25496ed6FA62` and OUSD at +`0x2A8e1E676Ec238d8A992307B495b45B3fEAa5e86`. +[WrappedOusd](https://github.com/OriginProtocol/origin-dollar/blob/master/contracts/contracts/token/WrappedOusd.sol) +inherits Origin's WOETH wrapper implementation. + +Deploy using `scripts/deployment/phase2-assets/collaterals/deploy_origin_ousd.ts` +after the standard deployment prerequisites; verify using +`scripts/verification/collateral-plugins/verify_ousd.ts`. Both consume the same +constructor configuration and support Ethereum mainnet only. + +## Tests + +```sh +FORK= yarn hardhat test test/plugins/OUSDCollateral.test.ts +PROTO_IMPL=1 FORK=1 FORK_NETWORK=mainnet FORK_BLOCK=22164000 yarn hardhat test test/plugins/individual-collateral/origin/OUSDCollateral.test.ts +``` + +The fork suite requires an archive-capable `MAINNET_RPC_URL`. It uses real OUSD +transferred from the Curve OUSD/3CRV pool and wraps it through wOUSD's deposit +interface. It covers the ratio, transfers, registration, issuance and redemption. diff --git a/contracts/plugins/assets/origin/OUSDCollateral.sol b/contracts/plugins/assets/origin/OUSDCollateral.sol new file mode 100644 index 000000000..d825dee79 --- /dev/null +++ b/contracts/plugins/assets/origin/OUSDCollateral.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +import "../ERC4626FiatCollateral.sol"; +import "../../../libraries/Fixed.sol"; + +/** + * @title Origin Dollar Collateral for Mainnet + * @notice tok = wOUSD, ref = OUSD, tar = USD, UoA = USD + * @dev WARNING: Assumes 1 OUSD = 1 USD. This plugin does not detect OUSD depegs or + * backing losses that do not reduce the wrapper's OUSD/wOUSD exchange rate. + */ +contract OUSDCollateral is ERC4626FiatCollateral { + using FixLib for uint192; + + /// @param config.chainlinkFeed Ignored, but must be nonzero for Asset validation + /// @param config.oracleTimeout Delays saved-price decay on wrapper failure; no feed expiry + /// @param config.oracleError Pricing margin, not protection against an OUSD depeg + /// @param revenueHiding {1} Maximum fraction of refPerTok to hide + // solhint-disable no-empty-blocks + constructor(CollateralConfig memory config, uint192 revenueHiding) + ERC4626FiatCollateral(config, revenueHiding) + {} + + // solhint-enable no-empty-blocks + + /// @return low {UoA/tok} The low price estimate + /// @return high {UoA/tok} The high price estimate + /// @return pegPrice {target/ref} Assumes 1 USD/OUSD + function tryPrice() + external + view + override + returns ( + uint192 low, + uint192 high, + uint192 pegPrice + ) + { + // {UoA/tok} = {ref/tok}, assuming 1 USD/OUSD + uint192 p = underlyingRefPerTok(); + uint192 err = p.mul(oracleError, CEIL); + return (p - err, p + err, FIX_ONE); + } +} diff --git a/contracts/plugins/assets/origin/README.md b/contracts/plugins/assets/origin/README.md index ac4aa772f..0e2fb0c48 100644 --- a/contracts/plugins/assets/origin/README.md +++ b/contracts/plugins/assets/origin/README.md @@ -43,3 +43,5 @@ On base: ### claimRewards() There are no rewards to claim from `wOETH` and `wsuperOETH`, all yield is already included in the ERC4626 assets appreciation. + +For the fixed-peg wOUSD collateral plugin, see [Origin Dollar collateral](./OUSD.md). diff --git a/contracts/plugins/mocks/ERC4626Mock.sol b/contracts/plugins/mocks/ERC4626Mock.sol new file mode 100644 index 000000000..b3a77f60f --- /dev/null +++ b/contracts/plugins/mocks/ERC4626Mock.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: BlueOak-1.0.0 +pragma solidity 0.8.28; + +import "@openzeppelin/contracts/utils/math/Math.sol"; +import "./ERC20Mock.sol"; + +/// @dev Configurable ERC4626 pricing surface; minting is independent of assets for tests. +contract ERC4626Mock is ERC20Mock { + address public immutable asset; + uint8 private immutable shareDecimals; + uint256 public assetsPerShare; + uint8 public revertMode; + + constructor( + address asset_, + uint8 shareDecimals_, + uint256 assetsPerShare_ + ) ERC20Mock("Mock vault", "VAULT") { + asset = asset_; + shareDecimals = shareDecimals_; + assetsPerShare = assetsPerShare_; + } + + function decimals() public view override returns (uint8) { + return shareDecimals; + } + + function setAssetsPerShare(uint256 value) external { + assetsPerShare = value; + } + + function setRevertMode(uint8 value) external { + revertMode = value; + } + + function convertToAssets(uint256 shares) external view returns (uint256) { + require(revertMode != 1, "wrapper unavailable"); + // solhint-disable-next-line reason-string + if (revertMode == 2) revert(); + return Math.mulDiv(shares, assetsPerShare, 10**shareDecimals); + } +} diff --git a/scripts/deployment/phase2-assets/collaterals/deploy_origin_ousd.ts b/scripts/deployment/phase2-assets/collaterals/deploy_origin_ousd.ts new file mode 100644 index 000000000..9e262495b --- /dev/null +++ b/scripts/deployment/phase2-assets/collaterals/deploy_origin_ousd.ts @@ -0,0 +1,43 @@ +import fs from 'fs' +import hre from 'hardhat' +import { getChainId } from '../../../../common/blockchain-utils' +import { CollateralStatus } from '../../../../common/constants' +import { ousdCollateralConfig, ousdRevenueHiding } from '../../../../common/ousd' +import { + fileExists, + getAssetCollDeploymentFilename, + getDeploymentFile, + getDeploymentFilename, + IAssetCollDeployments, +} from '../../common' + +async function main() { + const chainId = await getChainId(hre) + if (chainId !== '1') throw new Error(`Unsupported chainId: ${chainId}`) + + const phase1File = getDeploymentFilename(chainId) + if (!fileExists(phase1File)) throw new Error(`${phase1File} doesn't exist yet. Run phase 1`) + + const filename = getAssetCollDeploymentFilename(chainId) + const deployments = getDeploymentFile(filename) as IAssetCollDeployments + const [deployer] = await hre.ethers.getSigners() + console.log(`Deploying wOUSD collateral on ${hre.network.name} with ${deployer.address}`) + + const factory = await hre.ethers.getContractFactory('OUSDCollateral', deployer) + const collateral = await factory.deploy(ousdCollateralConfig, ousdRevenueHiding) + await collateral.deployed() + await (await collateral.refresh()).wait() + if ((await collateral.status()) !== CollateralStatus.SOUND) { + throw new Error('wOUSD collateral is not SOUND') + } + + deployments.collateral.wOUSD = collateral.address + deployments.erc20s.wOUSD = ousdCollateralConfig.erc20 + fs.writeFileSync(filename, JSON.stringify(deployments, null, 2)) + console.log(`Deployed wOUSD collateral: ${collateral.address}; saved to ${filename}`) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scripts/verification/collateral-plugins/verify_ousd.ts b/scripts/verification/collateral-plugins/verify_ousd.ts new file mode 100644 index 000000000..bb58e8189 --- /dev/null +++ b/scripts/verification/collateral-plugins/verify_ousd.ts @@ -0,0 +1,33 @@ +import hre from 'hardhat' +import { getChainId } from '../../../common/blockchain-utils' +import { developmentChains } from '../../../common/configuration' +import { ousdCollateralConfig, ousdRevenueHiding } from '../../../common/ousd' +import { + getAssetCollDeploymentFilename, + getDeploymentFile, + IAssetCollDeployments, +} from '../../deployment/common' +import { verifyContract } from '../../deployment/utils' + +async function main() { + const chainId = await getChainId(hre) + if (chainId !== '1' || developmentChains.includes(hre.network.name)) { + throw new Error(`Unsupported verification network: ${hre.network.name} (${chainId})`) + } + const deployments = getDeploymentFile( + getAssetCollDeploymentFilename(chainId) + ) as IAssetCollDeployments + if (!deployments.collateral.wOUSD) throw new Error('Missing wOUSD collateral deployment') + + await verifyContract( + Number(chainId), + deployments.collateral.wOUSD, + [ousdCollateralConfig, ousdRevenueHiding], + 'contracts/plugins/assets/origin/OUSDCollateral.sol:OUSDCollateral' + ) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/test/plugins/OUSDCollateral.test.ts b/test/plugins/OUSDCollateral.test.ts new file mode 100644 index 000000000..072ce1b25 --- /dev/null +++ b/test/plugins/OUSDCollateral.test.ts @@ -0,0 +1,182 @@ +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers' +import { expect } from 'chai' +import { ethers } from 'hardhat' +import { CollateralStatus, MAX_UINT192, ZERO_ADDRESS } from '../../common/constants' +import { bn, fp } from '../../common/numbers' +import { ousdCollateralConfig, ousdRevenueHiding } from '../../common/ousd' + +describe('OUSDCollateral', () => { + async function fixture() { + const asset = await ( + await ethers.getContractFactory('ERC20Mock') + ).deploy('Origin Dollar', 'OUSD') + const vault = await ( + await ethers.getContractFactory('ERC4626Mock') + ).deploy(asset.address, 18, fp('1.2')) + const feed = await (await ethers.getContractFactory('MockV3Aggregator')).deploy(8, bn('1e8')) + const factory = await ethers.getContractFactory('OUSDCollateral') + const config = { ...ousdCollateralConfig, erc20: vault.address, chainlinkFeed: feed.address } + const collateral = await factory.deploy(config, ousdRevenueHiding) + await collateral.refresh() + return { asset, vault, feed, factory, config, collateral } + } + + it('prices the full wrapper ratio at a constant USD peg, with outward-rounded bounds', async () => { + const { vault, collateral } = await loadFixture(fixture) + const ratio = fp('1.234567890123456789') + await vault.setAssetsPerShare(ratio) + const error = ratio.mul(ousdCollateralConfig.oracleError).add(fp('1').sub(1)).div(fp('1')) + expect(await collateral.tryPrice()).to.deep.equal([ratio.sub(error), ratio.add(error), fp('1')]) + await collateral.refresh() + expect(await collateral.savedPegPrice()).to.equal(fp('1')) + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + expect(await collateral.targetPerRef()).to.equal(fp('1')) + expect(await collateral.refPerTok()).to.be.lt(ratio) + }) + + for (const [field, value, reason] of [ + ['erc20', ZERO_ADDRESS, 'missing erc20'], + ['chainlinkFeed', ZERO_ADDRESS, 'missing chainlink feed'], + ['oracleTimeout', 0, 'oracleTimeout zero'], + ['priceTimeout', 0, 'price timeout zero'], + ['oracleError', 0, 'oracle error out of range'], + ['oracleError', fp('1'), 'oracle error out of range'], + ['maxTradeVolume', 0, 'invalid max trade volume'], + ['targetName', ethers.constants.HashZero, 'targetName missing'], + ['delayUntilDefault', 1209601, 'delayUntilDefault too long'], + ] as const) { + it(`rejects invalid ${field}: ${value}`, async () => { + const { factory, config } = await loadFixture(fixture) + await expect( + factory.deploy({ ...config, [field]: value }, ousdRevenueHiding) + ).to.be.revertedWith(reason) + }) + } + + it('validates revenue hiding and allows a zero default threshold', async () => { + const { factory, config, collateral } = await loadFixture(fixture) + await expect(factory.deploy(config, fp('1'))).to.be.revertedWith('revenueHiding out of range') + expect(await collateral.pegBottom()).to.equal(fp('1')) + expect(await collateral.pegTop()).to.equal(fp('1')) + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + }) + + it('normalizes different share and underlying decimals', async () => { + const { factory, config } = await loadFixture(fixture) + const usdc = await (await ethers.getContractFactory('USDCMock')).deploy('USD Coin', 'USDC') + const vault = await ( + await ethers.getContractFactory('ERC4626Mock') + ).deploy(usdc.address, 8, 1234567) + const collateral = await factory.deploy({ ...config, erc20: vault.address }, 0) + expect(await collateral.underlyingRefPerTok()).to.equal(fp('1.234567')) + }) + + it('ignores divergent, invalid and stale feed answers', async () => { + const { collateral, feed } = await loadFixture(fixture) + const price = await collateral.tryPrice() + for (const answer of [bn('0'), bn('-1'), bn('1e6'), bn('1e10')]) { + await feed.updateAnswer(answer) + await time.increase(ousdCollateralConfig.priceTimeout + 1000) + await collateral.refresh() + expect(await collateral.tryPrice()).to.deep.equal(price) + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + } + }) + + it('never calls the configured feed, even when it has no oracle interface', async () => { + const { factory, config, asset } = await loadFixture(fixture) + const collateral = await factory.deploy({ ...config, chainlinkFeed: asset.address }, 0) + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + expect((await collateral.tryPrice())[2]).to.equal(fp('1')) + }) + + it('ignores explicit and empty oracle reverts', async () => { + const { factory, config } = await loadFixture(fixture) + const feed = await ( + await ethers.getContractFactory('InvalidMockV3Aggregator') + ).deploy(8, bn('1e8')) + const collateral = await factory.deploy({ ...config, chainlinkFeed: feed.address }, 0) + await feed.setRevertWithExplicitError(true) + await expect(feed.latestRoundData()).to.be.revertedWith('oracle explicit error') + await collateral.refresh() + const price = await collateral.price() + await feed.setSimplyRevert(true) + await expect(feed.latestRoundData()).to.be.revertedWithoutReason() + await collateral.refresh() + expect(await collateral.price()).to.deep.equal(price) + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + }) + + it('hides small drawdowns and exposes appreciation', async () => { + const { collateral, vault } = await loadFixture(fixture) + const original = await collateral.refPerTok() + await vault.setAssetsPerShare(fp('1.19994')) // 0.005% drawdown, below 0.01% hiding + await collateral.refresh() + expect(await collateral.refPerTok()).to.equal(original) + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + await vault.setAssetsPerShare(fp('1.3')) + await collateral.refresh() + expect(await collateral.refPerTok()).to.equal( + fp('1.3').mul(fp('1').sub(ousdRevenueHiding)).div(fp('1')) + ) + }) + + it('hard-defaults below the exposed ratio and cannot recover', async () => { + const { collateral, vault } = await loadFixture(fixture) + await vault.setAssetsPerShare((await collateral.refPerTok()).sub(1)) + await expect(collateral.refresh()) + .to.emit(collateral, 'CollateralStatusChanged') + .withArgs(CollateralStatus.SOUND, CollateralStatus.DISABLED) + await vault.setAssetsPerShare(fp('2')) + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + }) + + it('allows zero revenue hiding and defaults on even a one-wei loss', async () => { + const { factory, config, vault } = await loadFixture(fixture) + const collateral = await factory.deploy(config, 0) + await collateral.refresh() + expect(await collateral.refPerTok()).to.equal(fp('1.2')) + await vault.setAssetsPerShare(fp('1.2').sub(1)) + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + }) + + it('hard-defaults when the wrapper returns zero', async () => { + const { collateral, vault } = await loadFixture(fixture) + await vault.setAssetsPerShare(0) + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + expect(await collateral.price()).to.deep.equal([bn(0), bn(0)]) + }) + + it('hard-defaults on wrapper failure and decays saved prices to unpriced', async () => { + const { collateral, vault } = await loadFixture(fixture) + const saved = await collateral.price() + const lastSave = Number(await collateral.lastSave()) + await vault.setRevertMode(1) + await expect(collateral.tryPrice()).to.be.revertedWith('wrapper unavailable') + await collateral.refresh() + expect(await collateral.status()).to.equal(CollateralStatus.DISABLED) + expect(await collateral.price()).to.deep.equal(saved) + expect(await collateral.lastSave()).to.equal(lastSave) + const decayStart = lastSave + ousdCollateralConfig.oracleTimeout + 300 + await time.increaseTo(decayStart + 1000) + const decaying = await collateral.price() + expect(decaying[0]).to.be.lt(saved[0]) + expect(decaying[1]).to.be.gt(saved[1]) + await time.increaseTo(decayStart + ousdCollateralConfig.priceTimeout) + expect(await collateral.price()).to.deep.equal([bn(0), MAX_UINT192]) + }) + + it('propagates empty wrapper reverts without changing saved state', async () => { + const { collateral, vault } = await loadFixture(fixture) + const lastSave = await collateral.lastSave() + await vault.setRevertMode(2) + await expect(collateral.refresh()).to.be.revertedWithoutReason() + await expect(collateral.price()).to.be.revertedWithoutReason() + expect(await collateral.lastSave()).to.equal(lastSave) + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + }) +}) diff --git a/test/plugins/individual-collateral/origin/OUSDCollateral.test.ts b/test/plugins/individual-collateral/origin/OUSDCollateral.test.ts new file mode 100644 index 000000000..ccdc1891a --- /dev/null +++ b/test/plugins/individual-collateral/origin/OUSDCollateral.test.ts @@ -0,0 +1,96 @@ +import { loadFixture, time } from '@nomicfoundation/hardhat-network-helpers' +import { expect } from 'chai' +import { ethers } from 'hardhat' +import { CollateralStatus } from '../../../../common/constants' +import { fp } from '../../../../common/numbers' +import { ousdCollateralConfig, ousdRevenueHiding } from '../../../../common/ousd' +import { useEnv } from '../../../../utils/env' +import { defaultFixtureNoBasket } from '../../../fixtures' +import { whileImpersonating } from '../../../utils/impersonation' +import { getResetFork } from '../helpers' + +const FORK_BLOCK = 22164000 +const OUSD = '0x2A8e1E676Ec238d8A992307B495b45B3fEAa5e86' +const OUSD_WHALE = '0x87650d7bbfc3a9f10587d7778206671719d9910d' // Curve OUSD/3CRV pool +const describeFork = + useEnv('FORK') && useEnv('FORK_NETWORK') === 'mainnet' ? describe : describe.skip + +describeFork('OUSDCollateral - mainnet fork', () => { + before(getResetFork(FORK_BLOCK)) + + async function fixture() { + const [owner, alice] = await ethers.getSigners() + const wousd = await ethers.getContractAt( + '@openzeppelin/contracts/interfaces/IERC4626.sol:IERC4626', + ousdCollateralConfig.erc20 + ) + const ousd = await ethers.getContractAt('IERC20Metadata', OUSD) + const collateral = await ( + await ethers.getContractFactory('OUSDCollateral') + ).deploy(ousdCollateralConfig, ousdRevenueHiding) + await collateral.refresh() + + // Transfer existing OUSD, then wrap through the real ERC4626 interface. + await whileImpersonating(OUSD_WHALE, async (whale) => { + await ousd.connect(whale).transfer(alice.address, fp('100')) + }) + await ousd.connect(alice).approve(wousd.address, fp('100')) + await wousd.connect(alice).deposit(fp('100'), alice.address) + return { owner, alice, ousd, wousd, collateral } + } + + it('matches the real asset, decimals and conversion ratio', async () => { + const { ousd, wousd, collateral } = await loadFixture(fixture) + expect(await wousd.asset()).to.equal(OUSD) + expect(await ousd.decimals()).to.equal(18) + expect(await wousd.decimals()).to.equal(18) + const ratio = await wousd.convertToAssets(fp('1')) + expect(ratio).to.be.gt(fp('1')) + expect(await collateral.underlyingRefPerTok()).to.equal(ratio) + const [low, high, peg] = await collateral.tryPrice() + expect(low).to.be.lt(ratio) + expect(high).to.be.gt(ratio) + expect(peg).to.equal(fp('1')) + expect(await collateral.status()).to.equal(CollateralStatus.SOUND) + }) + + it('transfers real wOUSD atomically', async () => { + const { owner, alice, wousd } = await loadFixture(fixture) + await expect(wousd.connect(alice).transfer(owner.address, fp('1'))).to.changeTokenBalances( + wousd, + [alice, owner], + [fp('-1'), fp('1')] + ) + }) + + it('registers wOUSD, issues an RToken and redeems directly to wOUSD', async () => { + const { owner, alice, wousd, ousd, collateral } = await loadFixture(fixture) + const { assetRegistry, basketHandler, backingManager, rToken } = await defaultFixtureNoBasket() + await assetRegistry.connect(owner).register(collateral.address) + await basketHandler.connect(owner).setPrimeBasket([wousd.address], [fp('1')]) + await basketHandler.connect(owner).refreshBasket() + await time.increase(Number(await basketHandler.warmupPeriod()) + 1) + await backingManager.grantRTokenAllowance(wousd.address) + await wousd.connect(alice).approve(rToken.address, ethers.constants.MaxUint256) + + const startingWousd = await wousd.balanceOf(alice.address) + const startingOusd = await ousd.balanceOf(alice.address) + await rToken.connect(alice).issue(fp('10')) + expect(await rToken.balanceOf(alice.address)).to.equal(fp('10')) + const backing = await wousd.balanceOf(backingManager.address) + expect(backing).to.be.gt(0) + expect(startingWousd.sub(await wousd.balanceOf(alice.address))).to.equal(backing) + + const beforeRedeem = await wousd.balanceOf(alice.address) + await rToken.connect(alice).redeem(fp('10')) + const redeemed = (await wousd.balanceOf(alice.address)).sub(beforeRedeem) + const dust = await wousd.balanceOf(backingManager.address) + // Reserve rounds issuance up and redemption down, leaving at most 10 wei for 10 RTokens. + expect(redeemed).to.be.closeTo(backing, 10) + expect(redeemed.add(dust)).to.equal(backing) + expect(await rToken.balanceOf(alice.address)).to.equal(0) + expect(await wousd.balanceOf(alice.address)).to.equal(startingWousd.sub(dust)) + expect(await ousd.balanceOf(alice.address)).to.equal(startingOusd) + expect(await basketHandler.status()).to.equal(CollateralStatus.SOUND) + }) +})