diff --git a/knip.json b/knip.json index 40c13964..93d9137a 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,7 @@ { "entry": ["solidity/ts/compile.ts", "solidity/ts/gas-costs.ts", "solidity/ts/tests/**/*.ts", "solidity/ts/fuzz/**/*.ts", "solidity/ts/types/bun-test.d.ts", "solidity/ts/types/index.d.ts", "ui/ts/tests/**/*.ts", "ui/ts/tests/**/*.tsx", "ui/ts/index.ts", "ui/build/vendor.mts", "ui/build/tests.mts"], "project": ["solidity/ts/**/*.ts", "ui/ts/**/*.ts", "ui/ts/**/*.tsx", "ui/build/**/*.mts"], - "ignoreDependencies": ["better-typescript-lib"], + "ignoreDependencies": ["better-typescript-lib", "@zoltar/shared"], "ignoreIssues": { "solidity/ts/**/*.ts": ["unlisted"] }, diff --git a/solidity/contracts/peripherals/SecurityPoolOracleCoordinator.sol b/solidity/contracts/peripherals/SecurityPoolOracleCoordinator.sol index c63932d0..4b57510b 100644 --- a/solidity/contracts/peripherals/SecurityPoolOracleCoordinator.sol +++ b/solidity/contracts/peripherals/SecurityPoolOracleCoordinator.sol @@ -7,8 +7,10 @@ import { ReputationToken } from '../ReputationToken.sol'; import { ISecurityPool } from './interfaces/ISecurityPool.sol'; // price oracle -uint256 constant PRICE_VALID_FOR_SECONDS = 1 hours; +uint256 constant PRICE_VALID_FOR_SECONDS = 5 minutes; uint256 constant PRICE_PRECISION = 1e18; +uint256 constant ORACLE_BUDGET_BPS = 10000; +uint256 constant MAX_OPERATION_VALID_FOR_SECONDS = 5 minutes; enum OperationType { Liquidation, @@ -49,6 +51,14 @@ contract SecurityPoolOracleCoordinator { bool public immutable timeType; bool public immutable trackDisputes; address public immutable protocolFeeRecipient; + uint256 public immutable priceRoundBudgetMultiplierBps; + uint256 public immutable escalationHaltMultiplierBps; + uint256 public immutable maxSettlementBaseFeeMultiplierBps; + uint256 public immutable minLiquidationPriceDistanceBps; + uint256 public pendingReportMaxSettlementBaseFee; + uint256 public priceRoundId; + uint256 public priceRoundMaxNotional; + uint256 public priceRoundConsumedNotional; event PriceReported(uint256 reportId, uint256 price); event StagedOperationQueued( @@ -87,7 +97,11 @@ contract SecurityPoolOracleCoordinator { uint16 _multiplier, bool _timeType, bool _trackDisputes, - address _protocolFeeRecipient + address _protocolFeeRecipient, + uint256 _priceRoundBudgetMultiplierBps, + uint256 _escalationHaltMultiplierBps, + uint256 _maxSettlementBaseFeeMultiplierBps, + uint256 _minLiquidationPriceDistanceBps ) { reputationToken = _reputationToken; openOracle = _openOracle; @@ -103,6 +117,14 @@ contract SecurityPoolOracleCoordinator { timeType = _timeType; trackDisputes = _trackDisputes; protocolFeeRecipient = _protocolFeeRecipient; + require(_priceRoundBudgetMultiplierBps > 0, 'price budget multiplier is zero'); + require(_escalationHaltMultiplierBps > 0, 'escalation halt multiplier is zero'); + priceRoundBudgetMultiplierBps = _priceRoundBudgetMultiplierBps; + escalationHaltMultiplierBps = _escalationHaltMultiplierBps; + require(_maxSettlementBaseFeeMultiplierBps >= ORACLE_BUDGET_BPS, 'base fee multiplier too low'); + require(_minLiquidationPriceDistanceBps <= ORACLE_BUDGET_BPS, 'liquidation distance too high'); + maxSettlementBaseFeeMultiplierBps = _maxSettlementBaseFeeMultiplierBps; + minLiquidationPriceDistanceBps = _minLiquidationPriceDistanceBps; } function setSecurityPool(ISecurityPool _securityPool) public { @@ -123,11 +145,12 @@ contract SecurityPoolOracleCoordinator { require(pendingReportId == 0, 'Already pending request'); uint256 ethCost = getRequestPriceEthCost(); require(msg.value >= ethCost, 'not big enough eth bounty'); - uint256 escalationHalt = reputationToken.totalSupply() / 100000; + uint256 escalationHalt = (exactToken1Report * escalationHaltMultiplierBps) / ORACLE_BUDGET_BPS; uint256 settlerReward = block.basefee * 2 * gasConsumedOpenOracleReportPrice; require(exactToken1Report <= type(uint128).max, 'exactToken1Report too large'); require(escalationHalt <= type(uint128).max, 'escalation halt too large'); require(settlerReward <= type(uint96).max, 'settler reward too large'); + pendingReportMaxSettlementBaseFee = (block.basefee * maxSettlementBaseFeeMultiplierBps) / ORACLE_BUDGET_BPS; OpenOracle.CreateReportParams memory reportparams = OpenOracle.CreateReportParams({ exactToken1Report: uint128(exactToken1Report), @@ -167,6 +190,11 @@ contract SecurityPoolOracleCoordinator { require(msg.sender == address(openOracle), 'only open oracle can call'); require(reportId == pendingReportId, 'not report created by us'); pendingReportId = 0; + if (block.basefee > pendingReportMaxSettlementBaseFee) { + pendingReportMaxSettlementBaseFee = 0; + return; + } + pendingReportMaxSettlementBaseFee = 0; if (amount1 == 0 || amount2 == 0) { return; } @@ -176,22 +204,49 @@ contract SecurityPoolOracleCoordinator { } lastSettlementTimestamp = block.timestamp; lastPrice = price; + priceRoundId++; + priceRoundConsumedNotional = 0; + priceRoundMaxNotional = + (exactToken1Report * PRICE_PRECISION * priceRoundBudgetMultiplierBps) / + price / + ORACLE_BUDGET_BPS; emit PriceReported(reportId, lastPrice); if (pendingOperationSlotId != 0) { // TODO we maybe should allow executing couple operations? uint256 operationId = pendingOperationSlotId; pendingOperationSlotId = 0; + StagedOperation memory stagedOperation = stagedOperations[operationId]; + if (stagedOperation.initiatorVault == address(0)) return; + if (block.timestamp > stagedOperation.queuedAt + settlementTime + stagedOperation.validForSeconds) { + _consumeActiveStagedOperation(operationId); + stagedOperations[operationId].initiatorVault = address(0); + emit ExecutedStagedOperation(operationId, stagedOperation.operation, false, 'staged operation expired'); + return; + } executeStagedOperation(operationId); } } function isPriceValid() public view returns (bool) { + return isPriceFresh(); + } + + function isPriceFresh() public view returns (bool) { return lastPrice > 0 && lastSettlementTimestamp != 0 && lastSettlementTimestamp + PRICE_VALID_FOR_SECONDS > block.timestamp; } + function getPriceRoundRemainingNotional() public view returns (uint256) { + if (priceRoundMaxNotional <= priceRoundConsumedNotional) return 0; + return priceRoundMaxNotional - priceRoundConsumedNotional; + } + + function isPriceUsable() public view returns (bool) { + return isPriceFresh() && getPriceRoundRemainingNotional() > 0; + } + function requestPriceIfNeededAndStageOperation( OperationType operation, address targetVault, @@ -202,6 +257,7 @@ contract SecurityPoolOracleCoordinator { require(amount > 0, 'need to do non zero operation'); } require(validForSeconds > 0, 'operation timeout must be positive'); + require(validForSeconds <= MAX_OPERATION_VALID_FOR_SECONDS, 'operation timeout too long'); if (operation != OperationType.Liquidation) { require(targetVault == msg.sender, 'self operation target must match initiator'); } @@ -236,11 +292,13 @@ contract SecurityPoolOracleCoordinator { uint256 retained = 0; // amount to retain from msg.value (cost incurred) - if (isPriceValid()) { + if ( + isPriceFresh() && _getOperationNotional(stagedOperations[operationId]) <= getPriceRoundRemainingNotional() + ) { emit StagedOperationQueued(operationId, operation, msg.sender, targetVault, amount, false); executeStagedOperation(operationId); // no cost when price is valid - } else if (pendingOperationSlotId == 0) { + } else if (pendingReportId == 0 && pendingOperationSlotId == 0) { pendingOperationSlotId = operationId; emit StagedOperationQueued(operationId, operation, msg.sender, targetVault, amount, true); uint256 ethCost = getRequestPriceEthCost(); @@ -248,6 +306,11 @@ contract SecurityPoolOracleCoordinator { retained += ethCost; // Forward exactly ethCost to requestPrice to create the report this.requestPrice{ value: ethCost }(); + } else if (pendingReportId == 0) { + emit StagedOperationQueued(operationId, operation, msg.sender, targetVault, amount, false); + } else if (pendingOperationSlotId == 0) { + pendingOperationSlotId = operationId; + emit StagedOperationQueued(operationId, operation, msg.sender, targetVault, amount, true); } else { emit StagedOperationQueued(operationId, operation, msg.sender, targetVault, amount, false); // This is intentional: only one staged operation is marked as the auto-execute @@ -267,13 +330,30 @@ contract SecurityPoolOracleCoordinator { function executeStagedOperation(uint256 operationId) public { StagedOperation memory stagedOperation = stagedOperations[operationId]; require(stagedOperation.initiatorVault != address(0), 'no such operation'); - require(isPriceValid(), 'price is not valid to execute'); + require(isPriceFresh(), 'price is not valid to execute'); require( block.timestamp <= stagedOperation.queuedAt + settlementTime + stagedOperation.validForSeconds, 'staged operation expired' ); _consumeActiveStagedOperation(operationId); stagedOperations[operationId].initiatorVault = address(0); + uint256 operationNotional = _getOperationNotional(stagedOperation); + if (operationNotional > getPriceRoundRemainingNotional()) { + emit ExecutedStagedOperation(operationId, stagedOperation.operation, false, 'oracle budget exceeded'); + return; + } + if ( + stagedOperation.operation == OperationType.Liquidation && + !_isLiquidationBeyondMinPriceDistance(stagedOperation) + ) { + emit ExecutedStagedOperation( + operationId, + stagedOperation.operation, + false, + 'liquidation too close to threshold' + ); + return; + } if (stagedOperation.operation == OperationType.Liquidation) { try securityPool.performLiquidation( @@ -286,6 +366,7 @@ contract SecurityPoolOracleCoordinator { stagedOperation.snapshotDenominator ) { + _consumePriceRoundNotional(operationNotional); emit ExecutedStagedOperation(operationId, stagedOperation.operation, true, ''); } catch Error(string memory reason) { emit ExecutedStagedOperation(operationId, stagedOperation.operation, false, reason); @@ -296,6 +377,7 @@ contract SecurityPoolOracleCoordinator { } } else if (stagedOperation.operation == OperationType.WithdrawRep) { try securityPool.performWithdrawRep(stagedOperation.initiatorVault, stagedOperation.amount) { + _consumePriceRoundNotional(operationNotional); emit ExecutedStagedOperation(operationId, stagedOperation.operation, true, ''); } catch Error(string memory reason) { emit ExecutedStagedOperation(operationId, stagedOperation.operation, false, reason); @@ -306,6 +388,7 @@ contract SecurityPoolOracleCoordinator { } } else { try securityPool.performSetSecurityBondsAllowance(stagedOperation.initiatorVault, stagedOperation.amount) { + _consumePriceRoundNotional(operationNotional); emit ExecutedStagedOperation(operationId, stagedOperation.operation, true, ''); } catch Error(string memory reason) { emit ExecutedStagedOperation(operationId, stagedOperation.operation, false, reason); @@ -317,6 +400,71 @@ contract SecurityPoolOracleCoordinator { } } + function _consumePriceRoundNotional(uint256 notional) private { + priceRoundConsumedNotional += notional; + } + + function _getOperationNotional(StagedOperation memory stagedOperation) private view returns (uint256) { + if (stagedOperation.operation == OperationType.Liquidation) { + uint256 debtToMove = _getLiquidationDebtToMove(stagedOperation); + uint256 repToMove = _getLiquidationRepToMove(stagedOperation, debtToMove); + uint256 repEthValue = _repToEthNotional(repToMove); + return debtToMove > repEthValue ? debtToMove : repEthValue; + } + if (stagedOperation.operation == OperationType.WithdrawRep) { + uint256 price = lastPrice; + if (price == 0 || stagedOperation.amount == 0) return 0; + uint256 numerator = stagedOperation.amount * PRICE_PRECISION; + return (numerator - 1) / price + 1; + } + if (stagedOperation.amount <= stagedOperation.snapshotTargetAllowance) return 0; + return stagedOperation.amount - stagedOperation.snapshotTargetAllowance; + } + + function _getSnapshotVaultRep(StagedOperation memory stagedOperation) private pure returns (uint256) { + if (stagedOperation.snapshotDenominator == 0) { + return stagedOperation.snapshotTargetOwnership / PRICE_PRECISION; + } + return + (stagedOperation.snapshotTargetOwnership * stagedOperation.snapshotTotalRep) / + stagedOperation.snapshotDenominator; + } + + function _getLiquidationDebtToMove(StagedOperation memory stagedOperation) private pure returns (uint256) { + return + stagedOperation.amount > stagedOperation.snapshotTargetAllowance + ? stagedOperation.snapshotTargetAllowance + : stagedOperation.amount; + } + + function _getLiquidationRepToMove( + StagedOperation memory stagedOperation, + uint256 debtToMove + ) private pure returns (uint256) { + if (stagedOperation.snapshotTargetAllowance == 0 || debtToMove == 0) return 0; + return (debtToMove * _getSnapshotVaultRep(stagedOperation)) / stagedOperation.snapshotTargetAllowance; + } + + function _repToEthNotional(uint256 repAmount) private view returns (uint256) { + uint256 price = lastPrice; + if (price == 0 || repAmount == 0) return 0; + uint256 numerator = repAmount * PRICE_PRECISION; + return (numerator - 1) / price + 1; + } + + function _isLiquidationBeyondMinPriceDistance(StagedOperation memory stagedOperation) private view returns (bool) { + if (minLiquidationPriceDistanceBps == 0) return true; + uint256 snapshotTargetAllowance = stagedOperation.snapshotTargetAllowance; + if (snapshotTargetAllowance == 0) return false; + uint256 currentPrice = lastPrice; + if (currentPrice == 0) return false; + uint256 vaultRep = _getSnapshotVaultRep(stagedOperation); + uint256 securityMultiplier = securityPool.securityMultiplier(); + uint256 thresholdPrice = (vaultRep * PRICE_PRECISION) / (snapshotTargetAllowance * securityMultiplier); + if (currentPrice <= thresholdPrice) return false; + return ((currentPrice - thresholdPrice) * ORACLE_BUDGET_BPS) / currentPrice >= minLiquidationPriceDistanceBps; + } + function getPendingOperationSlot() public view returns (StagedOperation memory) { return stagedOperations[pendingOperationSlotId]; } diff --git a/solidity/contracts/peripherals/factories/PriceOracleManagerAndOperatorQueuerFactory.sol b/solidity/contracts/peripherals/factories/PriceOracleManagerAndOperatorQueuerFactory.sol index b0df7cc5..12efba51 100644 --- a/solidity/contracts/peripherals/factories/PriceOracleManagerAndOperatorQueuerFactory.sol +++ b/solidity/contracts/peripherals/factories/PriceOracleManagerAndOperatorQueuerFactory.sol @@ -21,6 +21,10 @@ contract PriceOracleManagerAndOperatorQueuerFactory { bool public immutable timeType; bool public immutable trackDisputes; address public immutable protocolFeeRecipient; + uint256 public immutable priceRoundBudgetMultiplierBps; + uint256 public immutable escalationHaltMultiplierBps; + uint256 public immutable maxSettlementBaseFeeMultiplierBps; + uint256 public immutable minLiquidationPriceDistanceBps; constructor( IWeth9 _weth, @@ -34,7 +38,11 @@ contract PriceOracleManagerAndOperatorQueuerFactory { uint16 _multiplier, bool _timeType, bool _trackDisputes, - address _protocolFeeRecipient + address _protocolFeeRecipient, + uint256 _priceRoundBudgetMultiplierBps, + uint256 _escalationHaltMultiplierBps, + uint256 _maxSettlementBaseFeeMultiplierBps, + uint256 _minLiquidationPriceDistanceBps ) { weth = _weth; gasConsumedOpenOracleReportPrice = _gasConsumedOpenOracleReportPrice; @@ -48,6 +56,10 @@ contract PriceOracleManagerAndOperatorQueuerFactory { timeType = _timeType; trackDisputes = _trackDisputes; protocolFeeRecipient = _protocolFeeRecipient; + priceRoundBudgetMultiplierBps = _priceRoundBudgetMultiplierBps; + escalationHaltMultiplierBps = _escalationHaltMultiplierBps; + maxSettlementBaseFeeMultiplierBps = _maxSettlementBaseFeeMultiplierBps; + minLiquidationPriceDistanceBps = _minLiquidationPriceDistanceBps; } function deployPriceOracleManagerAndOperatorQueuer( @@ -70,7 +82,11 @@ contract PriceOracleManagerAndOperatorQueuerFactory { multiplier, timeType, trackDisputes, - protocolFeeRecipient + protocolFeeRecipient, + priceRoundBudgetMultiplierBps, + escalationHaltMultiplierBps, + maxSettlementBaseFeeMultiplierBps, + minLiquidationPriceDistanceBps ); } } diff --git a/solidity/contracts/peripherals/factories/UniformPriceDualCapBatchAuctionFactory.sol b/solidity/contracts/peripherals/factories/UniformPriceDualCapBatchAuctionFactory.sol index e7c07dda..faaa968a 100644 --- a/solidity/contracts/peripherals/factories/UniformPriceDualCapBatchAuctionFactory.sol +++ b/solidity/contracts/peripherals/factories/UniformPriceDualCapBatchAuctionFactory.sol @@ -7,6 +7,12 @@ contract UniformPriceDualCapBatchAuctionFactory { address owner, bytes32 salt ) external returns (UniformPriceDualCapBatchAuction) { - return new UniformPriceDualCapBatchAuction{ salt: salt }(owner); + bytes memory initCode = abi.encodePacked(type(UniformPriceDualCapBatchAuction).creationCode, abi.encode(owner)); + address auction; + assembly { + auction := create2(0, add(initCode, 0x20), mload(initCode), salt) + } + require(auction != address(0), 'auction deploy failed'); + return UniformPriceDualCapBatchAuction(auction); } } diff --git a/solidity/ts/tests/peripherals.test.ts b/solidity/ts/tests/peripherals.test.ts index 5bef3322..2ec7b899 100644 --- a/solidity/ts/tests/peripherals.test.ts +++ b/solidity/ts/tests/peripherals.test.ts @@ -1885,7 +1885,7 @@ describe('Peripherals Contract Test Suite', () => { test('Can Liquidate', async () => { const endTime = await getQuestionEndDate(client, questionId) await mockWindow.setTime(endTime + 10000n) - const securityPoolAllowance = repDeposit / 4n + const securityPoolAllowance = 75n * 10n ** 18n strictEqualTypeSafe(await getCurrentRetentionRate(client, securityPoolAddresses.securityPool), MAX_RETENTION_RATE, 'retention rate was not at max') await manipulatePriceOracleAndPerformOperation(client, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.SetSecurityBondsAllowance, client.account.address, securityPoolAllowance) const initialPrice = await getLastPrice(client, securityPoolAddresses.priceOracleManagerAndOperatorQueuer) @@ -1895,14 +1895,15 @@ describe('Peripherals Contract Test Suite', () => { const liquidatorClient = createWriteClient(mockWindow, TEST_ADDRESSES[1], 0) await approveToken(liquidatorClient, addressString(GENESIS_REPUTATION_TOKEN), securityPoolAddresses.securityPool) await depositRep(liquidatorClient, securityPoolAddresses.securityPool, repDeposit * 10n) - const openInterestAmount = 100n * 10n ** 18n + const openInterestAmount = 50n * 10n ** 18n await createCompleteSet(client, securityPoolAddresses.securityPool, openInterestAmount) await mockWindow.advanceTime(100000n) strictEqualTypeSafe(canLiquidate(initialPrice, securityPoolAllowance, repDeposit, 2n), false, 'Should not be able to liquidate yet') // REP/ETH increases to 10x, 10 REP = 1 ETH (rep drops in value) const forcedPrice = PRICE_PRECISION * 10n - await requestPriceIfNeededAndStageOperation(liquidatorClient, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.Liquidation, client.account.address, securityPoolAllowance) + const liquidationAmount = 25n * 10n ** 18n + await requestPriceIfNeededAndStageOperation(liquidatorClient, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.Liquidation, client.account.address, liquidationAmount) await handleOracleReporting(liquidatorClient, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, forcedPrice) @@ -1911,19 +1912,19 @@ describe('Peripherals Contract Test Suite', () => { strictEqualTypeSafe(canLiquidate(currentPrice, securityPoolAllowance, repDeposit, 2n), true, 'Should be able to liquidate now') - // liquidator should have all the assets now const originalVault = await getSecurityVault(client, securityPoolAddresses.securityPool, client.account.address) const liquidatorVault = await getSecurityVault(client, securityPoolAddresses.securityPool, liquidatorClient.account.address) - strictEqualTypeSafe(originalVault.securityBondAllowance, 0n, 'original vault should not have any security bonds') - strictEqualTypeSafe(originalVault.repDepositShare, 0n, 'original vault should not have any rep') - strictEqualTypeSafe(liquidatorVault.securityBondAllowance, securityPoolAllowance, "liquidator doesn't have all the security pool allowances") - strictEqualTypeSafe(liquidatorVault.repDepositShare / PRICE_PRECISION, repDeposit + repDeposit * 10n, 'liquidator should have all the rep in the pool') + const expectedRepMoved = (liquidationAmount * repDeposit) / securityPoolAllowance + strictEqualTypeSafe(originalVault.securityBondAllowance, securityPoolAllowance - liquidationAmount, 'original vault should keep only the non-liquidated security bonds') + strictEqualTypeSafe(originalVault.repDepositShare / PRICE_PRECISION, repDeposit - expectedRepMoved, 'original vault should keep the non-liquidated REP') + strictEqualTypeSafe(liquidatorVault.securityBondAllowance, liquidationAmount, "liquidator doesn't have the liquidated security pool allowance") + strictEqualTypeSafe(liquidatorVault.repDepositShare / PRICE_PRECISION, repDeposit * 10n + expectedRepMoved, 'liquidator should receive the liquidated REP') }) test('liquidation should use snapshot to prevent blocking via additional rep deposit', async () => { const endTime = await getQuestionEndDate(client, questionId) await mockWindow.setTime(endTime + 10000n) - const securityPoolAllowance = repDeposit / 4n + const securityPoolAllowance = 75n * 10n ** 18n // Set the target's security bond allowance await manipulatePriceOracleAndPerformOperation(client, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.SetSecurityBondsAllowance, client.account.address, securityPoolAllowance) assert.ok((await getLastPrice(client, securityPoolAddresses.priceOracleManagerAndOperatorQueuer)) > 0n, 'Price was not set!') @@ -1935,7 +1936,7 @@ describe('Peripherals Contract Test Suite', () => { await depositRep(liquidatorClient, securityPoolAddresses.securityPool, repDeposit * 10n) // Create open interest - const openInterestAmount = 100n * 10n ** 18n + const openInterestAmount = 50n * 10n ** 18n await createCompleteSet(client, securityPoolAddresses.securityPool, openInterestAmount) await mockWindow.advanceTime(100000n) @@ -1950,7 +1951,8 @@ describe('Peripherals Contract Test Suite', () => { // Queue liquidation (liquidator requests price to trigger liquidation) const forcedPrice = PRICE_PRECISION * 10n - await requestPriceIfNeededAndStageOperation(liquidatorClient, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.Liquidation, client.account.address, securityPoolAllowance) + const liquidationAmount = 25n * 10n ** 18n + await requestPriceIfNeededAndStageOperation(liquidatorClient, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.Liquidation, client.account.address, liquidationAmount) // Record liquidator's ownership before attack const liquidatorVaultBefore = await getSecurityVault(client, securityPoolAddresses.securityPool, liquidatorClient.account.address) @@ -1973,11 +1975,10 @@ describe('Peripherals Contract Test Suite', () => { const liquidatorVaultAfter = await getSecurityVault(client, securityPoolAddresses.securityPool, liquidatorClient.account.address) const targetVaultAfter = await getSecurityVault(client, securityPoolAddresses.securityPool, client.account.address) - // Target's allowance should be zero after liquidation (since we moved the full allowance) - strictEqualTypeSafe(targetVaultAfter.securityBondAllowance, 0n, 'target security bond allowance should be zero after liquidation') + strictEqualTypeSafe(targetVaultAfter.securityBondAllowance, securityPoolAllowance - liquidationAmount, 'target security bond allowance should decrease by the liquidated amount') // Compute expected changes based on snapshot - const debtToMove = securityPoolAllowance + const debtToMove = liquidationAmount const effectiveDebtToMove = debtToMove < snapshotTargetAllowance ? debtToMove : snapshotTargetAllowance const repToMove = (effectiveDebtToMove * snapshotExpectedRepDeposit) / snapshotTargetAllowance const ownershipToMove = (repToMove * denominatorAfter) / totalRepAfter @@ -1996,7 +1997,7 @@ describe('Peripherals Contract Test Suite', () => { }) test('liquidation only moves REP that is not committed to escalation', async () => { - const securityPoolAllowance = 400n * 10n ** 18n + const securityPoolAllowance = 200n * 10n ** 18n await manipulatePriceOracleAndPerformOperation(client, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.SetSecurityBondsAllowance, client.account.address, securityPoolAllowance) const liquidatorClient = createWriteClient(mockWindow, TEST_ADDRESSES[1], 0) @@ -2008,7 +2009,7 @@ describe('Peripherals Contract Test Suite', () => { const endTime = await getQuestionEndDate(client, questionId) await mockWindow.setTime(endTime + 10000n) - const lockedDeposit = 300n * 10n ** 18n + const lockedDeposit = 700n * 10n ** 18n await depositToEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, lockedDeposit) const targetVaultAfterLock = await getSecurityVault(client, securityPoolAddresses.securityPool, client.account.address) diff --git a/solidity/ts/tests/priceOracleSecurity.test.ts b/solidity/ts/tests/priceOracleSecurity.test.ts index b5d73c3e..5a817e2a 100644 --- a/solidity/ts/tests/priceOracleSecurity.test.ts +++ b/solidity/ts/tests/priceOracleSecurity.test.ts @@ -13,13 +13,13 @@ import { deployOriginSecurityPool, ensureInfraDeployed, getInfraContractAddresse import { createQuestion, getQuestionId } from '../testsuite/simulator/utils/contracts/zoltarQuestionData' import { ensureZoltarDeployed } from '../testsuite/simulator/utils/contracts/zoltar' import { OperationType, getOpenOracleExtraData, getOpenOracleReportMeta, getRequestPriceEthCost, openOracleSettle, openOracleSubmitInitialReport, wrapWeth } from '../testsuite/simulator/utils/contracts/peripherals' -import { getSecurityVault } from '../testsuite/simulator/utils/contracts/securityPool' +import { depositRep, getSecurityVault } from '../testsuite/simulator/utils/contracts/securityPool' import { peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator } from '../types/contractArtifact' setDefaultTimeout(TEST_TIMEOUT_MS) describe('Price Oracle Refund Security Tests', () => { - const DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS = 30n * 60n + const DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS = 5n * 60n const { getAnvilWindowEthereum } = useIsolatedAnvilNode() let mockWindow: AnvilWindowEthereum let client: WriteClient @@ -204,6 +204,7 @@ describe('Price Oracle Refund Security Tests', () => { const amount2 = amount1 * 10n ** 18n + 1n await approveToken(client, addressString(GENESIS_REPUTATION_TOKEN), openOracle) await approveToken(client, WETH_ADDRESS, openOracle) + await mockWindow.setBalance(client.account.address, amount2 * 2n) const wethBalanceBefore = await getERC20Balance(client, WETH_ADDRESS, client.account.address) await wrapWeth(client, amount2) const wethBalanceAfter = await getERC20Balance(client, WETH_ADDRESS, client.account.address) @@ -238,6 +239,103 @@ describe('Price Oracle Refund Security Tests', () => { assert.strictEqual(pendingOperationSlotId, 1n, 'invalid settled reports should leave the staged operation pending for a later valid price') assert.strictEqual(isPriceValid, false, 'invalid settled reports must not make the price valid') assert.strictEqual(vault.securityBondAllowance, 0n, 'invalid oracle prices must not execute staged allowance updates') + const secondAllowance = repDeposit / 5n + + const balanceBefore = await getETHBalance(client, client.account.address) + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'requestPriceIfNeededAndStageOperation', + args: [OperationType.SetSecurityBondsAllowance, client.account.address, secondAllowance, DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS], + }), + ) + const balanceAfter = await getETHBalance(client, client.account.address) + const pendingReportIdAfterSecondOperation = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'pendingReportId', + args: [], + }) + + assert.strictEqual(balanceBefore - balanceAfter, 0n, 'unrelated staged operations should not be charged to retry an older pending operation') + assert.strictEqual(pendingReportIdAfterSecondOperation, 0n, 'unrelated staged operations should not request a report for an older pending slot') + }) + + test('expired pending auto-execute slots do not block later valid oracle settlements', async () => { + const ethCost = await getRequestPriceEthCost(client, priceOracle) + const unsafeAllowance = repDeposit * 10n + + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'requestPriceIfNeededAndStageOperation', + args: [OperationType.SetSecurityBondsAllowance, client.account.address, unsafeAllowance, DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS], + value: ethCost, + }), + ) + + const pendingReportId = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'pendingReportId', + args: [], + }) + const openOracle = getInfraContractAddresses().openOracle + const reportMeta = await getOpenOracleReportMeta(client, pendingReportId) + const amount1 = reportMeta.exactToken1Report + const amount2 = amount1 * 10n ** 18n + 1n + await approveToken(client, addressString(GENESIS_REPUTATION_TOKEN), openOracle) + await approveToken(client, WETH_ADDRESS, openOracle) + await mockWindow.setBalance(client.account.address, amount2 * 2n) + await wrapWeth(client, amount2) + const stateHash = (await getOpenOracleExtraData(client, pendingReportId)).stateHash + await openOracleSubmitInitialReport(client, pendingReportId, amount1, amount2, stateHash) + await mockWindow.advanceTime(BigInt(reportMeta.settlementTime) + 1n) + await openOracleSettle(client, pendingReportId) + await mockWindow.advanceTime(DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS + 1n) + + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'requestPrice', + value: ethCost, + }), + ) + await handleOracleReporting(client, mockWindow, priceOracle, 10n ** 18n) + + const isPriceValid = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'isPriceValid', + args: [], + }) + const pendingOperationSlotId = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'pendingOperationSlotId', + args: [], + }) + const stagedOperation = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'stagedOperations', + args: [1n], + }) + const vault = await getSecurityVault(client, securityPool, client.account.address) + + assert.strictEqual(isPriceValid, true, 'valid reports should settle even when the pending auto-execute slot expired') + assert.strictEqual(pendingOperationSlotId, 0n, 'expired pending auto-execute slots should be cleared during callback') + assert.strictEqual(stagedOperation[1], zeroAddress, 'expired pending auto-execute operations should be consumed') + assert.strictEqual(vault.securityBondAllowance, 0n, 'expired pending operations must not execute during later valid settlement') }) test('active staged operations stay newest-first after pending-slot settlement and manual execution', async () => { @@ -374,6 +472,197 @@ describe('Price Oracle Refund Security Tests', () => { assert.strictEqual(remainingOperations[1]?.amount, secondAllowance, 'older remaining operation should stay second in the preview') }) + test('one oracle price round cannot authorize operations beyond its shared protocol budget', async () => { + const ethCost = await getRequestPriceEthCost(client, priceOracle) + const budgetConsumingAllowance = 900n * 10n ** 18n + const budgetExceedingAllowance = 1050n * 10n ** 18n + + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'requestPriceIfNeededAndStageOperation', + args: [OperationType.SetSecurityBondsAllowance, client.account.address, budgetConsumingAllowance, DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS], + value: ethCost, + }), + ) + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'requestPriceIfNeededAndStageOperation', + args: [OperationType.SetSecurityBondsAllowance, client.account.address, budgetExceedingAllowance, DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS], + }), + ) + + await handleOracleReporting(client, mockWindow, priceOracle, 10n ** 18n) + + const consumedAfterAutoExecution = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'priceRoundConsumedNotional', + args: [], + }) + const remainingAfterAutoExecution = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'getPriceRoundRemainingNotional', + args: [], + }) + assert.strictEqual(consumedAfterAutoExecution, budgetConsumingAllowance, 'auto-executed operation should consume price-round budget') + assert.strictEqual(remainingAfterAutoExecution, 100n * 10n ** 18n, 'remaining budget should be shared by all operations using this price') + + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'executeStagedOperation', + args: [2n], + }), + ) + + const vault = await getSecurityVault(client, securityPool, client.account.address) + const consumedAfterBudgetFailure = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'priceRoundConsumedNotional', + args: [], + }) + const secondStagedOperation = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'stagedOperations', + args: [2n], + }) + + assert.strictEqual(vault.securityBondAllowance, budgetConsumingAllowance, 'budget-exceeded operation must not change vault exposure') + assert.strictEqual(consumedAfterBudgetFailure, budgetConsumingAllowance, 'failed budget checks must not consume additional budget') + assert.strictEqual(secondStagedOperation[1], zeroAddress, 'budget-exceeded operations should be consumed as failed staged operations') + + const incrementalAllowance = 950n * 10n ** 18n + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'requestPriceIfNeededAndStageOperation', + args: [OperationType.SetSecurityBondsAllowance, client.account.address, incrementalAllowance, DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS], + }), + ) + const consumedAfterIncrement = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'priceRoundConsumedNotional', + args: [], + }) + assert.strictEqual(consumedAfterIncrement, 950n * 10n ** 18n, 'allowance increases should only consume incremental exposure') + + const budgetExhaustingWithdrawal = 50n * 10n ** 18n + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'requestPriceIfNeededAndStageOperation', + args: [OperationType.WithdrawRep, client.account.address, budgetExhaustingWithdrawal, DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS], + }), + ) + const remainingAfterBudgetExhaustingWithdrawal = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'getPriceRoundRemainingNotional', + args: [], + }) + assert.strictEqual(remainingAfterBudgetExhaustingWithdrawal, 0n, 'test setup should exhaust the price-round budget') + + const reducedAllowance = 925n * 10n ** 18n + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'requestPriceIfNeededAndStageOperation', + args: [OperationType.SetSecurityBondsAllowance, client.account.address, reducedAllowance, DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS], + }), + ) + const consumedAfterReduction = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'priceRoundConsumedNotional', + args: [], + }) + const vaultAfterReduction = await getSecurityVault(client, securityPool, client.account.address) + assert.strictEqual(consumedAfterReduction, 1000n * 10n ** 18n, 'allowance reductions should not consume price-round budget') + assert.strictEqual(vaultAfterReduction.securityBondAllowance, reducedAllowance, 'allowance reductions should still execute while the price is fresh, even after the budget is exhausted') + }) + + test('liquidations too close to the threshold are rejected even when oracle budget remains', async () => { + const ethCost = await getRequestPriceEthCost(client, priceOracle) + const targetAllowance = 75n * 10n ** 18n + const liquidationAmount = 10n * 10n ** 18n + const nearThresholdPrice = 7n * 10n ** 18n + const liquidatorClient = createWriteClient(mockWindow, TEST_ADDRESSES[1], 0) + + await approveToken(liquidatorClient, addressString(GENESIS_REPUTATION_TOKEN), securityPool) + await depositRep(liquidatorClient, securityPool, repDeposit) + + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'requestPriceIfNeededAndStageOperation', + args: [OperationType.SetSecurityBondsAllowance, client.account.address, targetAllowance, DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS], + value: ethCost, + }), + ) + await handleOracleReporting(client, mockWindow, priceOracle, 10n ** 18n) + await mockWindow.advanceTime(DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS + 1n) + + await writeContractAndWait( + liquidatorClient, + async () => + await liquidatorClient.writeContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'requestPriceIfNeededAndStageOperation', + args: [OperationType.Liquidation, client.account.address, liquidationAmount, DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS], + value: ethCost, + }), + ) + await handleOracleReporting(client, mockWindow, priceOracle, nearThresholdPrice) + + const targetVault = await getSecurityVault(client, securityPool, client.account.address) + const liquidatorVault = await getSecurityVault(client, securityPool, liquidatorClient.account.address) + const consumedNotional = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'priceRoundConsumedNotional', + args: [], + }) + const stagedOperation = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'stagedOperations', + args: [2n], + }) + + assert.strictEqual(targetVault.securityBondAllowance, targetAllowance, 'near-threshold liquidations must not reduce the target vault allowance') + assert.strictEqual(liquidatorVault.securityBondAllowance, 0n, 'near-threshold liquidations must not move debt to the liquidator vault') + assert.strictEqual(consumedNotional, 0n, 'near-threshold liquidation failures must not consume price-round budget') + assert.strictEqual(stagedOperation[1], zeroAddress, 'near-threshold liquidation attempts should be consumed as failed staged operations') + }) + test('staged operations can only be executed once', async () => { const ethCost = await getRequestPriceEthCost(client, priceOracle) const successfulAllowance = repDeposit / 4n diff --git a/solidity/ts/testsuite/simulator/utils/contracts/deployPeripherals.ts b/solidity/ts/testsuite/simulator/utils/contracts/deployPeripherals.ts index 4748de6c..4e834bd3 100644 --- a/solidity/ts/testsuite/simulator/utils/contracts/deployPeripherals.ts +++ b/solidity/ts/testsuite/simulator/utils/contracts/deployPeripherals.ts @@ -33,17 +33,22 @@ import { getRepTokenAddress } from './zoltar' const ZERO_SALT: Hex = toHex(0, { size: 32 }) const MULTICALL3_BYTECODE = `0x${peripherals_Multicall3_Multicall3.evm.bytecode.object}` satisfies Hex const MAINNET_WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' satisfies Address +const ORACLE_FEE_SINK_ADDRESS = '0x000000000000000000000000000000000000dEaD' satisfies Address const ORACLE_REPORT_GAS = 100000n const ORACLE_SETTLEMENT_GAS = 1000000 -const ORACLE_EXACT_TOKEN1_REPORT = 26392439800n -const ORACLE_SETTLEMENT_TIME = 15 * 12 +const ORACLE_EXACT_TOKEN1_REPORT = 250n * 10n ** 18n +const ORACLE_SETTLEMENT_TIME = 40 * 12 const ORACLE_DISPUTE_DELAY = 0 -const ORACLE_PROTOCOL_FEE = 0 +const ORACLE_PROTOCOL_FEE = 100000 const ORACLE_FEE_PERCENTAGE = 10000 -const ORACLE_MULTIPLIER = 140 +const ORACLE_MULTIPLIER = 115 const ORACLE_TIME_TYPE = true -const ORACLE_TRACK_DISPUTES = false -const ORACLE_PROTOCOL_FEE_RECIPIENT = addressString(0x0n) +const ORACLE_TRACK_DISPUTES = true +const ORACLE_PROTOCOL_FEE_RECIPIENT = ORACLE_FEE_SINK_ADDRESS +const ORACLE_PRICE_ROUND_BUDGET_MULTIPLIER_BPS = 40000n +const ORACLE_ESCALATION_HALT_MULTIPLIER_BPS = 100000n +const ORACLE_MAX_SETTLEMENT_BASE_FEE_MULTIPLIER_BPS = 30000n +const ORACLE_MIN_LIQUIDATION_PRICE_DISTANCE_BPS = 1000n const getSecurityPoolUtilsAddress = () => getCreate2Address({ bytecode: `0x${peripherals_SecurityPoolUtils_SecurityPoolUtils.evm.bytecode.object}`, from: addressString(PROXY_DEPLOYER_ADDRESS), salt: ZERO_SALT }) @@ -65,8 +70,42 @@ function getPriceOracleManagerAndOperatorQueuerFactoryByteCode(): Hex { return concatHex([ `0x${peripherals_factories_PriceOracleManagerAndOperatorQueuerFactory_PriceOracleManagerAndOperatorQueuerFactory.evm.bytecode.object}`, encodeAbiParameters( - [{ type: 'address' }, { type: 'uint256' }, { type: 'uint32' }, { type: 'uint256' }, { type: 'uint48' }, { type: 'uint24' }, { type: 'uint24' }, { type: 'uint24' }, { type: 'uint16' }, { type: 'bool' }, { type: 'bool' }, { type: 'address' }], - [MAINNET_WETH_ADDRESS, ORACLE_REPORT_GAS, ORACLE_SETTLEMENT_GAS, ORACLE_EXACT_TOKEN1_REPORT, ORACLE_SETTLEMENT_TIME, ORACLE_DISPUTE_DELAY, ORACLE_PROTOCOL_FEE, ORACLE_FEE_PERCENTAGE, ORACLE_MULTIPLIER, ORACLE_TIME_TYPE, ORACLE_TRACK_DISPUTES, ORACLE_PROTOCOL_FEE_RECIPIENT], + [ + { type: 'address' }, + { type: 'uint256' }, + { type: 'uint32' }, + { type: 'uint256' }, + { type: 'uint48' }, + { type: 'uint24' }, + { type: 'uint24' }, + { type: 'uint24' }, + { type: 'uint16' }, + { type: 'bool' }, + { type: 'bool' }, + { type: 'address' }, + { type: 'uint256' }, + { type: 'uint256' }, + { type: 'uint256' }, + { type: 'uint256' }, + ], + [ + MAINNET_WETH_ADDRESS, + ORACLE_REPORT_GAS, + ORACLE_SETTLEMENT_GAS, + ORACLE_EXACT_TOKEN1_REPORT, + ORACLE_SETTLEMENT_TIME, + ORACLE_DISPUTE_DELAY, + ORACLE_PROTOCOL_FEE, + ORACLE_FEE_PERCENTAGE, + ORACLE_MULTIPLIER, + ORACLE_TIME_TYPE, + ORACLE_TRACK_DISPUTES, + ORACLE_PROTOCOL_FEE_RECIPIENT, + ORACLE_PRICE_ROUND_BUDGET_MULTIPLIER_BPS, + ORACLE_ESCALATION_HALT_MULTIPLIER_BPS, + ORACLE_MAX_SETTLEMENT_BASE_FEE_MULTIPLIER_BPS, + ORACLE_MIN_LIQUIDATION_PRICE_DISTANCE_BPS, + ], ), ]) } @@ -180,8 +219,46 @@ export const { getSecurityPoolAddresses } = createSecurityPoolAddressHelper({ concatHex([ `0x${peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.evm.bytecode.object}`, encodeAbiParameters( - [{ type: 'address' }, { type: 'address' }, { type: 'address' }, { type: 'uint256' }, { type: 'uint32' }, { type: 'uint256' }, { type: 'uint48' }, { type: 'uint24' }, { type: 'uint24' }, { type: 'uint24' }, { type: 'uint16' }, { type: 'bool' }, { type: 'bool' }, { type: 'address' }], - [openOracle, repToken, MAINNET_WETH_ADDRESS, ORACLE_REPORT_GAS, ORACLE_SETTLEMENT_GAS, ORACLE_EXACT_TOKEN1_REPORT, ORACLE_SETTLEMENT_TIME, ORACLE_DISPUTE_DELAY, ORACLE_PROTOCOL_FEE, ORACLE_FEE_PERCENTAGE, ORACLE_MULTIPLIER, ORACLE_TIME_TYPE, ORACLE_TRACK_DISPUTES, ORACLE_PROTOCOL_FEE_RECIPIENT], + [ + { type: 'address' }, + { type: 'address' }, + { type: 'address' }, + { type: 'uint256' }, + { type: 'uint32' }, + { type: 'uint256' }, + { type: 'uint48' }, + { type: 'uint24' }, + { type: 'uint24' }, + { type: 'uint24' }, + { type: 'uint16' }, + { type: 'bool' }, + { type: 'bool' }, + { type: 'address' }, + { type: 'uint256' }, + { type: 'uint256' }, + { type: 'uint256' }, + { type: 'uint256' }, + ], + [ + openOracle, + repToken, + MAINNET_WETH_ADDRESS, + ORACLE_REPORT_GAS, + ORACLE_SETTLEMENT_GAS, + ORACLE_EXACT_TOKEN1_REPORT, + ORACLE_SETTLEMENT_TIME, + ORACLE_DISPUTE_DELAY, + ORACLE_PROTOCOL_FEE, + ORACLE_FEE_PERCENTAGE, + ORACLE_MULTIPLIER, + ORACLE_TIME_TYPE, + ORACLE_TRACK_DISPUTES, + ORACLE_PROTOCOL_FEE_RECIPIENT, + ORACLE_PRICE_ROUND_BUDGET_MULTIPLIER_BPS, + ORACLE_ESCALATION_HALT_MULTIPLIER_BPS, + ORACLE_MAX_SETTLEMENT_BASE_FEE_MULTIPLIER_BPS, + ORACLE_MIN_LIQUIDATION_PRICE_DISTANCE_BPS, + ], ), ]), getRepTokenAddress, diff --git a/solidity/ts/testsuite/simulator/utils/contracts/peripherals.ts b/solidity/ts/testsuite/simulator/utils/contracts/peripherals.ts index 55eb406b..528082dd 100644 --- a/solidity/ts/testsuite/simulator/utils/contracts/peripherals.ts +++ b/solidity/ts/testsuite/simulator/utils/contracts/peripherals.ts @@ -20,7 +20,7 @@ export enum OperationType { SetSecurityBondsAllowance = 2, } -const DEFAULT_SELF_OPERATION_VALID_FOR_SECONDS = 24n * 60n * 60n +const DEFAULT_SELF_OPERATION_VALID_FOR_SECONDS = 5n * 60n export const requestPriceIfNeededAndStageOperation = async (client: WriteClient, priceOracleManagerAndOperatorQueuer: Address, operation: OperationType, targetVault: Address, amount: bigint, validForSeconds = DEFAULT_SELF_OPERATION_VALID_FOR_SECONDS) => { const ethCost = await getRequestPriceEthCost(client, priceOracleManagerAndOperatorQueuer) diff --git a/solidity/ts/testsuite/simulator/utils/contracts/peripheralsTestUtils.ts b/solidity/ts/testsuite/simulator/utils/contracts/peripheralsTestUtils.ts index 9a912cbe..23797d87 100644 --- a/solidity/ts/testsuite/simulator/utils/contracts/peripheralsTestUtils.ts +++ b/solidity/ts/testsuite/simulator/utils/contracts/peripheralsTestUtils.ts @@ -4,7 +4,7 @@ import { AnvilWindowEthereum } from '../../AnvilWindowEthereum' import { addressString } from '../bigint' import { GENESIS_REPUTATION_TOKEN, WETH_ADDRESS } from '../constants' import { getInfraContractAddresses, getSecurityPoolAddresses } from './deployPeripherals' -import { approveToken, contractExists, getERC20Balance } from '../utilities' +import { approveToken, contractExists, getERC20Balance, getETHBalance } from '../utilities' import { WriteClient } from '../viem' import assert from 'node:assert/strict' import { getOpenOracleExtraData, getOpenOracleReportMeta, getPendingReportId, openOracleSettle, openOracleSubmitInitialReport, OperationType, requestPrice, requestPriceIfNeededAndStageOperation, wrapWeth } from './peripherals' @@ -59,6 +59,8 @@ export const handleOracleReporting = async (client: WriteClient, mockWindow: Anv const openOracle = getInfraContractAddresses().openOracle await approveToken(client, addressString(GENESIS_REPUTATION_TOKEN), openOracle) await approveToken(client, WETH_ADDRESS, openOracle) + const ethBalance = await getETHBalance(client, client.account.address) + if (ethBalance <= amount2) await mockWindow.setBalance(client.account.address, amount2 + 10n ** 18n) const wethBalanceBefore = await getERC20Balance(client, WETH_ADDRESS, client.account.address) await wrapWeth(client, amount2) const wethBalance = await getERC20Balance(client, WETH_ADDRESS, client.account.address) diff --git a/solidity/ts/testsuite/simulator/utils/utilities.ts b/solidity/ts/testsuite/simulator/utils/utilities.ts index 6c90f1b3..b7e91f63 100644 --- a/solidity/ts/testsuite/simulator/utils/utilities.ts +++ b/solidity/ts/testsuite/simulator/utils/utilities.ts @@ -11,7 +11,7 @@ import { ReputationToken_ReputationToken, peripherals_WETH9_WETH9 } from '../../ export { sortStringArrayByKeccak } from './sortStringArrayByKeccak' const TOKEN_AMOUNT_TO_MINT = 100000000n * 10n ** 18n const ETH_AMOUNT_TO_MINT = 10n ** 30n -const DEFAULT_APPROVAL_AMOUNT = 1000000000000000000000000000000n +const DEFAULT_APPROVAL_AMOUNT = (1n << 256n) - 1n const PROXY_DEPLOYER_BYTECODE = '0x60003681823780368234f58015156014578182fd5b80825250506014600cf3' function hexToBytes(value: string) { diff --git a/ui/ts/components/LiquidationModal.tsx b/ui/ts/components/LiquidationModal.tsx index 8fb8a15d..758ae836 100644 --- a/ui/ts/components/LiquidationModal.tsx +++ b/ui/ts/components/LiquidationModal.tsx @@ -64,7 +64,7 @@ type QueuedLiquidationOperationView = { } function getLiquidationExecutionMode(currentPoolOracleManagerDetails: OracleManagerDetails | undefined) { if (currentPoolOracleManagerDetails === undefined) return 'refreshing' - return currentPoolOracleManagerDetails.isPriceValid ? 'execute' : 'queue' + return currentPoolOracleManagerDetails.isPriceUsable === true ? 'execute' : 'queue' } function getLiquidationModalTitle(currentPoolOracleManagerDetails: OracleManagerDetails | undefined) { const executionMode = getLiquidationExecutionMode(currentPoolOracleManagerDetails) @@ -302,7 +302,7 @@ export function LiquidationModal({ if (loadingPoolOracleManager || currentPoolOracleManagerDetails === undefined) return 'refreshing' return (() => { - if (currentPoolOracleManagerDetails.isPriceValid) return 'executed' + if (currentPoolOracleManagerDetails.isPriceUsable === true) return 'executed' return 'missing' })() diff --git a/ui/ts/contracts.ts b/ui/ts/contracts.ts index c2d5382b..e4346844 100644 --- a/ui/ts/contracts.ts +++ b/ui/ts/contracts.ts @@ -980,7 +980,7 @@ export async function redeemRepFromSecurityPool(client: WriteClient, securityPoo } satisfies SecurityVaultActionResult } export async function loadOracleManagerDetails(client: ReadClient, managerAddress: Address, openOracleAddress?: Address): Promise { - const [lastPrice, pendingOperationSlotId, pendingReportId, requestPriceEthCost, rawIsPriceValid, lastSettlementTimestamp, activeStagedOperationCount] = await readRequiredMulticall(client, [ + const [lastPrice, pendingOperationSlotId, pendingReportId, requestPriceEthCost, rawIsPriceValid, rawIsPriceUsable, lastSettlementTimestamp, activeStagedOperationCount, priceRoundId, priceRoundMaxNotional, priceRoundConsumedNotional, priceRoundRemainingNotional] = await readRequiredMulticall(client, [ { abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, functionName: 'lastPrice', @@ -1011,6 +1011,12 @@ export async function loadOracleManagerDetails(client: ReadClient, managerAddres address: managerAddress, args: [], }, + { + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + functionName: 'isPriceUsable', + address: managerAddress, + args: [], + }, { abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, functionName: 'lastSettlementTimestamp', @@ -1023,6 +1029,30 @@ export async function loadOracleManagerDetails(client: ReadClient, managerAddres address: managerAddress, args: [], }, + { + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + functionName: 'priceRoundId', + address: managerAddress, + args: [], + }, + { + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + functionName: 'priceRoundMaxNotional', + address: managerAddress, + args: [], + }, + { + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + functionName: 'priceRoundConsumedNotional', + address: managerAddress, + args: [], + }, + { + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + functionName: 'getPriceRoundRemainingNotional', + address: managerAddress, + args: [], + }, ]) const resolvedOracleAddress = openOracleAddress ?? getInfraContractAddresses().openOracle let callbackStateHash: Hex | undefined @@ -1098,6 +1128,7 @@ export async function loadOracleManagerDetails(client: ReadClient, managerAddres activeStagedOperationCount, callbackStateHash, exactToken1Report, + isPriceUsable: lastSettlementTimestamp > 0n && rawIsPriceUsable, isPriceValid: lastSettlementTimestamp > 0n && rawIsPriceValid, lastPrice, lastSettlementTimestamp, @@ -1106,6 +1137,10 @@ export async function loadOracleManagerDetails(client: ReadClient, managerAddres pendingOperation, pendingOperationSlotId, pendingReportId, + priceRoundConsumedNotional, + priceRoundId, + priceRoundMaxNotional, + priceRoundRemainingNotional, priceValidUntilTimestamp: getOracleManagerPriceValidUntilTimestamp(lastSettlementTimestamp), requestPriceEthCost, stagedOperations, diff --git a/ui/ts/contracts/deploymentHelpers.ts b/ui/ts/contracts/deploymentHelpers.ts index 2450170d..32f13307 100644 --- a/ui/ts/contracts/deploymentHelpers.ts +++ b/ui/ts/contracts/deploymentHelpers.ts @@ -21,17 +21,22 @@ export const PROXY_DEPLOYER_ADDRESS = bigintToAddress(0x7a0d94f55792c434d74a4088 export const ZERO_SALT = toHex(0, { size: 32 }) export const MULTICALL3_BYTECODE = `0x${peripherals_Multicall3_Multicall3.evm.bytecode.object}` satisfies Hex const MAINNET_WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' satisfies Address +const ORACLE_FEE_SINK_ADDRESS = '0x000000000000000000000000000000000000dEaD' satisfies Address const ORACLE_REPORT_GAS = 100000n const ORACLE_SETTLEMENT_GAS = 1000000 -const ORACLE_EXACT_TOKEN1_REPORT = 26392439800n -const ORACLE_SETTLEMENT_TIME = 15 * 12 +const ORACLE_EXACT_TOKEN1_REPORT = 250n * 10n ** 18n +const ORACLE_SETTLEMENT_TIME = 40 * 12 const ORACLE_DISPUTE_DELAY = 0 -const ORACLE_PROTOCOL_FEE = 0 +const ORACLE_PROTOCOL_FEE = 100000 const ORACLE_FEE_PERCENTAGE = 10000 -const ORACLE_MULTIPLIER = 140 +const ORACLE_MULTIPLIER = 115 const ORACLE_TIME_TYPE = true -const ORACLE_TRACK_DISPUTES = false -const ORACLE_PROTOCOL_FEE_RECIPIENT = bigintToAddress(0x0n) +const ORACLE_TRACK_DISPUTES = true +const ORACLE_PROTOCOL_FEE_RECIPIENT = ORACLE_FEE_SINK_ADDRESS +const ORACLE_PRICE_ROUND_BUDGET_MULTIPLIER_BPS = 40000n +const ORACLE_ESCALATION_HALT_MULTIPLIER_BPS = 100000n +const ORACLE_MAX_SETTLEMENT_BASE_FEE_MULTIPLIER_BPS = 30000n +const ORACLE_MIN_LIQUIDATION_PRICE_DISTANCE_BPS = 1000n const getSecurityPoolUtilsAddress = () => getCreate2Address({ @@ -75,8 +80,42 @@ export const getPriceOracleManagerAndOperatorQueuerFactoryByteCode = () => concatHex([ `0x${peripherals_factories_PriceOracleManagerAndOperatorQueuerFactory_PriceOracleManagerAndOperatorQueuerFactory.evm.bytecode.object}`, encodeAbiParameters( - [{ type: 'address' }, { type: 'uint256' }, { type: 'uint32' }, { type: 'uint256' }, { type: 'uint48' }, { type: 'uint24' }, { type: 'uint24' }, { type: 'uint24' }, { type: 'uint16' }, { type: 'bool' }, { type: 'bool' }, { type: 'address' }], - [MAINNET_WETH_ADDRESS, ORACLE_REPORT_GAS, ORACLE_SETTLEMENT_GAS, ORACLE_EXACT_TOKEN1_REPORT, ORACLE_SETTLEMENT_TIME, ORACLE_DISPUTE_DELAY, ORACLE_PROTOCOL_FEE, ORACLE_FEE_PERCENTAGE, ORACLE_MULTIPLIER, ORACLE_TIME_TYPE, ORACLE_TRACK_DISPUTES, ORACLE_PROTOCOL_FEE_RECIPIENT], + [ + { type: 'address' }, + { type: 'uint256' }, + { type: 'uint32' }, + { type: 'uint256' }, + { type: 'uint48' }, + { type: 'uint24' }, + { type: 'uint24' }, + { type: 'uint24' }, + { type: 'uint16' }, + { type: 'bool' }, + { type: 'bool' }, + { type: 'address' }, + { type: 'uint256' }, + { type: 'uint256' }, + { type: 'uint256' }, + { type: 'uint256' }, + ], + [ + MAINNET_WETH_ADDRESS, + ORACLE_REPORT_GAS, + ORACLE_SETTLEMENT_GAS, + ORACLE_EXACT_TOKEN1_REPORT, + ORACLE_SETTLEMENT_TIME, + ORACLE_DISPUTE_DELAY, + ORACLE_PROTOCOL_FEE, + ORACLE_FEE_PERCENTAGE, + ORACLE_MULTIPLIER, + ORACLE_TIME_TYPE, + ORACLE_TRACK_DISPUTES, + ORACLE_PROTOCOL_FEE_RECIPIENT, + ORACLE_PRICE_ROUND_BUDGET_MULTIPLIER_BPS, + ORACLE_ESCALATION_HALT_MULTIPLIER_BPS, + ORACLE_MAX_SETTLEMENT_BASE_FEE_MULTIPLIER_BPS, + ORACLE_MIN_LIQUIDATION_PRICE_DISTANCE_BPS, + ], ), ]) diff --git a/ui/ts/hooks/useSecurityPoolsOverview.ts b/ui/ts/hooks/useSecurityPoolsOverview.ts index 872ec77e..bf85a8d8 100644 --- a/ui/ts/hooks/useSecurityPoolsOverview.ts +++ b/ui/ts/hooks/useSecurityPoolsOverview.ts @@ -13,7 +13,7 @@ import { parseAddressInput } from '../lib/inputs.js' import { parseBigIntInput, parseRepAmountInput } from '../lib/marketForm.js' import { getOracleRequestEthGuardMessage } from '../lib/oracleRequestEth.js' import { useRequestGuard } from '../lib/requestGuard.js' -import { DEFAULT_STAGED_OPERATION_TIMEOUT_MINUTES, getStagedOperationTimeoutSeconds, MIN_STAGED_OPERATION_TIMEOUT_MINUTES } from '../lib/securityVault.js' +import { DEFAULT_STAGED_OPERATION_TIMEOUT_MINUTES, getStagedOperationTimeoutSeconds, MAX_STAGED_OPERATION_TIMEOUT_MINUTES, MIN_STAGED_OPERATION_TIMEOUT_MINUTES } from '../lib/securityVault.js' import type { WriteOperationsParameters } from '../types/app.js' import type { ListedSecurityPool, SecurityPoolOverviewActionResult, SecurityPoolPage } from '../types/contracts.js' @@ -164,6 +164,7 @@ export function useSecurityPoolsOverview({ accountAddress, onTransactionFailed, const amount = parseRepAmountInput(liquidationAmount.value, 'Liquidation amount') const timeoutMinutes = parseBigIntInput(liquidationTimeoutMinutes.value, 'Liquidation timeout') if (timeoutMinutes < MIN_STAGED_OPERATION_TIMEOUT_MINUTES) throw new Error('Liquidation timeout must be at least 1 minute') + if (timeoutMinutes > MAX_STAGED_OPERATION_TIMEOUT_MINUTES) throw new Error('Liquidation timeout must be 5 minutes or less') const validForSeconds = getStagedOperationTimeoutSeconds(timeoutMinutes) if (validForSeconds === undefined) throw new Error('Liquidation timeout must be at least 1 minute') return await queueSecurityPoolLiquidation(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), managerAddress, targetVault, amount, validForSeconds) diff --git a/ui/ts/lib/liquidationStatus.ts b/ui/ts/lib/liquidationStatus.ts index 3d01994d..b5abd84f 100644 --- a/ui/ts/lib/liquidationStatus.ts +++ b/ui/ts/lib/liquidationStatus.ts @@ -19,6 +19,6 @@ export function getLiquidationNoticeState({ if (securityPoolOverviewResult.queuedOperation?.operation === 'liquidation') return 'queued' if (loadingPoolOracleManager || currentPoolOracleManagerDetails === undefined) return 'submitted' if (currentPoolOracleManagerDetails.pendingOperation?.operation === 'liquidation' && sameAddress(currentPoolOracleManagerDetails.pendingOperation.targetVault, liquidationTargetVault)) return 'queued' - if (currentPoolOracleManagerDetails.isPriceValid) return 'successful' + if (currentPoolOracleManagerDetails.isPriceUsable === true) return 'successful' return 'submitted' } diff --git a/ui/ts/lib/marketForm.ts b/ui/ts/lib/marketForm.ts index ee86521f..53b58303 100644 --- a/ui/ts/lib/marketForm.ts +++ b/ui/ts/lib/marketForm.ts @@ -36,7 +36,7 @@ export function getDefaultSecurityVaultFormState(): SecurityVaultFormState { repWithdrawAmount: '0', selectedVaultAddress: '', securityPoolAddress: '', - stagedOperationTimeoutMinutes: '30', + stagedOperationTimeoutMinutes: '5', } } diff --git a/ui/ts/lib/securityVault.ts b/ui/ts/lib/securityVault.ts index 592bfcf4..76260a47 100644 --- a/ui/ts/lib/securityVault.ts +++ b/ui/ts/lib/securityVault.ts @@ -5,9 +5,10 @@ import { sameAddress } from './address.js' export const MIN_SECURITY_VAULT_REP_DEPOSIT = 10n * 10n ** 18n export const MIN_SECURITY_BOND_ALLOWANCE = 1n * 10n ** 18n -export const ORACLE_MANAGER_PRICE_VALID_FOR_SECONDS = 60n * 60n -export const DEFAULT_STAGED_OPERATION_TIMEOUT_MINUTES = 30n +export const ORACLE_MANAGER_PRICE_VALID_FOR_SECONDS = 5n * 60n +export const DEFAULT_STAGED_OPERATION_TIMEOUT_MINUTES = 5n export const MIN_STAGED_OPERATION_TIMEOUT_MINUTES = 1n +export const MAX_STAGED_OPERATION_TIMEOUT_MINUTES = 5n const PRICE_PRECISION = 10n ** 18n export function getSelectedVaultAddress(selectedVaultAddress: string | undefined, accountAddress: Address | undefined) { diff --git a/ui/ts/lib/securityVaultGuards.ts b/ui/ts/lib/securityVaultGuards.ts index ef5d6d80..4dc7df26 100644 --- a/ui/ts/lib/securityVaultGuards.ts +++ b/ui/ts/lib/securityVaultGuards.ts @@ -1,7 +1,7 @@ import type { Address } from 'viem' import { formatCurrencyBalance } from './formatters.js' import { getOracleRequestEthGuardMessage } from './oracleRequestEth.js' -import { MIN_SECURITY_BOND_ALLOWANCE, MIN_SECURITY_VAULT_REP_DEPOSIT, MIN_STAGED_OPERATION_TIMEOUT_MINUTES } from './securityVault.js' +import { MAX_STAGED_OPERATION_TIMEOUT_MINUTES, MIN_SECURITY_BOND_ALLOWANCE, MIN_SECURITY_VAULT_REP_DEPOSIT, MIN_STAGED_OPERATION_TIMEOUT_MINUTES } from './securityVault.js' export function getVaultApprovalGuardMessage({ accountAddress, isMainnet, selectedVaultDetailsLoaded, selectedVaultIsOwnedByAccount }: { accountAddress: Address | undefined; isMainnet: boolean; selectedVaultDetailsLoaded: boolean; selectedVaultIsOwnedByAccount: boolean }) { if (accountAddress === undefined) return 'Connect wallet to continue.' @@ -67,6 +67,7 @@ export function getVaultWithdrawGuardMessage({ if (withdrawableRepAmount === undefined || withdrawableRepAmount <= 0n) return 'No REP is currently withdrawable from this vault.' if (withdrawAmount > withdrawableRepAmount) return `Reduce the withdrawal to ${formatCurrencyBalance(withdrawableRepAmount)} REP or less.` if (stagedOperationTimeoutMinutes === undefined || stagedOperationTimeoutMinutes < MIN_STAGED_OPERATION_TIMEOUT_MINUTES) return 'Enter a staged operation timeout of at least 1 minute.' + if (stagedOperationTimeoutMinutes > MAX_STAGED_OPERATION_TIMEOUT_MINUTES) return 'Enter a staged operation timeout of 5 minutes or less.' const ethGuardMessage = getOracleRequestEthGuardMessage({ actionLabel: 'queue this REP withdrawal', requestPriceEthCost, @@ -105,6 +106,7 @@ export function getVaultSetSecurityBondAllowanceGuardMessage({ if (securityBondAllowanceAmount !== 0n && securityBondAllowanceAmount < MIN_SECURITY_BOND_ALLOWANCE) return `Enter at least ${formatCurrencyBalance(MIN_SECURITY_BOND_ALLOWANCE)} ETH for a non-zero allowance.` if (maxSecurityBondAllowanceAmount !== undefined && securityBondAllowanceAmount > maxSecurityBondAllowanceAmount) return `Reduce the security bond allowance to ${formatCurrencyBalance(maxSecurityBondAllowanceAmount)} ETH or less.` if (stagedOperationTimeoutMinutes === undefined || stagedOperationTimeoutMinutes < MIN_STAGED_OPERATION_TIMEOUT_MINUTES) return 'Enter a staged operation timeout of at least 1 minute.' + if (stagedOperationTimeoutMinutes > MAX_STAGED_OPERATION_TIMEOUT_MINUTES) return 'Enter a staged operation timeout of 5 minutes or less.' const ethGuardMessage = getOracleRequestEthGuardMessage({ actionLabel: 'queue this bond allowance update', requestPriceEthCost, diff --git a/ui/ts/simulation/bootstrap.ts b/ui/ts/simulation/bootstrap.ts index 90ba01d5..71bb604b 100644 --- a/ui/ts/simulation/bootstrap.ts +++ b/ui/ts/simulation/bootstrap.ts @@ -45,12 +45,12 @@ const SEEDED_REP_ETH_PRICE = 3n * 10n ** 18n const REP_TOKEN_MINT_AMOUNT = 100_000_000n * 10n ** 18n const SECURITY_MULTIPLIER = 2n const SECURITY_POOL_REP_DEPOSIT = 10_000n * 10n ** 18n -const SECURITY_BOND_ALLOWANCE = SECURITY_POOL_REP_DEPOSIT / 4n +const SECURITY_BOND_ALLOWANCE = 100n * 10n ** 18n const SECURITY_POOL_X2_PRIMARY_REP_DEPOSIT = 12_000n * 10n ** 18n -const SECURITY_POOL_X2_PRIMARY_SECURITY_BOND_ALLOWANCE = 1_000n * 10n ** 18n +const SECURITY_POOL_X2_PRIMARY_SECURITY_BOND_ALLOWANCE = 100n * 10n ** 18n const SECURITY_POOL_X2_SECONDARY_REP_DEPOSIT = SECURITY_POOL_REP_DEPOSIT const SECURITY_POOL_X2_SECONDARY_SECURITY_BOND_ALLOWANCE = SECURITY_BOND_ALLOWANCE -const STAGED_SELF_OPERATION_TIMEOUT_SECONDS = 24n * 60n * 60n +const STAGED_SELF_OPERATION_TIMEOUT_SECONDS = 5n * 60n const SECURITY_POOL_X2_AUCTION_EXTRA_REP_DEPOSIT = 20_000_000n * 10n ** 18n const SECURITY_POOL_X2_AUCTION_UNMIGRATED_REP_DEPOSIT = 1_000n * 10n ** 18n const SECURITY_POOL_X2_AUCTION_BID_PRICES = [getTruthAuctionPriceAtTick(12n), getTruthAuctionPriceAtTick(10n), getTruthAuctionPriceAtTick(8n)] as const @@ -554,7 +554,7 @@ async function ensureSecurityBondAllowanceConfigured({ } if (!reportDetails.isDistributed) { - await advanceSimulationTime(memoryClient, DAY_IN_SECONDS) + await advanceSimulationTime(memoryClient, reportDetails.settlementTime + 1n) await settleOracleReport(writeClient, managerDetails.openOracleAddress, managerDetails.pendingReportId) } } @@ -658,12 +658,27 @@ async function settleSeededOracleReport({ } } -async function settleOracleReportIfNeeded({ readClient, writeClient, openOracleAddress, pendingReportId }: { readClient: ReadClient; writeClient: WriteClient; openOracleAddress: Address; pendingReportId: bigint }) { +async function settleOracleReportIfNeeded({ memoryClient, readClient, writeClient, openOracleAddress, pendingReportId }: { memoryClient: TevmLikeClient; readClient: ReadClient; writeClient: WriteClient; openOracleAddress: Address; pendingReportId: bigint }) { const seededReport = await loadOpenOracleReportDetails(readClient, openOracleAddress, pendingReportId) if (seededReport.isDistributed) return + const reportTimestamp = getSimulationReportTiming(seededReport.reportTimestamp) + const settlementTime = getSimulationReportTiming(seededReport.settlementTime) + if (reportTimestamp !== undefined && settlementTime !== undefined) { + const settlementReadyTimestamp = reportTimestamp + settlementTime + 1n + const currentTimestamp = await getSimulationChainTimestamp(memoryClient) + if (currentTimestamp < settlementReadyTimestamp) { + await advanceSimulationTime(memoryClient, settlementReadyTimestamp - currentTimestamp) + } + } await settleOracleReport(writeClient, openOracleAddress, pendingReportId) } +function getSimulationReportTiming(value: unknown) { + if (typeof value === 'bigint') return value + if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return BigInt(value) + return undefined +} + async function seedSecurityPool({ createReadClient, createWriteClient, @@ -718,8 +733,8 @@ async function seedSecurityPool({ readClient, securityBondAllowance: primaryVaultSpec.securityBondAllowance, }) - await advanceSimulationTime(memoryClient, DAY_IN_SECONDS) await settleOracleReportIfNeeded({ + memoryClient, openOracleAddress: seededOracleReport.openOracleAddress, pendingReportId: seededOracleReport.pendingReportId, readClient, @@ -917,10 +932,9 @@ async function seedSecurityPoolX2Scenario({ }) } - await advanceSimulationTime(memoryClient, DAY_IN_SECONDS) - for (const preparedPool of preparedPools) { await settleOracleReportIfNeeded({ + memoryClient, openOracleAddress: preparedPool.openOracleAddress, pendingReportId: preparedPool.pendingReportId, readClient, diff --git a/ui/ts/tests/activeEnvironment.test.ts b/ui/ts/tests/activeEnvironment.test.ts index 2480b7c9..c8a3bcc3 100644 --- a/ui/ts/tests/activeEnvironment.test.ts +++ b/ui/ts/tests/activeEnvironment.test.ts @@ -16,7 +16,7 @@ import { installDomEnvironment } from './testUtils/domEnvironment.js' const DEFAULT_SIMULATION_REP_PER_ETH_PRICE = 3n * 10n ** 18n const SIMULATION_REP_MINT_AMOUNT = 1_000_000n * 10n ** 18n const SEEDED_REP_DEPOSIT = 10_000n * 10n ** 18n -const SEEDED_SECURITY_BOND_ALLOWANCE = SEEDED_REP_DEPOSIT / 4n +const SEEDED_SECURITY_BOND_ALLOWANCE = 100n * 10n ** 18n afterEach(() => { resetActiveEnvironmentForTesting() }) diff --git a/ui/ts/tests/contracts.test.ts b/ui/ts/tests/contracts.test.ts index 552f914b..6092fd24 100644 --- a/ui/ts/tests/contracts.test.ts +++ b/ui/ts/tests/contracts.test.ts @@ -550,7 +550,7 @@ describe('contracts helpers', () => { multicall: async request => { const firstContract = request.contracts[0] if (getContractFunctionName(firstContract) !== 'lastPrice') throw new Error(`Unexpected multicall contract: ${getContractFunctionName(firstContract)}`) - return [1n, pendingOperationSlotId, 0n, 5n, true, 10n, 40n] + return [1n, pendingOperationSlotId, 0n, 5n, true, true, 10n, 40n, 1n, 3000n, 0n, 3000n] }, readContract: async request => { if (request.functionName === 'getActiveStagedOperations') { diff --git a/ui/ts/tests/liquidationModal.test.tsx b/ui/ts/tests/liquidationModal.test.tsx index 79fc7ae8..b5c38d71 100644 --- a/ui/ts/tests/liquidationModal.test.tsx +++ b/ui/ts/tests/liquidationModal.test.tsx @@ -37,10 +37,11 @@ function createMarketDetails(overrides: Partial = {}): MarketDeta } function createOracleManagerDetails(overrides: Partial = {}): OracleManagerDetails { - return { + const details = { callbackStateHash: undefined, exactToken1Report: undefined, isPriceValid: true, + isPriceUsable: true, lastPrice: 1n, lastSettlementTimestamp: 1n, managerAddress: zeroAddress, @@ -54,6 +55,8 @@ function createOracleManagerDetails(overrides: Partial = { token2: zeroAddress, ...overrides, } + if (!details.isPriceValid) details.isPriceUsable = false + return details } function createTargetVaultSummary(overrides: Partial = {}): SecurityPoolVaultSummary { @@ -139,7 +142,7 @@ describe('LiquidationModal', () => { liquidationModalOpen liquidationSecurityPoolAddress={zeroAddress} liquidationTargetVault={defaultTargetVaultAddress} - liquidationTimeoutMinutes='30' + liquidationTimeoutMinutes='5' loadingPoolOracleManager={false} onLoadPoolOracleManager={() => undefined} onLiquidationAmountChange={() => undefined} @@ -164,6 +167,7 @@ describe('LiquidationModal', () => { const renderedComponent = await renderLiquidationModal({ currentPoolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, }), poolState: createEndedPoolState(), selectedPool: createSelectedPool({ @@ -190,7 +194,7 @@ describe('LiquidationModal', () => { expectTransactionButtonDisabled(document.body, 'Queue Liquidation') }) - test('defaults queued liquidation timeout copy to 30 minutes', async () => { + test('defaults queued liquidation timeout copy to 5 minutes', async () => { const renderedComponent = await renderLiquidationModal({ currentPoolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: false, @@ -198,7 +202,7 @@ describe('LiquidationModal', () => { }) cleanupRenderedComponent = renderedComponent.cleanup - expect(document.body.textContent?.includes('This queued staged operation will expire 30m after the oracle settlement window completes.')).toBe(true) + expect(document.body.textContent?.includes('This queued staged operation will expire 5m after the oracle settlement window completes.')).toBe(true) }) test('requires a queued liquidation timeout of at least 1 minute', async () => { @@ -270,7 +274,7 @@ describe('LiquidationModal', () => { liquidationManagerAddress={zeroAddress} liquidationModalOpen liquidationSecurityPoolAddress={zeroAddress} - liquidationTimeoutMinutes='30' + liquidationTimeoutMinutes='5' loadingPoolOracleManager={false} liquidationTargetVault={zeroAddress} onLoadPoolOracleManager={() => undefined} @@ -392,6 +396,7 @@ describe('LiquidationModal', () => { const renderedComponent = await renderLiquidationModal({ currentPoolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, pendingOperation: undefined, pendingOperationSlotId: 0n, }), @@ -436,6 +441,7 @@ describe('LiquidationModal', () => { const renderedComponent = await renderLiquidationModal({ currentPoolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, pendingOperation: undefined, pendingOperationSlotId: 0n, }), @@ -475,6 +481,7 @@ describe('LiquidationModal', () => { }} currentPoolOracleManagerDetails={createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 1n * 10n ** 18n, pendingOperation: undefined, pendingOperationSlotId: 0n, @@ -486,7 +493,7 @@ describe('LiquidationModal', () => { liquidationModalOpen={liquidationModalOpen} liquidationSecurityPoolAddress={zeroAddress} liquidationTargetVault={defaultTargetVaultAddress} - liquidationTimeoutMinutes='30' + liquidationTimeoutMinutes='5' loadingPoolOracleManager={false} onLoadPoolOracleManager={() => undefined} onLiquidationAmountChange={() => undefined} @@ -569,6 +576,7 @@ describe('LiquidationModal', () => { }} currentPoolOracleManagerDetails={createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 1n * 10n ** 18n, pendingOperation: undefined, pendingOperationSlotId: 0n, @@ -580,7 +588,7 @@ describe('LiquidationModal', () => { liquidationModalOpen={liquidationModalOpen} liquidationSecurityPoolAddress={zeroAddress} liquidationTargetVault={defaultTargetVaultAddress} - liquidationTimeoutMinutes='30' + liquidationTimeoutMinutes='5' loadingPoolOracleManager={false} onLoadPoolOracleManager={() => undefined} onLiquidationAmountChange={() => undefined} @@ -654,6 +662,7 @@ describe('LiquidationModal', () => { const renderedComponent = await renderLiquidationModal({ currentPoolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 1n * 10n ** 18n, }), selectedPool: createSelectedPool({ @@ -675,7 +684,7 @@ describe('LiquidationModal', () => { test('uses the shared chain timestamp context for oracle expiry text', async () => { const renderedComponent = await renderIntoDocument( - + undefined} @@ -687,7 +696,7 @@ describe('LiquidationModal', () => { liquidationModalOpen liquidationSecurityPoolAddress={zeroAddress} liquidationTargetVault={defaultTargetVaultAddress} - liquidationTimeoutMinutes='30' + liquidationTimeoutMinutes='5' loadingPoolOracleManager={false} onLoadPoolOracleManager={() => undefined} onLiquidationAmountChange={() => undefined} @@ -718,6 +727,7 @@ describe('LiquidationModal', () => { const renderedComponent = await renderLiquidationModal({ currentPoolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 10n * 10n ** 18n, }), liquidationAmount: '2', @@ -752,6 +762,7 @@ describe('LiquidationModal', () => { const renderedComponent = await renderLiquidationModal({ currentPoolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 10n * 10n ** 18n, }), liquidationAmount: '1', @@ -805,6 +816,7 @@ describe('LiquidationModal', () => { accountAddress: vaultAddress, currentPoolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 10n * 10n ** 18n, }), liquidationAmount: '1', @@ -840,6 +852,7 @@ describe('LiquidationModal', () => { accountAddress: callerVaultAddress, currentPoolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 10n * 10n ** 18n, }), liquidationAmount: '2', @@ -875,6 +888,7 @@ describe('LiquidationModal', () => { closeLiquidationModal={() => undefined} currentPoolOracleManagerDetails={createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 3n * 10n ** 18n, })} isMainnet @@ -884,7 +898,7 @@ describe('LiquidationModal', () => { liquidationModalOpen liquidationSecurityPoolAddress={zeroAddress} liquidationTargetVault={defaultTargetVaultAddress} - liquidationTimeoutMinutes='30' + liquidationTimeoutMinutes='5' loadingPoolOracleManager={false} onLoadPoolOracleManager={() => undefined} onLiquidationAmountChange={setLiquidationAmount} @@ -974,6 +988,7 @@ describe('LiquidationModal', () => { accountAddress: callerVaultAddress, currentPoolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 3n * 10n ** 18n, }), callerVaultSummary: createTargetVaultSummary({ @@ -1003,6 +1018,7 @@ describe('LiquidationModal', () => { const renderedComponent = await renderLiquidationModal({ currentPoolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 1n * 10n ** 18n, }), targetVaultSummary: createTargetVaultSummary({ @@ -1022,6 +1038,7 @@ describe('LiquidationModal', () => { const renderedComponent = await renderLiquidationModal({ currentPoolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 1n * 10n ** 18n, }), targetVaultSummary: createTargetVaultSummary({ diff --git a/ui/ts/tests/marketForm.test.ts b/ui/ts/tests/marketForm.test.ts index 99ce61d5..74c3b0bc 100644 --- a/ui/ts/tests/marketForm.test.ts +++ b/ui/ts/tests/marketForm.test.ts @@ -24,7 +24,7 @@ describe('market form defaults and conversion helpers', () => { expect(getDefaultSecurityPoolFormState().currentRetentionRate).toBe('10') expect(getDefaultSecurityPoolFormState().securityMultiplier).toBe('2') expect(getDefaultSecurityVaultFormState().depositAmount).toBe('0') - expect(getDefaultSecurityVaultFormState().stagedOperationTimeoutMinutes).toBe('30') + expect(getDefaultSecurityVaultFormState().stagedOperationTimeoutMinutes).toBe('5') expect(getDefaultReportingFormState().selectedOutcome).toBeUndefined() expect(getDefaultReportingFormState().selectedWithdrawDepositIndexesByOutcome).toEqual({ invalid: [], yes: [], no: [] }) expect(getDefaultTradingFormState().selectedShareOutcome).toBe('yes') diff --git a/ui/ts/tests/openOracle.test.ts b/ui/ts/tests/openOracle.test.ts index 149de480..ade27e34 100644 --- a/ui/ts/tests/openOracle.test.ts +++ b/ui/ts/tests/openOracle.test.ts @@ -49,7 +49,7 @@ const genesisUniverse = 0n const securityMultiplier = 2n const MAX_RETENTION_RATE = 999_999_996_848_000_000n const reportedRepEthPrice = 10n -const DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS = 30n * 60n +const DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS = 5n * 60n const outcomes = ['Yes', 'No'] function createQuoteClient(amountOut: bigint): Parameters[0] { @@ -1028,6 +1028,7 @@ describe('Open Oracle helpers', () => { const openOracleAddress = getOpenOracleAddress() await approveToken(client, addressString(GENESIS_REPUTATION_TOKEN), openOracleAddress) await approveToken(client, WETH_ADDRESS, openOracleAddress) + await mockWindow.setBalance(client.account.address, amount2 + 10n ** 18n) await wrapWethTestHelper(client, amount2) const stateHash = (await getOpenOracleExtraData(client, reportId)).stateHash @@ -1065,6 +1066,7 @@ describe('Open Oracle helpers', () => { const openOracleAddress = getOpenOracleAddress() await approveToken(client, addressString(GENESIS_REPUTATION_TOKEN), openOracleAddress) await approveToken(client, WETH_ADDRESS, openOracleAddress) + await mockWindow.setBalance(client.account.address, amount2 + 10n ** 18n) await wrapWethTestHelper(client, amount2) const stateHash = (await getOpenOracleExtraData(client, reportId)).stateHash @@ -1085,6 +1087,7 @@ describe('Open Oracle helpers', () => { const openOracleAddress = getOpenOracleAddress() await approveToken(client, addressString(GENESIS_REPUTATION_TOKEN), openOracleAddress) await approveToken(client, WETH_ADDRESS, openOracleAddress) + await mockWindow.setBalance(client.account.address, amount2 + 10n ** 18n) await wrapWethTestHelper(client, amount2) const stateHash = (await getOpenOracleExtraData(client, reportId)).stateHash diff --git a/ui/ts/tests/securityPoolWorkflow.test.ts b/ui/ts/tests/securityPoolWorkflow.test.ts index 79d64ec0..a34ca7ad 100644 --- a/ui/ts/tests/securityPoolWorkflow.test.ts +++ b/ui/ts/tests/securityPoolWorkflow.test.ts @@ -546,7 +546,7 @@ void describe('selected pool oracle price display', () => { lastSettlementTimestamp: 1n, priceValidUntilTimestamp: undefined, }), - ).toEqual({ text: '(Valid for 59m)', tone: 'success' }) + ).toEqual({ text: '(Valid for 4m)', tone: 'success' }) }) void test('omits validity before settlement and reports expiry after the window closes', () => { diff --git a/ui/ts/tests/securityPoolWorkflowSection.test.tsx b/ui/ts/tests/securityPoolWorkflowSection.test.tsx index 1e9eab9f..5149f04f 100644 --- a/ui/ts/tests/securityPoolWorkflowSection.test.tsx +++ b/ui/ts/tests/securityPoolWorkflowSection.test.tsx @@ -140,10 +140,11 @@ function createSecurityVaultDetails(overrides: Partial = { } function createOracleManagerDetails(overrides: Partial = {}): OracleManagerDetails { - return { + const details = { callbackStateHash: undefined, exactToken1Report: undefined, isPriceValid: true, + isPriceUsable: true, lastPrice: 1n, lastSettlementTimestamp: 1n, managerAddress: zeroAddress, @@ -157,6 +158,8 @@ function createOracleManagerDetails(overrides: Partial = { token2: zeroAddress, ...overrides, } + if (!details.isPriceValid) details.isPriceUsable = false + return details } function createSecurityPoolVaultSummary(overrides: Partial = {}): SecurityPoolVaultSummary { @@ -309,7 +312,7 @@ function createSecurityPoolWorkflowProps(overrides: Partial undefined, @@ -734,6 +737,7 @@ describe('SecurityPoolWorkflowSection', () => { checkedSecurityPoolAddress: selectedPoolAddress, poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, }), securityPoolAddress: selectedPoolAddress, securityPools: [ @@ -1039,6 +1043,7 @@ describe('SecurityPoolWorkflowSection', () => { accountState: createAccountState(), poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, pendingOperation: undefined, pendingOperationSlotId: 0n, }), @@ -1085,6 +1090,7 @@ describe('SecurityPoolWorkflowSection', () => { accountState: createAccountState(), poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, pendingOperation: undefined, pendingOperationSlotId: 0n, }), @@ -1140,6 +1146,7 @@ describe('SecurityPoolWorkflowSection', () => { liquidationTargetVault: zeroAddress, poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, managerAddress: zeroAddress, pendingOperation: undefined, }), @@ -1173,6 +1180,7 @@ describe('SecurityPoolWorkflowSection', () => { liquidationTargetVault: zeroAddress, poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, managerAddress: zeroAddress, pendingOperation: undefined, }), @@ -1214,6 +1222,7 @@ describe('SecurityPoolWorkflowSection', () => { }, poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, pendingOperation: undefined, pendingOperationSlotId: 0n, }), @@ -1421,6 +1430,7 @@ describe('SecurityPoolWorkflowSection', () => { }, poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, managerAddress: zeroAddress, pendingOperation: undefined, }), @@ -1471,6 +1481,7 @@ describe('SecurityPoolWorkflowSection', () => { }, poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, managerAddress: zeroAddress, pendingOperation: undefined, }), @@ -1643,6 +1654,7 @@ describe('SecurityPoolWorkflowSection', () => { }, poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, managerAddress: zeroAddress, }), securityPoolAddress: selectedPoolAddress, @@ -1761,6 +1773,7 @@ describe('SecurityPoolWorkflowSection', () => { accountState: createAccountState(), poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 3n * 10n ** 18n, }), securityPoolAddress: selectedPoolAddress, @@ -1811,6 +1824,7 @@ describe('SecurityPoolWorkflowSection', () => { accountState: createAccountState(), poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 3n * 10n ** 18n, }), securityPoolAddress: selectedPoolAddress, @@ -1868,6 +1882,7 @@ describe('SecurityPoolWorkflowSection', () => { accountState: createAccountState({ ethBalance: 2n * 10n ** 18n }), poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 3n * 10n ** 18n, }), securityPoolAddress: selectedPoolAddress, @@ -1920,6 +1935,7 @@ describe('SecurityPoolWorkflowSection', () => { accountState: createAccountState({ ethBalance: 5n * 10n ** 18n }), poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 3n * 10n ** 18n, requestPriceEthCost: 10n * 10n ** 18n, }), @@ -1972,6 +1988,7 @@ describe('SecurityPoolWorkflowSection', () => { accountState: createAccountState({ ethBalance: 5n * 10n ** 18n }), poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, lastPrice: 3n * 10n ** 18n, requestPriceEthCost: 10n * 10n ** 18n, }), @@ -2138,7 +2155,7 @@ describe('SecurityPoolWorkflowSection', () => { test('uses the shared chain timestamp context for oracle expiry text', async () => { const renderedComponent = await renderIntoDocument( - + { callbackStateHash: undefined, exactToken1Report: undefined, isPriceValid: true, + isPriceUsable: true, lastPrice: 2n * 10n ** 18n, lastSettlementTimestamp: 100n, managerAddress: zeroAddress, @@ -2231,6 +2249,7 @@ describe('SecurityPoolWorkflowSection', () => { callbackStateHash: undefined, exactToken1Report: undefined, isPriceValid: true, + isPriceUsable: true, lastPrice: 2n * 10n ** 18n, lastSettlementTimestamp: 100n, managerAddress: zeroAddress, @@ -2305,6 +2324,7 @@ describe('SecurityPoolWorkflowSection', () => { callbackStateHash: undefined, exactToken1Report: undefined, isPriceValid: true, + isPriceUsable: true, lastPrice: 2n * 10n ** 18n, lastSettlementTimestamp: 100n, managerAddress: zeroAddress, @@ -2348,6 +2368,7 @@ describe('SecurityPoolWorkflowSection', () => { callbackStateHash: undefined, exactToken1Report: undefined, isPriceValid: true, + isPriceUsable: true, lastPrice: 2n * 10n ** 18n, lastSettlementTimestamp: 100n, managerAddress: zeroAddress, diff --git a/ui/ts/tests/securityPoolsOverviewSection.test.tsx b/ui/ts/tests/securityPoolsOverviewSection.test.tsx index f8b6fe2e..af3fc25e 100644 --- a/ui/ts/tests/securityPoolsOverviewSection.test.tsx +++ b/ui/ts/tests/securityPoolsOverviewSection.test.tsx @@ -108,7 +108,7 @@ function createProps(overrides: Partial = {}) liquidationModalOpen: false, liquidationSecurityPoolAddress: undefined, liquidationTargetVault: '', - liquidationTimeoutMinutes: '30', + liquidationTimeoutMinutes: '5', loadingPoolOracleManager: false, loadingSecurityPoolPage: false, loadingSecurityPools: false, diff --git a/ui/ts/tests/securityPoolsSection.test.ts b/ui/ts/tests/securityPoolsSection.test.ts index aec41577..79f38343 100644 --- a/ui/ts/tests/securityPoolsSection.test.ts +++ b/ui/ts/tests/securityPoolsSection.test.ts @@ -217,10 +217,11 @@ function createSelectedPool(overrides: Partial = {}): Listed } function createOracleManagerDetails(overrides: Partial = {}): OracleManagerDetails { - return { + const details = { callbackStateHash: undefined, exactToken1Report: undefined, isPriceValid: true, + isPriceUsable: true, lastPrice: 1n, lastSettlementTimestamp: 1n, managerAddress: zeroAddress, @@ -234,6 +235,8 @@ function createOracleManagerDetails(overrides: Partial = { token2: zeroAddress, ...overrides, } + if (!details.isPriceValid) details.isPriceUsable = false + return details } function createWorkflowProps(overrides: Partial = {}): SecurityPoolWorkflowRouteContentProps { @@ -249,7 +252,7 @@ function createWorkflowProps(overrides: Partial undefined, @@ -308,7 +311,7 @@ function createOverviewProps(overrides: Partial { liquidationTargetVault: zeroAddress, poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, managerAddress: zeroAddress, }), securityPoolOverviewResult: { @@ -741,6 +745,7 @@ void describe('SecurityPoolsSection', () => { liquidationTargetVault: zeroAddress, poolOracleManagerDetails: createOracleManagerDetails({ isPriceValid: true, + isPriceUsable: true, managerAddress: zeroAddress, }), securityPoolOverviewResult: { diff --git a/ui/ts/tests/securityVaultGuards.test.ts b/ui/ts/tests/securityVaultGuards.test.ts index 1197aeb3..2c04479c 100644 --- a/ui/ts/tests/securityVaultGuards.test.ts +++ b/ui/ts/tests/securityVaultGuards.test.ts @@ -65,7 +65,7 @@ describe('security vault guards', () => { isMainnet: true, requestPriceEthCost: undefined, selectedVaultIsOwnedByAccount: true, - stagedOperationTimeoutMinutes: 30n, + stagedOperationTimeoutMinutes: 5n, withdrawAmount: 1n, withdrawableRepAmount: 1n, walletEthBalance: 1n, @@ -79,7 +79,7 @@ describe('security vault guards', () => { isMainnet: true, requestPriceEthCost: undefined, selectedVaultIsOwnedByAccount: true, - stagedOperationTimeoutMinutes: 30n, + stagedOperationTimeoutMinutes: 5n, withdrawAmount: 10_000n * 10n ** 18n, withdrawableRepAmount: 2_500n * 10n ** 18n, walletEthBalance: 1n, @@ -95,7 +95,7 @@ describe('security vault guards', () => { securityBondAllowanceAmount: undefined, selectedVaultDetailsLoaded: true, selectedVaultIsOwnedByAccount: true, - stagedOperationTimeoutMinutes: 30n, + stagedOperationTimeoutMinutes: 5n, walletEthBalance: 1n, }), ).toBe('Enter a valid security bond allowance.') @@ -109,7 +109,7 @@ describe('security vault guards', () => { securityBondAllowanceAmount: 0n, selectedVaultDetailsLoaded: true, selectedVaultIsOwnedByAccount: true, - stagedOperationTimeoutMinutes: 30n, + stagedOperationTimeoutMinutes: 5n, walletEthBalance: 1n, }), ).toBeUndefined() @@ -123,7 +123,7 @@ describe('security vault guards', () => { securityBondAllowanceAmount: 5n * 10n ** 17n, selectedVaultDetailsLoaded: true, selectedVaultIsOwnedByAccount: true, - stagedOperationTimeoutMinutes: 30n, + stagedOperationTimeoutMinutes: 5n, walletEthBalance: 1n, }), ).toBe('Enter at least 1 ETH for a non-zero allowance.') @@ -137,7 +137,7 @@ describe('security vault guards', () => { securityBondAllowanceAmount: 6n * 10n ** 18n, selectedVaultDetailsLoaded: true, selectedVaultIsOwnedByAccount: true, - stagedOperationTimeoutMinutes: 30n, + stagedOperationTimeoutMinutes: 5n, walletEthBalance: 1n, }), ).toBe('Reduce the security bond allowance to 5 ETH or less.') @@ -212,7 +212,7 @@ describe('security vault guards', () => { isMainnet: true, requestPriceEthCost: 10n * ETH, selectedVaultIsOwnedByAccount: true, - stagedOperationTimeoutMinutes: 30n, + stagedOperationTimeoutMinutes: 5n, withdrawAmount: 1n * ETH, withdrawableRepAmount: 5n * ETH, walletEthBalance: 5n * ETH, @@ -228,7 +228,7 @@ describe('security vault guards', () => { securityBondAllowanceAmount: 0n, selectedVaultDetailsLoaded: true, selectedVaultIsOwnedByAccount: true, - stagedOperationTimeoutMinutes: 30n, + stagedOperationTimeoutMinutes: 5n, walletEthBalance: 5n * ETH, }), ).toBe('Need 7 more ETH in this wallet to queue this bond allowance update.') diff --git a/ui/ts/tests/securityVaultSection.test.tsx b/ui/ts/tests/securityVaultSection.test.tsx index c0286131..487089ce 100644 --- a/ui/ts/tests/securityVaultSection.test.tsx +++ b/ui/ts/tests/securityVaultSection.test.tsx @@ -207,7 +207,7 @@ describe('SecurityVaultSection', () => { expectTransactionButtonEnabled(document.body, 'Set Security Bond Allowance') }) - test('defaults queued self-service timeout copy to 30 minutes when the form has no explicit timeout', async () => { + test('defaults queued self-service timeout copy to 5 minutes when the form has no explicit timeout', async () => { const renderedComponent = await renderIntoDocument( { ) cleanupRenderedComponent = renderedComponent.cleanup - expect(document.body.textContent?.includes('This queued self-service operation will expire 30m after the oracle settlement window completes.')).toBe(true) + expect(document.body.textContent?.includes('This queued self-service operation will expire 5m after the oracle settlement window completes.')).toBe(true) }) test('blocks non-zero security bond allowances below the minimum', async () => { diff --git a/ui/ts/types/contracts.ts b/ui/ts/types/contracts.ts index a862ddad..753df467 100644 --- a/ui/ts/types/contracts.ts +++ b/ui/ts/types/contracts.ts @@ -185,6 +185,7 @@ export type OracleManagerDetails = { callbackStateHash: Hex | undefined exactToken1Report: bigint | undefined isPriceValid: boolean + isPriceUsable?: boolean lastPrice: bigint lastSettlementTimestamp: bigint managerAddress: Address @@ -192,6 +193,10 @@ export type OracleManagerDetails = { pendingOperation: StagedOracleOperation | undefined pendingOperationSlotId: bigint pendingReportId: bigint + priceRoundConsumedNotional?: bigint + priceRoundId?: bigint + priceRoundMaxNotional?: bigint + priceRoundRemainingNotional?: bigint priceValidUntilTimestamp: bigint | undefined requestPriceEthCost: bigint stagedOperations?: StagedOracleOperation[]