From ef4e1d7bd92fe4122752984d9ee61020eabaee40 Mon Sep 17 00:00:00 2001 From: Doug Hoyte Date: Thu, 19 Jan 2023 23:50:07 -0500 Subject: [PATCH 01/11] asset policies --- contracts/BaseLogic.sol | 59 ++++++++++++ contracts/Constants.sol | 10 ++ contracts/Events.sol | 1 + contracts/Storage.sol | 16 ++++ contracts/modules/DToken.sol | 3 + contracts/modules/EToken.sol | 7 ++ contracts/modules/Exec.sol | 2 + contracts/modules/Governance.sol | 8 ++ contracts/modules/Markets.sol | 5 + contracts/modules/Swap.sol | 5 + contracts/modules/SwapHub.sol | 10 ++ docs/asset-policies.md | 32 +++++++ test/assetPolicies.js | 160 +++++++++++++++++++++++++++++++ test/lib/eTestLib.js | 15 +++ 14 files changed, 333 insertions(+) create mode 100644 docs/asset-policies.md create mode 100644 test/assetPolicies.js diff --git a/contracts/BaseLogic.sol b/contracts/BaseLogic.sol index f586ae32..9f96bb14 100644 --- a/contracts/BaseLogic.sol +++ b/contracts/BaseLogic.sol @@ -639,4 +639,63 @@ abstract contract BaseLogic is BaseModule { accountLookup[account].lastAverageLiquidityUpdate = uint40(block.timestamp); accountLookup[account].averageLiquidity = computeNewAverageLiquidity(account, deltaT); } + + + // Asset Policies + + function assetPolicyCheck(address underlying, uint16 pauseType) internal view { + require((pauseType & assetPolicies[underlying].pauseBitmask) == 0, "e/market-operation-paused"); + } + + function assetPolicyDirty(AssetCache memory assetCache, uint16 pauseType) internal { + AssetPolicy memory policy = assetPolicies[assetCache.underlying]; + + require((pauseType & policy.pauseBitmask) == 0, "e/market-operation-paused"); + + if (policy.supplyCap == 0 && policy.borrowCap == 0) return; + if (assetSnapshots[assetCache.underlying].dirty) return; + + uint112 origTotalBalances = encodeAmount(balanceToUnderlyingAmount(assetCache, assetCache.totalBalances) / assetCache.underlyingDecimalsScaler); + uint112 origTotalBorrows = encodeAmount(assetCache.totalBorrows / INTERNAL_DEBT_PRECISION / assetCache.underlyingDecimalsScaler); + + assetSnapshots[assetCache.underlying] = AssetSnapshot(true, origTotalBalances, origTotalBorrows); + } + + function assetPolicyClean(AssetCache memory assetCache, address account, bool allowDefer) internal { + AssetPolicy memory policy = assetPolicies[assetCache.underlying]; + + if (policy.supplyCap == 0 && policy.borrowCap == 0) return; + if (!assetSnapshots[assetCache.underlying].dirty) return; + if (allowDefer && accountLookup[account].deferLiquidityStatus != DEFERLIQUIDITY__NONE && isEnteredInMarket(account, assetCache.underlying)) return; + + uint112 newTotalBalances = encodeAmount(balanceToUnderlyingAmount(assetCache, assetCache.totalBalances) / assetCache.underlyingDecimalsScaler); + uint112 newTotalBorrows = encodeAmount(assetCache.totalBorrows / INTERNAL_DEBT_PRECISION / assetCache.underlyingDecimalsScaler); + + require(policy.supplyCap == 0 + || newTotalBalances < policy.supplyCap * 1e18 / assetCache.underlyingDecimalsScaler + || newTotalBalances <= assetSnapshots[assetCache.underlying].origTotalBalances, "e/supply-cap-exceeded"); + + require(policy.borrowCap == 0 + || newTotalBorrows < policy.borrowCap * 1e18 / assetCache.underlyingDecimalsScaler + || newTotalBorrows <= assetSnapshots[assetCache.underlying].origTotalBorrows, "e/borrow-cap-exceeded"); + + assetSnapshots[assetCache.underlying] = AssetSnapshot(false, 0, 0); + } + + function assetPolicyCleanAllEntered(address account) internal { + AssetStorage storage assetStorage; + AssetCache memory assetCache; + + address[] memory underlyings = getEnteredMarketsArray(account); + + for (uint i = 0; i < underlyings.length; ++i) { + address underlying = underlyings[i]; + if (!assetSnapshots[underlying].dirty) continue; + + assetStorage = eTokenLookup[underlyingLookup[underlying].eTokenAddress]; + initAssetCache(underlying, assetStorage, assetCache); + + assetPolicyClean(assetCache, account, false); + } + } } diff --git a/contracts/Constants.sol b/contracts/Constants.sol index 0a524352..a579df9a 100644 --- a/contracts/Constants.sol +++ b/contracts/Constants.sol @@ -49,6 +49,16 @@ abstract contract Constants { uint16 internal constant PRICINGTYPE__OUT_OF_BOUNDS = 5; + // Pause bitmask + + uint16 internal constant PAUSETYPE__DEPOSIT = 1 << 0; + uint16 internal constant PAUSETYPE__WITHDRAW = 1 << 1; + uint16 internal constant PAUSETYPE__BORROW = 1 << 2; + uint16 internal constant PAUSETYPE__REPAY = 1 << 3; + uint16 internal constant PAUSETYPE__MINT = 1 << 4; + uint16 internal constant PAUSETYPE__BURN = 1 << 5; + + // Modules // Public single-proxy modules diff --git a/contracts/Events.sol b/contracts/Events.sol index dc295ede..ec0f3bf4 100644 --- a/contracts/Events.sol +++ b/contracts/Events.sol @@ -57,6 +57,7 @@ abstract contract Events { event GovSetReserveFee(address indexed underlying, uint32 newReserveFee); event GovConvertReserves(address indexed underlying, address indexed recipient, uint amount); event GovSetChainlinkPriceFeed(address indexed underlying, address chainlinkAggregator); + event GovSetAssetPolicy(address indexed underlying, Storage.AssetPolicy newPolicy); event RequestSwap(address indexed accountIn, address indexed accountOut, address indexed underlyingIn, address underlyingOut, uint amount, uint swapType); event RequestSwapHub(address indexed accountIn, address indexed accountOut, address indexed underlyingIn, address underlyingOut, uint amountIn, uint amountOut, uint mode, address swapHandler); diff --git a/contracts/Storage.sol b/contracts/Storage.sol index 7bd1a290..73514c23 100644 --- a/contracts/Storage.sol +++ b/contracts/Storage.sol @@ -93,4 +93,20 @@ abstract contract Storage is Constants { mapping(address => address) internal pTokenLookup; // PToken => underlying mapping(address => address) internal reversePTokenLookup; // underlying => PToken mapping(address => address) internal chainlinkPriceFeedLookup; // underlying => chainlinkAggregator + + struct AssetPolicy { + uint64 supplyCap; // underlying units without decimals, 0 means no cap + uint64 borrowCap; // underlying units without decimals, 0 means no cap + uint16 pauseBitmask; + uint32 loanOriginationFee; + } + + struct AssetSnapshot { + bool dirty; + uint112 origTotalBalances; // underlying units and decimals + uint112 origTotalBorrows; // underlying units and decimals + } + + mapping(address => AssetPolicy) assetPolicies; // underlying => AssetPolicy + mapping(address => AssetSnapshot) assetSnapshots; // underlying => AssetSnapshot } diff --git a/contracts/modules/DToken.sol b/contracts/modules/DToken.sol index d539cc7b..6c717a87 100644 --- a/contracts/modules/DToken.sol +++ b/contracts/modules/DToken.sol @@ -103,6 +103,7 @@ contract DToken is BaseLogic { emit RequestBorrow(account, amount); AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); + assetPolicyDirty(assetCache, PAUSETYPE__BORROW); if (amount == type(uint).max) { amount = assetCache.poolSize; @@ -116,6 +117,7 @@ contract DToken is BaseLogic { increaseBorrow(assetStorage, assetCache, proxyAddr, account, amount); + assetPolicyClean(assetCache, account, true); checkLiquidity(account); logAssetStatus(assetCache); } @@ -131,6 +133,7 @@ contract DToken is BaseLogic { emit RequestRepay(account, amount); AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); + assetPolicyCheck(underlying, PAUSETYPE__REPAY); if (amount != type(uint).max) { amount = decodeExternalAmount(assetCache, amount); diff --git a/contracts/modules/EToken.sol b/contracts/modules/EToken.sol index 8ab8d85a..e8aaaa55 100644 --- a/contracts/modules/EToken.sol +++ b/contracts/modules/EToken.sol @@ -144,6 +144,7 @@ contract EToken is BaseLogic { emit RequestDeposit(account, amount); AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); + assetPolicyDirty(assetCache, PAUSETYPE__DEPOSIT); if (amount == type(uint).max) { amount = callBalanceOf(assetCache, msgSender); @@ -167,6 +168,8 @@ contract EToken is BaseLogic { increaseBalance(assetStorage, assetCache, proxyAddr, account, amountInternal); + assetPolicyClean(assetCache, account, true); + // Depositing a token to an account with pre-existing debt in that token creates a self-collateralized loan // which may result in borrow isolation violation if other tokens are also borrowed on the account if (assetStorage.users[account].owed != 0) checkLiquidity(account); @@ -185,6 +188,7 @@ contract EToken is BaseLogic { emit RequestWithdraw(account, amount); AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); + assetPolicyCheck(underlying, PAUSETYPE__WITHDRAW); uint amountInternal; (amount, amountInternal) = withdrawAmounts(assetStorage, assetCache, account, amount); @@ -211,6 +215,7 @@ contract EToken is BaseLogic { emit RequestMint(account, amount); AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); + assetPolicyDirty(assetCache, PAUSETYPE__MINT); amount = decodeExternalAmount(assetCache, amount); uint amountInternal = underlyingAmountToBalanceRoundUp(assetCache, amount); @@ -224,6 +229,7 @@ contract EToken is BaseLogic { increaseBorrow(assetStorage, assetCache, assetStorage.dTokenAddress, account, amount); + assetPolicyClean(assetCache, account, true); checkLiquidity(account); logAssetStatus(assetCache); } @@ -239,6 +245,7 @@ contract EToken is BaseLogic { emit RequestBurn(account, amount); AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); + assetPolicyCheck(underlying, PAUSETYPE__BURN); uint owed = getCurrentOwed(assetStorage, assetCache, account); if (owed == 0) return; diff --git a/contracts/modules/Exec.sol b/contracts/modules/Exec.sol index dbc3a40e..288c40ad 100644 --- a/contracts/modules/Exec.sol +++ b/contracts/modules/Exec.sol @@ -97,6 +97,7 @@ contract Exec is BaseLogic { uint8 status = accountLookup[account].deferLiquidityStatus; accountLookup[account].deferLiquidityStatus = DEFERLIQUIDITY__NONE; + assetPolicyCleanAllEntered(account); if (status == DEFERLIQUIDITY__DIRTY) checkLiquidity(account); } @@ -318,6 +319,7 @@ contract Exec is BaseLogic { uint8 status = accountLookup[account].deferLiquidityStatus; accountLookup[account].deferLiquidityStatus = DEFERLIQUIDITY__NONE; + assetPolicyCleanAllEntered(account); if (status == DEFERLIQUIDITY__DIRTY) checkLiquidity(account); } } diff --git a/contracts/modules/Governance.sol b/contracts/modules/Governance.sol index c5b7f539..a6c33ca5 100644 --- a/contracts/modules/Governance.sol +++ b/contracts/modules/Governance.sol @@ -117,6 +117,14 @@ contract Governance is BaseLogic { emit GovSetChainlinkPriceFeed(underlying, chainlinkAggregator); } + function setAssetPolicy(address underlying, AssetPolicy memory newPolicy) external nonReentrant governorOnly { + require(underlyingLookup[underlying].eTokenAddress != address(0), "e/gov/underlying-not-activated"); + + assetPolicies[underlying] = newPolicy; + + emit GovSetAssetPolicy(underlying, newPolicy); + } + // getters diff --git a/contracts/modules/Markets.sol b/contracts/modules/Markets.sol index b0bd366c..6473cc45 100644 --- a/contracts/modules/Markets.sol +++ b/contracts/modules/Markets.sol @@ -273,6 +273,11 @@ contract Markets is BaseLogic { require(owed == 0, "e/outstanding-borrow"); + if (assetSnapshots[oldMarket].dirty) { + AssetCache memory assetCache = loadAssetCache(oldMarket, assetStorage); + assetPolicyClean(assetCache, account, false); + } + doExitMarket(account, oldMarket); if (config.collateralFactor != 0 && balance != 0) { diff --git a/contracts/modules/Swap.sol b/contracts/modules/Swap.sol index 63f6240b..3b0dbbee 100644 --- a/contracts/modules/Swap.sol +++ b/contracts/modules/Swap.sol @@ -397,8 +397,13 @@ contract Swap is BaseLogic { processWithdraw(eTokenLookup[swap.eTokenIn], swap.assetCacheIn, swap.eTokenIn, swap.accountIn, swap.amountInternalIn, balanceIn); + assetPolicyCheck(swap.assetCacheIn.underlying, PAUSETYPE__WITHDRAW); + assetPolicyDirty(swap.assetCacheOut, PAUSETYPE__DEPOSIT); + processDeposit(eTokenLookup[swap.eTokenOut], swap.assetCacheOut, swap.eTokenOut, swap.accountOut, swap.amountOut); + assetPolicyClean(swap.assetCacheOut, swap.accountOut, true); + checkLiquidity(swap.accountIn); } diff --git a/contracts/modules/SwapHub.sol b/contracts/modules/SwapHub.sol index eb301c77..5f9353ad 100644 --- a/contracts/modules/SwapHub.sol +++ b/contracts/modules/SwapHub.sol @@ -41,6 +41,9 @@ contract SwapHub is BaseLogic { function swap(uint subAccountIdIn, uint subAccountIdOut, address swapHandler, ISwapHandler.SwapParams memory params) external nonReentrant { SwapCache memory cache = initSwap(subAccountIdIn, subAccountIdOut, params); + assetPolicyCheck(params.underlyingIn, PAUSETYPE__WITHDRAW); + assetPolicyDirty(cache.assetCacheOut, PAUSETYPE__DEPOSIT); + emit RequestSwapHub( cache.accountIn, cache.accountOut, @@ -62,6 +65,8 @@ contract SwapHub is BaseLogic { increaseBalance(assetStorageOut, cache.assetCacheOut, cache.eTokenOut, cache.accountOut, amountOutInternal); logAssetStatus(cache.assetCacheOut); + assetPolicyClean(cache.assetCacheOut, cache.accountOut, true); + // Check liquidity checkLiquidity(cache.accountIn); @@ -89,6 +94,9 @@ contract SwapHub is BaseLogic { swapHandler ); + assetPolicyCheck(params.underlyingIn, PAUSETYPE__WITHDRAW); + assetPolicyDirty(cache.assetCacheOut, PAUSETYPE__DEPOSIT | PAUSETYPE__REPAY); + // Adjust params for repay require(params.mode == 1, "e/swap-hub/repay-mode"); @@ -104,6 +112,8 @@ contract SwapHub is BaseLogic { decreaseBorrow(assetStorageOut, cache.assetCacheOut, assetStorageOut.dTokenAddress, cache.accountOut, decodeExternalAmount(cache.assetCacheOut, amountOut)); logAssetStatus(cache.assetCacheOut); + assetPolicyClean(cache.assetCacheOut, cache.accountOut, true); + // Check liquidity only for outgoing account, repay can't lower the health score or cause borrow isolation error checkLiquidity(cache.accountIn); } diff --git a/docs/asset-policies.md b/docs/asset-policies.md new file mode 100644 index 00000000..af48c7e9 --- /dev/null +++ b/docs/asset-policies.md @@ -0,0 +1,32 @@ +## Asset Policies + +### Supply/Borrow caps + +For some assets it is desired to limit the amount of assets supplied and/or borrowed on the platform. This could be done in order to phase-in a collateral asset while minimising exposure to the protocol. An asset may also have a limited amount of on-chain liquidity, which could merit capping the lending/borrowing activity. + +While it is possible for governance to lower a cap to below the current supply/borrow level, caps are not intended to function as emergency pause mechanisms. It should still be possible to withdraw/repay, even when an asset is in violation of the cap. This should be true even if your withdraw/repay is insufficient to solve the violation, and the asset's supply/borrow remains above your the cap after your operation. + +Furthermore, it should be possible to temporarily exceed the cap, as long as the supply/borrows are brought back down to a non-violating amount in the same transaction, or at least to the level they were when the transaction was initiated. + +### Operation pausing + +In order to give governance an ability to quickly react to market conditions, contract bugs, and other unpredictable events, assets can have operations "paused". This is intended to temporarily prevent a deposit/withdraw/etc operation from being performed on the asset. + +### Mechanism + +Caps and operation pausing are specified inside a new storage mapping `assetPolicies`. This mapping only needs to exist for assets that have policies configured. + +Whenever an operation is to be performed on an asset, its asset policies should be checked to ensure the operation is not paused, and also that the caps will not be exceeded. + + + + + +TODO: + * Testing + * Alternate decimals + * Exchange rate != 1 + * Pause bitmask + * Add pause checks to places that need them + * new pause checks? + * optimisations diff --git a/test/assetPolicies.js b/test/assetPolicies.js new file mode 100644 index 00000000..a3f08c87 --- /dev/null +++ b/test/assetPolicies.js @@ -0,0 +1,160 @@ +const et = require('./lib/eTestLib'); +const scenarios = require('./lib/scenarios'); + + +et.testSet({ + desc: "asset policies", + + preActions: scenarios.basicLiquidity(), +}) + + + +.test({ + desc: "simple supply cap", + actions: ctx => [ + { call: 'eTokens.eTST.totalSupply', equals: [10, .001], }, + + // Deposit prevented: + + { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 11, }, }, + { send: 'eTokens.eTST.deposit', args: [0, et.eth(2)], expectError: 'e/supply-cap-exceeded', }, + + // Raise Cap and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 13, }, }, + { send: 'eTokens.eTST.deposit', args: [0, et.eth(2)], }, + + // New limit prevents additional deposits: + + { send: 'eTokens.eTST.deposit', args: [0, et.eth(2)], expectError: 'e/supply-cap-exceeded', }, + + // Lower supply cap. Withdrawl still works, even though it's not enough withdrawn to solve the policy violation: + + { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 5, }, }, + { send: 'eTokens.eTST.withdraw', args: [0, et.eth(3)], }, + + { call: 'eTokens.eTST.totalSupply', equals: [9, .001], }, + + // Deposit doesn't work + + { send: 'eTokens.eTST.deposit', args: [0, et.eth(.1)], expectError: 'e/supply-cap-exceeded', }, + ], +}) + + +.test({ + desc: "batch deferral of supply cap check", + actions: ctx => [ + // Current supply 10, supply cap 15 + + { call: 'eTokens.eTST.totalSupply', equals: [10, .001], }, + { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 15, }, }, + + // This won't work because we don't defer liquidity check: + + { action: 'sendBatch', batch: [ + { send: 'eTokens.eTST.deposit', args: [0, et.eth(10)], }, + { send: 'eTokens.eTST.withdraw', args: [0, et.eth(8)], }, + ], + expectError: 'e/supply-cap-exceeded', + }, + + // This won't work because we aren't entered: + + { send: 'markets.exitMarket', args: [0, ctx.contracts.tokens.TST.address], }, + + { action: 'sendBatch', batch: [ + { send: 'eTokens.eTST.deposit', args: [0, et.eth(10)], }, + { send: 'eTokens.eTST.withdraw', args: [0, et.eth(8)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + expectError: 'e/supply-cap-exceeded', + }, + + // Deferring doesn't allow us to leave the asset in policy violation: + + { send: 'markets.enterMarket', args: [0, ctx.contracts.tokens.TST.address], }, + + { action: 'sendBatch', batch: [ + { send: 'eTokens.eTST.deposit', args: [0, et.eth(10)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + expectError: 'e/supply-cap-exceeded', + }, + + // Being entered into the market allows the policy check to be deferred, so transient violations don't fail: + + { action: 'sendBatch', batch: [ + { send: 'eTokens.eTST.deposit', args: [0, et.eth(10)], }, + { send: 'eTokens.eTST.withdraw', args: [0, et.eth(8)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + }, + + { call: 'eTokens.eTST.totalSupply', equals: [12, .001], }, + + // Same behaviour if we also have a borrow (which causes liquidity check in deposit) + + { from: ctx.wallet, send: 'dTokens.dTST.borrow', args: [0, et.eth(.1)], }, + + { send: 'eTokens.eTST.deposit', args: [0, et.eth(10)], expectError: 'e/supply-cap-exceeded', }, + + // Failures: + + { send: 'markets.enterMarket', args: [0, ctx.contracts.tokens.TST.address], }, + + { action: 'sendBatch', batch: [ + { send: 'eTokens.eTST.deposit', args: [0, et.eth(10)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + expectError: 'e/supply-cap-exceeded', + }, + + // Success + + { action: 'sendBatch', batch: [ + { send: 'eTokens.eTST.deposit', args: [0, et.eth(10)], }, + { send: 'eTokens.eTST.withdraw', args: [0, et.eth(8)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + }, + ], +}) + + + +.test({ + desc: "can't exit market to bypass checks", + actions: ctx => [ + // Current supply 10, supply cap 15 + + { call: 'eTokens.eTST.totalSupply', equals: [10, .001], }, + { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 15, }, }, + + { send: 'markets.enterMarket', args: [0, ctx.contracts.tokens.TST.address], }, + + // Can't exit the market if the asset is in violation at the point of exit: + + { action: 'sendBatch', batch: [ + { send: 'eTokens.eTST.deposit', args: [0, et.eth(10)], }, + { send: 'markets.exitMarket', args: [0, ctx.contracts.tokens.TST.address], }, + { send: 'eTokens.eTST.withdraw', args: [0, et.eth(8)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + expectError: 'e/supply-cap-exceeded', + }, + + // ... but you can if it is not in violation: + + { action: 'sendBatch', batch: [ + { send: 'eTokens.eTST.deposit', args: [0, et.eth(10)], }, + { send: 'eTokens.eTST.withdraw', args: [0, et.eth(8)], }, + { send: 'markets.exitMarket', args: [0, ctx.contracts.tokens.TST.address], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + }, + ], +}) + +.run(); diff --git a/test/lib/eTestLib.js b/test/lib/eTestLib.js index 4c90ae81..88b04b37 100644 --- a/test/lib/eTestLib.js +++ b/test/lib/eTestLib.js @@ -407,6 +407,18 @@ async function buildContext(provider, wallets, tokenSetupName) { await (await ctx.contracts.governance.connect(ctx.wallet).setAssetConfig(underlying, config)).wait(); }; + ctx.setAssetPolicy = async (underlying, newPolicy) => { + let policy = { + supplyCap: 0, + borrowCap: 0, + pauseBitmask: 0, + loanOriginationFee: 0, + ...newPolicy, + }; + + await (await ctx.contracts.governance.connect(ctx.wallet).setAssetPolicy(underlying, policy)).wait(); + }; + // Batch transactions ctx._batchItemToContract = (item) => { @@ -1494,6 +1506,9 @@ class TestSet { } else if (action.action === 'setAssetConfig') { let underlying = ctx.contracts.tokens[action.tok].address; await ctx.setAssetConfig(underlying, action.config); + } else if (action.action === 'setAssetPolicy') { + let underlying = ctx.contracts.tokens[action.tok].address; + await ctx.setAssetPolicy(underlying, action.policy); } else if (action.action === 'setTokenBalanceInStorage') { await ctx.setTokenBalanceInStorage(action.token, action.for, action.amount, action.slot); } else if (action.action === 'doUniswapSwap') { From 04ea0ba3d6d22be3cc4e76088368cc9b4620fa8c Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Tue, 14 Feb 2023 12:59:14 +0000 Subject: [PATCH 02/11] asset policies tweaks --- contracts/modules/DToken.sol | 6 ++++-- contracts/modules/EToken.sol | 9 +++++---- contracts/modules/Swap.sol | 18 +++++++++++------- contracts/modules/SwapHub.sol | 11 +++++------ 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/contracts/modules/DToken.sol b/contracts/modules/DToken.sol index 6c717a87..3242c5a8 100644 --- a/contracts/modules/DToken.sol +++ b/contracts/modules/DToken.sol @@ -132,8 +132,8 @@ contract DToken is BaseLogic { updateAverageLiquidity(account); emit RequestRepay(account, amount); - AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); assetPolicyCheck(underlying, PAUSETYPE__REPAY); + AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); if (amount != type(uint).max) { amount = decodeExternalAmount(assetCache, amount); @@ -213,7 +213,6 @@ contract DToken is BaseLogic { /// @param amount In underlying units. Use max uint256 for full balance. function transferFrom(address from, address to, uint amount) public nonReentrant returns (bool) { (address underlying, AssetStorage storage assetStorage, address proxyAddr, address msgSender) = CALLER(); - AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); if (from == address(0)) from = msgSender; require(from != to, "e/self-transfer"); @@ -222,6 +221,9 @@ contract DToken is BaseLogic { updateAverageLiquidity(to); emit RequestTransferDToken(from, to, amount); + assetPolicyCheck(underlying, PAUSETYPE__BORROW | PAUSETYPE__REPAY); + AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); + if (amount == type(uint).max) amount = getCurrentOwed(assetStorage, assetCache, from); else amount = decodeExternalAmount(assetCache, amount); diff --git a/contracts/modules/EToken.sol b/contracts/modules/EToken.sol index e8aaaa55..a16175ea 100644 --- a/contracts/modules/EToken.sol +++ b/contracts/modules/EToken.sol @@ -187,8 +187,8 @@ contract EToken is BaseLogic { updateAverageLiquidity(account); emit RequestWithdraw(account, amount); - AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); assetPolicyCheck(underlying, PAUSETYPE__WITHDRAW); + AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); uint amountInternal; (amount, amountInternal) = withdrawAmounts(assetStorage, assetCache, account, amount); @@ -244,8 +244,8 @@ contract EToken is BaseLogic { updateAverageLiquidity(account); emit RequestBurn(account, amount); - AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); assetPolicyCheck(underlying, PAUSETYPE__BURN); + AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); uint owed = getCurrentOwed(assetStorage, assetCache, account); if (owed == 0) return; @@ -330,8 +330,6 @@ contract EToken is BaseLogic { function transferFrom(address from, address to, uint amount) public nonReentrant returns (bool) { (address underlying, AssetStorage storage assetStorage, address proxyAddr, address msgSender) = CALLER(); - AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); - if (from == address(0)) from = msgSender; require(from != to, "e/self-transfer"); @@ -339,6 +337,9 @@ contract EToken is BaseLogic { updateAverageLiquidity(to); emit RequestTransferEToken(from, to, amount); + assetPolicyCheck(underlying, PAUSETYPE__WITHDRAW | PAUSETYPE__DEPOSIT); + AssetCache memory assetCache = loadAssetCache(underlying, assetStorage); + if (amount == 0) return true; if (!isSubAccountOf(msgSender, from) && assetStorage.eTokenAllowance[from][msgSender] != type(uint).max) { diff --git a/contracts/modules/Swap.sol b/contracts/modules/Swap.sol index 3b0dbbee..c7766d6a 100644 --- a/contracts/modules/Swap.sol +++ b/contracts/modules/Swap.sol @@ -395,19 +395,27 @@ contract Swap is BaseLogic { function finalizeSwap(SwapCache memory swap) private { uint balanceIn = checkBalances(swap); + assetPolicyCheck(swap.assetCacheIn.underlying, PAUSETYPE__WITHDRAW); processWithdraw(eTokenLookup[swap.eTokenIn], swap.assetCacheIn, swap.eTokenIn, swap.accountIn, swap.amountInternalIn, balanceIn); - assetPolicyCheck(swap.assetCacheIn.underlying, PAUSETYPE__WITHDRAW); assetPolicyDirty(swap.assetCacheOut, PAUSETYPE__DEPOSIT); - - processDeposit(eTokenLookup[swap.eTokenOut], swap.assetCacheOut, swap.eTokenOut, swap.accountOut, swap.amountOut); + AssetStorage storage assetStorageOut = eTokenLookup[swap.eTokenOut]; + processDeposit(assetStorageOut, swap.assetCacheOut, swap.eTokenOut, swap.accountOut, swap.amountOut); assetPolicyClean(swap.assetCacheOut, swap.accountOut, true); checkLiquidity(swap.accountIn); + + // Depositing a token to the account with a pre-existing debt in that token creates a self-collateralized loan + // which may result in borrow isolation violation if other tokens are also borrowed on the account + if (swap.accountIn != swap.accountOut && assetStorageOut.users[swap.accountOut].owed != 0) + checkLiquidity(swap.accountOut); } function finalizeSwapAndRepay(SwapCache memory swap) private { + assetPolicyCheck(swap.assetCacheIn.underlying, PAUSETYPE__WITHDRAW); + assetPolicyCheck(swap.assetCacheOut.underlying, PAUSETYPE__REPAY); + uint balanceIn = checkBalances(swap); processWithdraw(eTokenLookup[swap.eTokenIn], swap.assetCacheIn, swap.eTokenIn, swap.accountIn, swap.amountInternalIn, balanceIn); @@ -434,10 +442,6 @@ contract Swap is BaseLogic { increaseBalance(assetStorage, assetCache, eTokenAddress, account, amountInternal); - // Depositing a token to an account with pre-existing debt in that token creates a self-collateralized loan - // which may result in borrow isolation violation if other tokens are also borrowed on the account - if (assetStorage.users[account].owed != 0) checkLiquidity(account); - logAssetStatus(assetCache); } diff --git a/contracts/modules/SwapHub.sol b/contracts/modules/SwapHub.sol index 5f9353ad..ddf81f17 100644 --- a/contracts/modules/SwapHub.sol +++ b/contracts/modules/SwapHub.sol @@ -39,9 +39,10 @@ contract SwapHub is BaseLogic { /// @param swapHandler address of a swap handler to use /// @param params struct defining the requested trade function swap(uint subAccountIdIn, uint subAccountIdOut, address swapHandler, ISwapHandler.SwapParams memory params) external nonReentrant { + assetPolicyCheck(params.underlyingIn, PAUSETYPE__WITHDRAW); + SwapCache memory cache = initSwap(subAccountIdIn, subAccountIdOut, params); - assetPolicyCheck(params.underlyingIn, PAUSETYPE__WITHDRAW); assetPolicyDirty(cache.assetCacheOut, PAUSETYPE__DEPOSIT); emit RequestSwapHub( @@ -83,6 +84,9 @@ contract SwapHub is BaseLogic { /// @param params struct defining the requested trade /// @param targetDebt how much debt should remain after calling the function function swapAndRepay(uint subAccountIdIn, uint subAccountIdOut, address swapHandler, ISwapHandler.SwapParams memory params, uint targetDebt) external nonReentrant { + assetPolicyCheck(params.underlyingIn, PAUSETYPE__WITHDRAW); + assetPolicyCheck(params.underlyingOut, PAUSETYPE__REPAY); + SwapCache memory cache = initSwap(subAccountIdIn, subAccountIdOut, params); emit RequestSwapHubRepay( @@ -94,9 +98,6 @@ contract SwapHub is BaseLogic { swapHandler ); - assetPolicyCheck(params.underlyingIn, PAUSETYPE__WITHDRAW); - assetPolicyDirty(cache.assetCacheOut, PAUSETYPE__DEPOSIT | PAUSETYPE__REPAY); - // Adjust params for repay require(params.mode == 1, "e/swap-hub/repay-mode"); @@ -112,8 +113,6 @@ contract SwapHub is BaseLogic { decreaseBorrow(assetStorageOut, cache.assetCacheOut, assetStorageOut.dTokenAddress, cache.accountOut, decodeExternalAmount(cache.assetCacheOut, amountOut)); logAssetStatus(cache.assetCacheOut); - assetPolicyClean(cache.assetCacheOut, cache.accountOut, true); - // Check liquidity only for outgoing account, repay can't lower the health score or cause borrow isolation error checkLiquidity(cache.accountIn); } From 1bdbea1e5b06d9a2478d4a4baec2e083514dee02 Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Tue, 14 Feb 2023 19:04:36 +0000 Subject: [PATCH 03/11] added simple borrow cap tests --- test/assetPolicies.js | 156 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 154 insertions(+), 2 deletions(-) diff --git a/test/assetPolicies.js b/test/assetPolicies.js index a3f08c87..a9f363cb 100644 --- a/test/assetPolicies.js +++ b/test/assetPolicies.js @@ -29,7 +29,7 @@ et.testSet({ { send: 'eTokens.eTST.deposit', args: [0, et.eth(2)], expectError: 'e/supply-cap-exceeded', }, - // Lower supply cap. Withdrawl still works, even though it's not enough withdrawn to solve the policy violation: + // Lower supply cap. Withdrawal still works, even though it's not enough withdrawn to solve the policy violation: { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 5, }, }, { send: 'eTokens.eTST.withdraw', args: [0, et.eth(3)], }, @@ -43,6 +43,50 @@ et.testSet({ }) +.test({ + desc: "simple borrow cap", + actions: ctx => [ + { send: 'dTokens.dTST.borrow', args: [0, et.eth(5)], }, + + { call: 'dTokens.dTST.totalSupply', equals: [5, .001], }, + + // Borrow prevented: + + { action: 'setAssetPolicy', tok: 'TST', policy: { borrowCap: 6, }, }, + { send: 'dTokens.dTST.borrow', args: [0, et.eth(2)], expectError: 'e/borrow-cap-exceeded', }, + + // Raise Cap and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST', policy: { borrowCap: 8, }, }, + { send: 'dTokens.dTST.borrow', args: [0, et.eth(2)], }, + + // New limit prevents additional deposits: + + { send: 'dTokens.dTST.borrow', args: [0, et.eth(2)], expectError: 'e/borrow-cap-exceeded', }, + + // Lower borrow cap to the current dToken supply, set IRM to non-zero. + // Jump time so that new dToken supply exceeds the borrow cap due to the interest accrued + + { action: 'setAssetPolicy', tok: 'TST', policy: { borrowCap: 7, }, }, + { action: 'setIRM', underlying: 'TST', irm: 'IRM_FIXED', }, + { call: 'dTokens.dTST.totalSupply', equals: [7, .001], }, + + { action: 'jumpTimeAndMine', time: 2 * 365 * 24 * 60 * 60, }, // 2 years + { call: 'dTokens.dTST.totalSupply', equals: [8.55, .001], }, + + // Repay still works, even though it's not enough repaid to solve the policy violation: + + { send: 'dTokens.dTST.repay', args: [0, et.eth(1)], }, + + { call: 'dTokens.dTST.totalSupply', equals: [7.55, .001], }, + + // Borrow doesn't work + + { send: 'dTokens.dTST.borrow', args: [0, et.eth(.1)], expectError: 'e/borrow-cap-exceeded', }, + ], +}) + + .test({ desc: "batch deferral of supply cap check", actions: ctx => [ @@ -123,9 +167,83 @@ et.testSet({ }) +.test({ + desc: "batch deferral of borrow cap check", + actions: ctx => [ + // Current borrow 0, borrow cap 5 + + { call: 'dTokens.dTST2.totalSupply', equals: [0, .001], }, + { action: 'setAssetPolicy', tok: 'TST2', policy: { borrowCap: 5, }, }, + + // This won't work because we don't defer liquidity check: + + { action: 'sendBatch', batch: [ + { send: 'dTokens.dTST2.borrow', args: [0, et.eth(6)], }, + { send: 'dTokens.dTST2.repay', args: [0, et.eth(2)], }, + ], + expectError: 'e/borrow-cap-exceeded', + }, + + // Deferring doesn't allow us to leave the asset in policy violation: + + { send: 'markets.enterMarket', args: [0, ctx.contracts.tokens.TST2.address], }, + + { action: 'sendBatch', batch: [ + { send: 'dTokens.dTST2.borrow', args: [0, et.eth(6)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + expectError: 'e/borrow-cap-exceeded', + }, + + // Being entered into the market allows the policy check to be deferred, so transient violations don't fail: + + { action: 'sendBatch', batch: [ + { send: 'dTokens.dTST2.borrow', args: [0, et.eth(6)], }, + { send: 'dTokens.dTST2.repay', args: [0, et.eth(2)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + }, + + { call: 'dTokens.dTST2.totalSupply', equals: [4, .001], }, + + // This works despite the fact we had exited the market because we enter it again on borrow + + { send: 'dTokens.dTST2.repay', args: [0, et.MaxUint256], }, + { send: 'markets.exitMarket', args: [0, ctx.contracts.tokens.TST2.address], }, + + { action: 'sendBatch', batch: [ + { send: 'dTokens.dTST2.borrow', args: [0, et.eth(6)], }, + { send: 'dTokens.dTST2.repay', args: [0, et.eth(2)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + }, + + { call: 'dTokens.dTST2.totalSupply', equals: [4, .001], }, + + + // Failures: + + { action: 'sendBatch', batch: [ + { send: 'dTokens.dTST2.borrow', args: [0, et.eth(1)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + expectError: 'e/borrow-cap-exceeded', + }, + + // Success + + { action: 'sendBatch', batch: [ + { send: 'dTokens.dTST2.borrow', args: [0, et.eth(1)], }, + { send: 'dTokens.dTST2.repay', args: [0, et.eth(0.1)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + }, + ], +}) + .test({ - desc: "can't exit market to bypass checks", + desc: "can't exit market to bypass supply cap checks", actions: ctx => [ // Current supply 10, supply cap 15 @@ -157,4 +275,38 @@ et.testSet({ ], }) + +.test({ + desc: "can't exit market to bypass borrow cap checks", + actions: ctx => [ + // Current borrow 1, borrow cap 5 + + { send: 'dTokens.dTST.borrow', args: [0, et.eth(1)], }, + { call: 'dTokens.dTST.totalSupply', equals: [1, .001], }, + { action: 'setAssetPolicy', tok: 'TST', policy: { borrowCap: 5, }, }, + + // Can't exit the market if the asset is in violation at the point of exit: + { send: 'markets.enterMarket', args: [1, ctx.contracts.tokens.TST.address], }, + + { action: 'sendBatch', batch: [ + { send: 'dTokens.dTST.borrow', args: [0, et.eth(5)], }, + { send: 'markets.exitMarket', args: [1, ctx.contracts.tokens.TST.address], }, + { send: 'dTokens.dTST.repay', args: [0, et.eth(5)], }, + ], + deferLiquidityChecks: [ctx.wallet.address, et.getSubAccount(ctx.wallet.address, 1)], + expectError: 'e/borrow-cap-exceeded', + }, + + // ... but you can if it is not in violation: + + { action: 'sendBatch', batch: [ + { send: 'dTokens.dTST.borrow', args: [0, et.eth(5)], }, + { send: 'dTokens.dTST.repay', args: [0, et.eth(5)], }, + { send: 'markets.exitMarket', args: [1, ctx.contracts.tokens.TST.address], }, + ], + deferLiquidityChecks: [ctx.wallet.address, et.getSubAccount(ctx.wallet.address, 1)], + }, + ], +}) + .run(); From 2967a3cd352525901f4d20e2785e6e1f66862d46 Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Wed, 15 Feb 2023 15:43:48 +0000 Subject: [PATCH 04/11] force entering market if needed to support supply and borrow caps --- contracts/BaseLogic.sol | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/contracts/BaseLogic.sol b/contracts/BaseLogic.sol index 9f96bb14..fe3e72c9 100644 --- a/contracts/BaseLogic.sol +++ b/contracts/BaseLogic.sol @@ -666,17 +666,21 @@ abstract contract BaseLogic is BaseModule { if (policy.supplyCap == 0 && policy.borrowCap == 0) return; if (!assetSnapshots[assetCache.underlying].dirty) return; - if (allowDefer && accountLookup[account].deferLiquidityStatus != DEFERLIQUIDITY__NONE && isEnteredInMarket(account, assetCache.underlying)) return; + + if (allowDefer && accountLookup[account].deferLiquidityStatus != DEFERLIQUIDITY__NONE) { + doEnterMarket(account, assetCache.underlying); + return; + } uint112 newTotalBalances = encodeAmount(balanceToUnderlyingAmount(assetCache, assetCache.totalBalances) / assetCache.underlyingDecimalsScaler); uint112 newTotalBorrows = encodeAmount(assetCache.totalBorrows / INTERNAL_DEBT_PRECISION / assetCache.underlyingDecimalsScaler); require(policy.supplyCap == 0 - || newTotalBalances < policy.supplyCap * 1e18 / assetCache.underlyingDecimalsScaler + || newTotalBalances < uint(policy.supplyCap) * 1e18 / assetCache.underlyingDecimalsScaler || newTotalBalances <= assetSnapshots[assetCache.underlying].origTotalBalances, "e/supply-cap-exceeded"); require(policy.borrowCap == 0 - || newTotalBorrows < policy.borrowCap * 1e18 / assetCache.underlyingDecimalsScaler + || newTotalBorrows < uint(policy.borrowCap) * 1e18 / assetCache.underlyingDecimalsScaler || newTotalBorrows <= assetSnapshots[assetCache.underlying].origTotalBorrows, "e/borrow-cap-exceeded"); assetSnapshots[assetCache.underlying] = AssetSnapshot(false, 0, 0); From d076a28c234f745affde003d8f79fd5a3b63b8a3 Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Wed, 15 Feb 2023 15:44:26 +0000 Subject: [PATCH 05/11] additional asset policies tests --- contracts/test/MockSwapHandler.sol | 13 + test/assetPolicies.js | 481 +++++++++++++++++++++++++++-- test/lib/eTestLib.js | 5 + 3 files changed, 478 insertions(+), 21 deletions(-) create mode 100644 contracts/test/MockSwapHandler.sol diff --git a/contracts/test/MockSwapHandler.sol b/contracts/test/MockSwapHandler.sol new file mode 100644 index 00000000..fa19f087 --- /dev/null +++ b/contracts/test/MockSwapHandler.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.0; + +import "../swapHandlers/ISwapHandler.sol"; +import "../Utils.sol"; + +/// @notice Base contract for swap handlers +contract MockSwapHandler is ISwapHandler { + function executeSwap(SwapParams calldata params) override external { + Utils.safeTransfer(params.underlyingOut, msg.sender, params.amountOut); + } +} diff --git a/test/assetPolicies.js b/test/assetPolicies.js index a9f363cb..36f3ddcb 100644 --- a/test/assetPolicies.js +++ b/test/assetPolicies.js @@ -1,11 +1,21 @@ const et = require('./lib/eTestLib'); const scenarios = require('./lib/scenarios'); +const PAUSETYPE__DEPOSIT = 1 << 0 +const PAUSETYPE__WITHDRAW = 1 << 1 +const PAUSETYPE__BORROW = 1 << 2 +const PAUSETYPE__REPAY = 1 << 3 +const PAUSETYPE__MINT = 1 << 4 +const PAUSETYPE__BURN = 1 << 5 et.testSet({ desc: "asset policies", - preActions: scenarios.basicLiquidity(), + preActions: ctx => { + let actions = scenarios.basicLiquidity()(ctx) + actions.push({ send: 'tokens.TST2.mint', args: [ctx.contracts.swapHandlers.mockSwapHandler.address, et.eth(100)], }) + return actions + } }) @@ -87,6 +97,96 @@ et.testSet({ }) +.test({ + desc: "supply and borrow cap for mint", + actions: ctx => [ + { call: 'eTokens.eTST.totalSupply', equals: [10, .001], }, + { call: 'dTokens.dTST.totalSupply', equals: [0], }, + + // Mint prevented: + + { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 12, borrowCap: 5 }, }, + { send: 'eTokens.eTST.mint', args: [0, et.eth(3)], expectError: 'e/supply-cap-exceeded', }, + + // Mint prevented: + + { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 15, borrowCap: 2 }, }, + { send: 'eTokens.eTST.mint', args: [0, et.eth(3)], expectError: 'e/borrow-cap-exceeded', }, + + // Raise caps and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 15, borrowCap: 5 }, }, + { send: 'eTokens.eTST.mint', args: [0, et.eth(3)], }, + + // New limit prevents additional mints: + + { send: 'eTokens.eTST.mint', args: [0, et.eth(3)], expectError: 'e/supply-cap-exceeded', }, + + // Lower supply cap. Burn still works, even though it's not enough burnt to solve the policy violation: + + { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 1, borrowCap: 1 }, }, + { send: 'eTokens.eTST.burn', args: [0, et.eth(1)], }, + { call: 'eTokens.eTST.totalSupply', equals: [12, .001], }, + { call: 'dTokens.dTST.totalSupply', equals: [2, .001], }, + + { send: 'eTokens.eTST.burn', args: [0, et.eth(1)], }, + { call: 'eTokens.eTST.totalSupply', equals: [11, .001], }, + { call: 'dTokens.dTST.totalSupply', equals: [1, .001], }, + + // Deposit doesn't work + + { send: 'eTokens.eTST.mint', args: [0, et.eth(.1)], expectError: 'e/supply-cap-exceeded', }, + + // Turn off supply cap. Mint still doesn't work because of borrow cap + + { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 0, borrowCap: 1 }, }, + + { send: 'eTokens.eTST.mint', args: [0, et.eth(.1)], expectError: 'e/borrow-cap-exceeded', }, + ], +}) + + +.test({ + desc: "supply cap for swap hub", + actions: ctx => [ + // Current supply 10, supply cap 15 + + { call: 'eTokens.eTST2.totalSupply', equals: [10, .001], }, + { action: 'setAssetPolicy', tok: 'TST2', policy: { supplyCap: 15, }, }, + + // Won't succeed if received tokens would put us over the supply cap + + { send: 'swapHub.swap', args: [0, 0, ctx.contracts.swapHandlers.mockSwapHandler.address, + { + underlyingIn: ctx.contracts.tokens.TST.address, + underlyingOut: ctx.contracts.tokens.TST2.address, + mode: 0, + amountIn: et.eth(1), + amountOut: et.eth(6), + exactOutTolerance: 0, + payload: '0x', + } + ], expectError: 'e/supply-cap-exceeded', }, + + // Succeeds if received tokens would put us below the supply cap + + { send: 'swapHub.swap', args: [0, 0, ctx.contracts.swapHandlers.mockSwapHandler.address, + { + underlyingIn: ctx.contracts.tokens.TST.address, + underlyingOut: ctx.contracts.tokens.TST2.address, + mode: 0, + amountIn: et.eth(1), + amountOut: et.eth(5), + exactOutTolerance: 0, + payload: '0x', + } + ], }, + + { call: 'eTokens.eTST2.totalSupply', equals: [15, .001], }, + ], +}) + + .test({ desc: "batch deferral of supply cap check", actions: ctx => [ @@ -104,22 +204,8 @@ et.testSet({ expectError: 'e/supply-cap-exceeded', }, - // This won't work because we aren't entered: - - { send: 'markets.exitMarket', args: [0, ctx.contracts.tokens.TST.address], }, - - { action: 'sendBatch', batch: [ - { send: 'eTokens.eTST.deposit', args: [0, et.eth(10)], }, - { send: 'eTokens.eTST.withdraw', args: [0, et.eth(8)], }, - ], - deferLiquidityChecks: [ctx.wallet.address], - expectError: 'e/supply-cap-exceeded', - }, - // Deferring doesn't allow us to leave the asset in policy violation: - { send: 'markets.enterMarket', args: [0, ctx.contracts.tokens.TST.address], }, - { action: 'sendBatch', batch: [ { send: 'eTokens.eTST.deposit', args: [0, et.eth(10)], }, ], @@ -127,7 +213,9 @@ et.testSet({ expectError: 'e/supply-cap-exceeded', }, - // Being entered into the market allows the policy check to be deferred, so transient violations don't fail: + // Even though we exited the market, it will get entered by itself, so transient violations don't fail: + + { send: 'markets.exitMarket', args: [0, ctx.contracts.tokens.TST.address], }, { action: 'sendBatch', batch: [ { send: 'eTokens.eTST.deposit', args: [0, et.eth(10)], }, @@ -146,8 +234,6 @@ et.testSet({ // Failures: - { send: 'markets.enterMarket', args: [0, ctx.contracts.tokens.TST.address], }, - { action: 'sendBatch', batch: [ { send: 'eTokens.eTST.deposit', args: [0, et.eth(10)], }, ], @@ -186,8 +272,6 @@ et.testSet({ // Deferring doesn't allow us to leave the asset in policy violation: - { send: 'markets.enterMarket', args: [0, ctx.contracts.tokens.TST2.address], }, - { action: 'sendBatch', batch: [ { send: 'dTokens.dTST2.borrow', args: [0, et.eth(6)], }, ], @@ -206,7 +290,7 @@ et.testSet({ { call: 'dTokens.dTST2.totalSupply', equals: [4, .001], }, - // This works despite the fact we had exited the market because we enter it again on borrow + // This works despite the fact we had exited the market because we enter it again when the asset policy is checked { send: 'dTokens.dTST2.repay', args: [0, et.MaxUint256], }, { send: 'markets.exitMarket', args: [0, ctx.contracts.tokens.TST2.address], }, @@ -309,4 +393,359 @@ et.testSet({ ], }) + +.test({ + desc: "simple actions pausing", + actions: ctx => [ + // Deposit prevented: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__DEPOSIT, }, }, + { send: 'eTokens.eTST.deposit', args: [0, 1], expectError: 'e/market-operation-paused', }, + + // Remove pause and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: 0, }, }, + { send: 'eTokens.eTST.deposit', args: [0, 1], }, + + // Withdrawal prevented: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__WITHDRAW, }, }, + { send: 'eTokens.eTST.withdraw', args: [0, 1], expectError: 'e/market-operation-paused', }, + + // Remove pause and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: 0, }, }, + { send: 'eTokens.eTST.withdraw', args: [0, 1], }, + + // Mint prevented: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__MINT, }, }, + { send: 'eTokens.eTST.mint', args: [0, 1], expectError: 'e/market-operation-paused', }, + + // Remove pause and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: 0, }, }, + { send: 'eTokens.eTST.mint', args: [0, 1], }, + + // Burn prevented: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__BURN, }, }, + { send: 'eTokens.eTST.burn', args: [0, 1], expectError: 'e/market-operation-paused', }, + + // Remove pause and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: 0, }, }, + { send: 'eTokens.eTST.burn', args: [0, 1], }, + + // Borrow prevented: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__BORROW, }, }, + { send: 'dTokens.dTST.borrow', args: [0, 1], expectError: 'e/market-operation-paused', }, + + // Remove pause and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: 0, }, }, + { send: 'dTokens.dTST.borrow', args: [0, 1], }, + + { send: 'dTokens.dTST.borrow', args: [0, et.eth(5)], }, + + // Repay prevented: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__REPAY, }, }, + { send: 'dTokens.dTST.repay', args: [0, et.MaxUint256], expectError: 'e/market-operation-paused', }, + + // Remove pause and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: 0, }, }, + { send: 'dTokens.dTST.repay', args: [0, et.MaxUint256], }, + + // eToken transfer prevented: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__DEPOSIT, }, }, + { send: 'eTokens.eTST.transfer', args: [et.AddressZero, 1], expectError: 'e/market-operation-paused', }, + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__WITHDRAW, }, }, + { send: 'eTokens.eTST.transfer', args: [et.AddressZero, 1], expectError: 'e/market-operation-paused', }, + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__DEPOSIT | PAUSETYPE__WITHDRAW, }, }, + { send: 'eTokens.eTST.transfer', args: [et.AddressZero, 1], expectError: 'e/market-operation-paused', }, + + // Remove pause and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: 0, }, }, + { send: 'eTokens.eTST.transfer', args: [et.AddressZero, 1], }, + + // dToken transfer prevented: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__BORROW, }, }, + { send: 'dTokens.dTST.transfer', args: [et.getSubAccount(ctx.wallet.address, 1), 1], expectError: 'e/market-operation-paused', }, + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__REPAY, }, }, + { send: 'dTokens.dTST.transfer', args: [et.getSubAccount(ctx.wallet.address, 1), 1], expectError: 'e/market-operation-paused', }, + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__BORROW | PAUSETYPE__REPAY, }, }, + { send: 'dTokens.dTST.transfer', args: [et.getSubAccount(ctx.wallet.address, 1), 1], expectError: 'e/market-operation-paused', }, + + // Remove pause and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: 0, }, }, + { send: 'eTokens.eTST.transfer', args: [et.getSubAccount(ctx.wallet.address, 1), 1], }, + + // swap prevented: + + { action: 'setAssetPolicy', tok: 'TST2', policy: { pauseBitmask: PAUSETYPE__DEPOSIT, }, }, + { send: 'swapHub.swap', args: [0, 0, ctx.contracts.swapHandlers.mockSwapHandler.address, + { + underlyingIn: ctx.contracts.tokens.TST.address, + underlyingOut: ctx.contracts.tokens.TST2.address, + mode: 0, + amountIn: 1, + amountOut: 1, + exactOutTolerance: 0, + payload: '0x', + } + ], expectError: 'e/market-operation-paused', }, + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__WITHDRAW, }, }, + { action: 'setAssetPolicy', tok: 'TST2', policy: { pauseBitmask: 0, }, }, + { send: 'swapHub.swap', args: [0, 0, ctx.contracts.swapHandlers.mockSwapHandler.address, + { + underlyingIn: ctx.contracts.tokens.TST.address, + underlyingOut: ctx.contracts.tokens.TST2.address, + mode: 0, + amountIn: 1, + amountOut: 1, + exactOutTolerance: 0, + payload: '0x', + } + ], expectError: 'e/market-operation-paused', }, + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__WITHDRAW, }, }, + { action: 'setAssetPolicy', tok: 'TST2', policy: { pauseBitmask: PAUSETYPE__DEPOSIT, }, }, + { send: 'swapHub.swap', args: [0, 0, ctx.contracts.swapHandlers.mockSwapHandler.address, + { + underlyingIn: ctx.contracts.tokens.TST.address, + underlyingOut: ctx.contracts.tokens.TST2.address, + mode: 0, + amountIn: 1, + amountOut: 1, + exactOutTolerance: 0, + payload: '0x', + } + ], expectError: 'e/market-operation-paused', }, + + // Remove pause and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: 0, }, }, + { action: 'setAssetPolicy', tok: 'TST2', policy: { pauseBitmask: 0, }, }, + { send: 'swapHub.swap', args: [0, 0, ctx.contracts.swapHandlers.mockSwapHandler.address, + { + underlyingIn: ctx.contracts.tokens.TST.address, + underlyingOut: ctx.contracts.tokens.TST2.address, + mode: 0, + amountIn: 1, + amountOut: 1, + exactOutTolerance: 0, + payload: '0x', + } + ], }, + + { send: 'dTokens.dTST2.borrow', args: [0, et.eth(5)], }, + + // swap and repay prevented: + + { action: 'setAssetPolicy', tok: 'TST2', policy: { pauseBitmask: PAUSETYPE__REPAY, }, }, + { send: 'swapHub.swapAndRepay', args: [0, 0, ctx.contracts.swapHandlers.mockSwapHandler.address, + { + underlyingIn: ctx.contracts.tokens.TST.address, + underlyingOut: ctx.contracts.tokens.TST2.address, + mode: 1, + amountIn: 1, + amountOut: 0, + exactOutTolerance: 0, + payload: '0x', + }, + 0, + ], expectError: 'e/market-operation-paused', }, + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__WITHDRAW, }, }, + { action: 'setAssetPolicy', tok: 'TST2', policy: { pauseBitmask: 0, }, }, + { send: 'swapHub.swapAndRepay', args: [0, 0, ctx.contracts.swapHandlers.mockSwapHandler.address, + { + underlyingIn: ctx.contracts.tokens.TST.address, + underlyingOut: ctx.contracts.tokens.TST2.address, + mode: 1, + amountIn: 1, + amountOut: 0, + exactOutTolerance: 0, + payload: '0x', + }, + 0, + ], expectError: 'e/market-operation-paused', }, + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: PAUSETYPE__WITHDRAW, }, }, + { action: 'setAssetPolicy', tok: 'TST2', policy: { pauseBitmask: PAUSETYPE__REPAY, }, }, + { send: 'swapHub.swapAndRepay', args: [0, 0, ctx.contracts.swapHandlers.mockSwapHandler.address, + { + underlyingIn: ctx.contracts.tokens.TST.address, + underlyingOut: ctx.contracts.tokens.TST2.address, + mode: 1, + amountIn: 1, + amountOut: 0, + exactOutTolerance: 0, + payload: '0x', + }, + 0, + ], expectError: 'e/market-operation-paused', }, + + // Remove pause and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST', policy: { pauseBitmask: 0, }, }, + { action: 'setAssetPolicy', tok: 'TST2', policy: { pauseBitmask: 0, }, }, + { send: 'swapHub.swapAndRepay', args: [0, 0, ctx.contracts.swapHandlers.mockSwapHandler.address, + { + underlyingIn: ctx.contracts.tokens.TST.address, + underlyingOut: ctx.contracts.tokens.TST2.address, + mode: 1, + amountIn: 1, + amountOut: 0, + exactOutTolerance: 0, + payload: '0x', + }, + 0, + ], }, + ], +}) + + +.test({ + desc: "complex scenario", + actions: ctx => [ + { call: 'eTokens.eTST.totalSupply', equals: [10, .001], }, + { call: 'eTokens.eTST2.totalSupply', equals: [10, .001], }, + { call: 'dTokens.dTST.totalSupply', equals: [0], }, + { call: 'dTokens.dTST2.totalSupply', equals: [0], }, + + { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 15, pauseBitmask: PAUSETYPE__MINT }, }, + { action: 'setAssetPolicy', tok: 'TST2', policy: { borrowCap: 5, }, }, + + // This won't work because the end state violates asset policies: + + { action: 'sendBatch', batch: [ + { send: 'eTokens.eTST.deposit', args: [0, et.eth(7)], }, + { send: 'dTokens.dTST2.borrow', args: [0, et.eth(7)], }, + + { send: 'eTokens.eTST.withdraw', args: [0, et.eth(1)], }, + { send: 'dTokens.dTST2.repay', args: [0, et.eth(1)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + expectError: 'e/supply-cap-exceeded', + }, + + { action: 'sendBatch', batch: [ + { send: 'eTokens.eTST.deposit', args: [0, et.eth(7)], }, + { send: 'dTokens.dTST2.borrow', args: [0, et.eth(7)], }, + + { send: 'eTokens.eTST.withdraw', args: [0, et.eth(3)], }, + { send: 'dTokens.dTST2.repay', args: [0, et.eth(1)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + expectError: 'e/borrow-cap-exceeded', + }, + + // Succeeeds if there's no violation: + + { action: 'sendBatch', batch: [ + { send: 'eTokens.eTST.deposit', args: [0, et.eth(7)], }, + { send: 'dTokens.dTST2.borrow', args: [0, et.eth(7)], }, + + { send: 'eTokens.eTST.withdraw', args: [0, et.eth(3)], }, + { send: 'dTokens.dTST2.repay', args: [0, et.eth(3)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + }, + + { send: 'eTokens.eTST.withdraw', args: [0, et.eth(4)], }, + { send: 'dTokens.dTST2.repay', args: [0, et.MaxUint256], }, + + // Fails again if mint item added: + + { action: 'sendBatch', batch: [ + { send: 'eTokens.eTST.deposit', args: [0, et.eth(7)], }, + { send: 'dTokens.dTST2.borrow', args: [0, et.eth(7)], }, + + { send: 'eTokens.eTST.mint', args: [0, et.eth(1)], }, + + { send: 'eTokens.eTST.withdraw', args: [0, et.eth(3)], }, + { send: 'dTokens.dTST2.repay', args: [0, et.eth(4)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + expectError: 'e/market-operation-paused', + }, + + // Succeeds again if mint item added for TST2 instead of TST: + + { action: 'sendBatch', batch: [ + { send: 'eTokens.eTST.deposit', args: [0, et.eth(7)], }, + { send: 'dTokens.dTST2.borrow', args: [0, et.eth(7)], }, + + { send: 'eTokens.eTST2.mint', args: [0, et.eth(1)], }, + + { send: 'eTokens.eTST.withdraw', args: [0, et.eth(3)], }, + { send: 'dTokens.dTST2.repay', args: [0, et.eth(4)], }, + ], + deferLiquidityChecks: [ctx.wallet.address], + }, + + // checkpoint: + + { call: 'eTokens.eTST.totalSupply', equals: [14, .001], }, + { call: 'eTokens.eTST2.totalSupply', equals: [11, .001], }, + { call: 'dTokens.dTST.totalSupply', equals: [0], }, + { call: 'dTokens.dTST2.totalSupply', equals: [4, .001], }, + + // set new asset policies: + + { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 10, borrowCap: 1 }, }, + { action: 'setAssetPolicy', tok: 'TST2', policy: { supplyCap: 1, borrowCap: 1, }, }, + + { action: 'sendBatch', batch: [ + // transfer TST2 deposit to sub-account 1. + // do not use 'transfer' function to test entering the market in assetPolicyClean. + // if not entered, it'd fail due to exceeded supply cap + { send: 'eTokens.eTST2.withdraw', args: [0, et.MaxUint256], }, + { send: 'eTokens.eTST2.deposit', args: [1, et.MaxUint256], }, // this exceeds the supply cap temporarily + + { send: 'dTokens.dTST2.transfer', args: [et.getSubAccount(ctx.wallet.address, 1), et.MaxUint256], }, + { send: 'eTokens.eTST.deposit', args: [0, et.eth(1)], }, // this exceeds the supply cap temporarily + + { send: 'swapHub.swap', args: [0, 1, ctx.contracts.swapHandlers.mockSwapHandler.address, + { + underlyingIn: ctx.contracts.tokens.TST.address, + underlyingOut: ctx.contracts.tokens.TST2.address, + mode: 0, + amountIn: et.eth(10), // this should send enough to swap handler not to violate the supply cap any longer + amountOut: et.eth(10), // this exceeds the supply cap temporarily + exactOutTolerance: 0, + payload: '0x', + }, + ], }, + + // this should burn TST2 debt and deposits, leaving the TST2 borrow cap no longer violated + { send: 'eTokens.eTST2.burn', args: [1, et.MaxUint256], }, + + // this should withdraw TST2 deposits, leaving the TST2 supply cap no longer violated. + // despite the total supply will be greater than the supply cap, the total supply was reduced hence the transaction succeeds + { send: 'eTokens.eTST2.withdraw', args: [1, et.MaxUint256], }, + ], + deferLiquidityChecks: [ctx.wallet.address, et.getSubAccount(ctx.wallet.address, 1)], + }, + + { call: 'eTokens.eTST.totalSupply', equals: [5, .001], }, + { call: 'eTokens.eTST2.totalSupply', equals: [10, .001], }, + { call: 'dTokens.dTST.totalSupply', equals: [0], }, + { call: 'dTokens.dTST2.totalSupply', equals: [0, .001], }, + ], +}) + .run(); diff --git a/test/lib/eTestLib.js b/test/lib/eTestLib.js index 88b04b37..dbcd8dbe 100644 --- a/test/lib/eTestLib.js +++ b/test/lib/eTestLib.js @@ -97,6 +97,7 @@ const contractNames = [ 'TestModule', 'MockAggregatorProxy', 'MockStETH', + 'MockSwapHandler', // Custom Oracles @@ -1064,6 +1065,10 @@ async function deployContracts(provider, wallets, tokenSetupName, verify = null) }; if (ctx.tokenSetup.testing) { + // Deploy mock swap handler + + ctx.contracts.swapHandlers.mockSwapHandler = await (await ctx.factories.MockSwapHandler.deploy()).deployed(); + // Setup default ETokens/DTokens for (let tok of ctx.tokenSetup.testing.activated) { From 9b4eb5220f5b72087b7604f60b96cdf5cd24bf47 Mon Sep 17 00:00:00 2001 From: Doug Hoyte Date: Wed, 15 Feb 2023 16:33:25 -0500 Subject: [PATCH 06/11] clean-up --- contracts/Storage.sol | 1 - contracts/modules/Swap.sol | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/contracts/Storage.sol b/contracts/Storage.sol index 73514c23..28dd903d 100644 --- a/contracts/Storage.sol +++ b/contracts/Storage.sol @@ -98,7 +98,6 @@ abstract contract Storage is Constants { uint64 supplyCap; // underlying units without decimals, 0 means no cap uint64 borrowCap; // underlying units without decimals, 0 means no cap uint16 pauseBitmask; - uint32 loanOriginationFee; } struct AssetSnapshot { diff --git a/contracts/modules/Swap.sol b/contracts/modules/Swap.sol index c7766d6a..77891913 100644 --- a/contracts/modules/Swap.sol +++ b/contracts/modules/Swap.sol @@ -415,7 +415,7 @@ contract Swap is BaseLogic { function finalizeSwapAndRepay(SwapCache memory swap) private { assetPolicyCheck(swap.assetCacheIn.underlying, PAUSETYPE__WITHDRAW); assetPolicyCheck(swap.assetCacheOut.underlying, PAUSETYPE__REPAY); - + uint balanceIn = checkBalances(swap); processWithdraw(eTokenLookup[swap.eTokenIn], swap.assetCacheIn, swap.eTokenIn, swap.accountIn, swap.amountInternalIn, balanceIn); From 01aa42164ff5f2b489f0a62e596291b9a448a625 Mon Sep 17 00:00:00 2001 From: Doug Hoyte Date: Wed, 15 Feb 2023 16:33:35 -0500 Subject: [PATCH 07/11] tests for non-18 decimal place tokens, and exchange rate conversion --- test/assetPolicies.js | 74 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/test/assetPolicies.js b/test/assetPolicies.js index 36f3ddcb..7d2c8e79 100644 --- a/test/assetPolicies.js +++ b/test/assetPolicies.js @@ -748,4 +748,78 @@ et.testSet({ ], }) + + +.test({ + desc: "supply/borrow caps, 6 decimals", + actions: ctx => [ + // Deposit prevented: + + { action: 'setAssetPolicy', tok: 'TST9', policy: { supplyCap: 1e6, }, }, + + { send: 'tokens.TST9.mint', args: [ctx.wallet.address, et.units(2e6, 6)], }, + { send: 'tokens.TST9.approve', args: [ctx.contracts.euler.address, et.MaxUint256,], }, + { send: 'eTokens.eTST9.deposit', args: [0, et.units('1000000.000001', 6)], expectError: 'e/supply-cap-exceeded', }, + + // Raise Cap and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST9', policy: { supplyCap: 1.1e6, }, }, + { send: 'eTokens.eTST9.deposit', args: [0, et.units('1000000.000001', 6)], }, + + // Set a borrow cap + + { action: 'setAssetPolicy', tok: 'TST9', policy: { supplyCap: 1.1e6, borrowCap: 0.5e6, }, }, + { send: 'dTokens.dTST9.borrow', args: [0, et.units(0.4e6, 6)], }, + { send: 'dTokens.dTST9.borrow', args: [0, et.units(0.2e6, 6)], expectError: 'e/borrow-cap-exceeded', }, + ], +}) + + +.test({ + desc: "supply/borrow caps, 0 decimals", + actions: ctx => [ + // Deposit prevented: + + { action: 'setAssetPolicy', tok: 'TST10', policy: { supplyCap: 8000, }, }, + + { send: 'tokens.TST10.mint', args: [ctx.wallet.address, et.units(100000, 0)], }, + { send: 'tokens.TST10.approve', args: [ctx.contracts.euler.address, et.MaxUint256,], }, + { send: 'eTokens.eTST10.deposit', args: [0, et.units(8001, 0)], expectError: 'e/supply-cap-exceeded', }, + + // Raise Cap and it succeeds: + + { action: 'setAssetPolicy', tok: 'TST10', policy: { supplyCap: 8001, }, }, + { send: 'eTokens.eTST10.deposit', args: [0, et.units(8001, 0)], }, + + // Set a borrow cap + + { action: 'setAssetPolicy', tok: 'TST10', policy: { supplyCap: 8001, borrowCap: 2000, }, }, + { send: 'dTokens.dTST10.borrow', args: [0, et.units(1999, 0)], }, + { send: 'dTokens.dTST10.borrow', args: [0, et.units(2, 0)], expectError: 'e/borrow-cap-exceeded', }, + ], +}) + + +.test({ + desc: "exchange rate conversion", + actions: ctx => [ + { call: 'eTokens.eTST.totalSupplyUnderlying', equals: [10, .001], }, + + // Transfer directly to the pool to increase exchange rate + { call: 'eTokens.eTST.convertBalanceToUnderlying', args: [et.eth(1)], equals: ['1', '0.00000001'], }, + { send: 'tokens.TST.mint', args: [ctx.contracts.euler.address, et.eth(40)], }, + { call: 'eTokens.eTST.convertBalanceToUnderlying', args: [et.eth(1)], equals: ['5', '0.00000001'], }, + + { call: 'eTokens.eTST.totalSupplyUnderlying', equals: [50, .001], }, + + // Deposit prevented: + + { send: 'tokens.TST.mint', args: [ctx.wallet.address, et.eth(10000)], }, + { action: 'setAssetPolicy', tok: 'TST', policy: { supplyCap: 100, }, }, + { send: 'eTokens.eTST.deposit', args: [0, et.eth(51)], expectError: 'e/supply-cap-exceeded', }, + { send: 'eTokens.eTST.deposit', args: [0, et.eth(50)], }, + ], +}) + + .run(); From 11c2e38fbf33d5d9fd6170249a5e78e88c5f9ca0 Mon Sep 17 00:00:00 2001 From: Doug Hoyte Date: Wed, 15 Feb 2023 16:34:07 -0500 Subject: [PATCH 08/11] more docs on asset policies --- docs/asset-policies.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/docs/asset-policies.md b/docs/asset-policies.md index af48c7e9..ea024a0b 100644 --- a/docs/asset-policies.md +++ b/docs/asset-policies.md @@ -4,29 +4,30 @@ For some assets it is desired to limit the amount of assets supplied and/or borrowed on the platform. This could be done in order to phase-in a collateral asset while minimising exposure to the protocol. An asset may also have a limited amount of on-chain liquidity, which could merit capping the lending/borrowing activity. -While it is possible for governance to lower a cap to below the current supply/borrow level, caps are not intended to function as emergency pause mechanisms. It should still be possible to withdraw/repay, even when an asset is in violation of the cap. This should be true even if your withdraw/repay is insufficient to solve the violation, and the asset's supply/borrow remains above your the cap after your operation. +While it is possible for governance to lower a cap to below the current supply/borrow level, caps are not intended to function as emergency pause mechanisms. It should still be possible to withdraw/repay, even when an asset is in violation of the cap. This should be true even if your withdraw/repay is insufficient to solve the violation, and the asset's supply/borrow remains above the cap after your operation. Furthermore, it should be possible to temporarily exceed the cap, as long as the supply/borrows are brought back down to a non-violating amount in the same transaction, or at least to the level they were when the transaction was initiated. +The caps are specified in terms of the underlying asset's units and are not converted to their ETH equivalents (for example). + ### Operation pausing -In order to give governance an ability to quickly react to market conditions, contract bugs, and other unpredictable events, assets can have operations "paused". This is intended to temporarily prevent a deposit/withdraw/etc operation from being performed on the asset. +Additionally, in order to give governance the ability to quickly react to market conditions, contract bugs, and other unpredictable events, assets can have operations "paused". This is intended to temporarily prevent a deposit/withdraw/etc operation from being performed on the asset. ### Mechanism -Caps and operation pausing are specified inside a new storage mapping `assetPolicies`. This mapping only needs to exist for assets that have policies configured. +Caps and operation pausing are specified inside a new storage mapping `assetPolicies`. This mapping only needs to exist for assets that have policies configured. Whenever an operation is to be performed on an asset, its asset policies should be checked to ensure the operation is not paused, and also that the caps will not be exceeded. -Whenever an operation is to be performed on an asset, its asset policies should be checked to ensure the operation is not paused, and also that the caps will not be exceeded. +For operations that cannot result in a cap being exceeded, the `assetPolicyCheck` function is used. This simply looks up the asset's policy and confirms the requested operation is not paused, throwing an exception if it is. +For operations that can result in a cap being exceeded, the pair of functions `assetPolicyDirty` and `assetPolicyClean` should be used instead. The first checks if the operation is paused as above, but also (if caps are configured) takes a snapshot of the `totalBalances` and `totalBorrows` of the asset and then marks the asset as "dirty", meaning it will need checking to validate it hasn't exceeded the caps. The operation is performed and then `assetPolicyClean` is called. This will either: +1. Verify the caps have not been exceeded (or at least are no more exceeded than when the snapshot was taken), and mark the asset clean +2. If the user has deferred liquidity checking, ensure that the user is entered into this market and then return, leaving the asset dirty +In the second case, the asset will have its caps verified and the assets marked dirty when the account's liquidity checking occurs. In this case, it will attempt to clean all markets the user has entered. Because of this, if a user attempts to exit a market, it must be cleaned first. +### Notes -TODO: - * Testing - * Alternate decimals - * Exchange rate != 1 - * Pause bitmask - * Add pause checks to places that need them - * new pause checks? - * optimisations +* In order to leave space for future asset policy extensions without overflowing into a new storage slot, the supply and borrow caps have been packed uint `uint64` types. These represent the underlying units without decimals. So, a supply cap of 1 million on an 18 decimal place token would simply be stored as `1000000`, not `1000000 * 1e18`. This reduces the granularity of the caps because fractional units cannot be represented, but does not reduce the range, since asset amounts including decimals must fit within a `uint112` on the euler platform (the `MAX_SANE_AMOUNT` constant). +* The supply/borrow caps are effectively "first-come-first-serve" allocations. This means that it is possible for a user to deliberately perform a large deposit and/or borrow in order to max out the cap, thereby denying the ability of other users to participate in the market. This attack can be done in a relatively capital-efficient manner using self-collateralised loans. For this reason, governance should be careful with caps and should monitor activity on capped markets closely. From 92571905e31907a78baf740067a5560300c932a1 Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Thu, 16 Feb 2023 11:33:01 +0000 Subject: [PATCH 09/11] added getAssetPolicy function --- contracts/Storage.sol | 4 ++-- contracts/modules/Markets.sol | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/contracts/Storage.sol b/contracts/Storage.sol index 28dd903d..e759be1e 100644 --- a/contracts/Storage.sol +++ b/contracts/Storage.sol @@ -106,6 +106,6 @@ abstract contract Storage is Constants { uint112 origTotalBorrows; // underlying units and decimals } - mapping(address => AssetPolicy) assetPolicies; // underlying => AssetPolicy - mapping(address => AssetSnapshot) assetSnapshots; // underlying => AssetSnapshot + mapping(address => AssetPolicy) internal assetPolicies; // underlying => AssetPolicy + mapping(address => AssetSnapshot) internal assetSnapshots; // underlying => AssetSnapshot } diff --git a/contracts/modules/Markets.sol b/contracts/modules/Markets.sol index 6473cc45..8336f241 100644 --- a/contracts/modules/Markets.sol +++ b/contracts/modules/Markets.sol @@ -236,6 +236,12 @@ contract Markets is BaseLogic { chainlinkAggregator = chainlinkPriceFeedLookup[underlying]; } + /// @notice Retrieves the Asset Policy config for an asset + /// @param underlying Token address + /// @return assetPolicy Asset Policy config + function getAssetPolicy(address underlying) external view returns (Storage.AssetPolicy memory assetPolicy) { + assetPolicy = assetPolicies[underlying]; + } // Enter/exit markets From b0b160249c24adc390c6e697d4fe4d57a82a4cad Mon Sep 17 00:00:00 2001 From: Tanya Roze Date: Mon, 13 Feb 2023 11:06:27 -0500 Subject: [PATCH 10/11] fix: links in readme file --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bb68f6d5..3527505f 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ This repo contains the smart contracts and tests for the [Euler Protocol](https: ## Docs * [General Euler Docs](https://docs.euler.finance/) -* [Contract Architecture](https://docs.euler.finance/developers/architecture) -* [Contract Reference](https://docs.euler.finance/developers/contract-reference) -* [IEuler.sol Solidity Interface](https://github.com/euler-xyz/euler-interfaces/blob/master/IEuler.sol) +* [Contract Architecturel](https://docs.euler.finance/developers/getting-started/architecture) +* [Contract Reference](https://docs.euler.finance/developers/getting-started/contract-reference) +* [IEuler.sol Solidity Interface](https://github.com/euler-xyz/euler-interfaces/blob/master/contracts/IEuler.sol) ## License From 1ab4fbd88b79b73ddc8cae672759dd767b9df480 Mon Sep 17 00:00:00 2001 From: kasperpawlowski Date: Thu, 16 Feb 2023 11:40:05 +0000 Subject: [PATCH 11/11] view - asset policies --- contracts/views/EulerGeneralView.sol | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/contracts/views/EulerGeneralView.sol b/contracts/views/EulerGeneralView.sol index 1cf4d1fd..cdaa62fe 100644 --- a/contracts/views/EulerGeneralView.sol +++ b/contracts/views/EulerGeneralView.sol @@ -57,6 +57,8 @@ contract EulerGeneralView is Constants { uint borrowAPY; uint supplyAPY; + Storage.AssetPolicy assetPolicy; + // Pricing uint twap; @@ -165,6 +167,11 @@ contract EulerGeneralView is Constants { (m.borrowAPY, m.supplyAPY) = computeAPYs(borrowSPY, m.totalBorrows, m.totalBalances, m.reserveFee); } + { + Storage.AssetPolicy memory p = marketsProxy.getAssetPolicy(m.underlying); + m.assetPolicy = p; + } + (m.twap, m.twapPeriod, m.currPrice) = execProxy.getPriceFull(m.underlying); (m.pricingType, m.pricingParameters, m.pricingForwarded) = marketsProxy.getPricingConfig(m.underlying);