From 516eeb433b62738ae6d4d94a1b2c499d539eb15e Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Fri, 12 Jun 2026 09:13:02 +0000 Subject: [PATCH] Add configurable protocol settings and safe token ops - Add shared protocol configuration plumbing with defaults, environment/global overrides, and validation - Make fork threshold, burn divisor, and initial escalation deposit configurable through constructors and factory wiring - Introduce safe ERC20 call wrappers and replace vulnerable transfer/transferFrom/approve usages - Add active vault/staged-operation paging APIs and update UI/contracts consumers and tests accordingly --- shared/package.json | 4 + shared/ts/protocolConfig.ts | 100 ++++++++++ solidity/contracts/SafeERC20Ops.sol | 27 +++ solidity/contracts/Zoltar.sol | 23 ++- .../contracts/peripherals/SecurityPool.sol | 130 +++++++++++-- .../peripherals/SecurityPoolForker.sol | 8 +- .../SecurityPoolMigrationProxy.sol | 8 +- .../SecurityPoolOracleCoordinator.sol | 73 +++++++- .../factories/SecurityPoolDeployer.sol | 2 + .../factories/SecurityPoolFactory.sol | 6 +- .../peripherals/interfaces/ISecurityPool.sol | 4 +- .../peripherals/test/FalseReturningERC20.sol | 30 +++ .../peripherals/test/SafeERC20OpsHarness.sol | 21 +++ .../escalationGame_forkThreshold.test.ts | 18 +- solidity/ts/tests/peripherals.test.ts | 42 +++++ solidity/ts/tests/priceOracleSecurity.test.ts | 79 +++++++- solidity/ts/tests/safeErc20.test.ts | 106 +++++++++++ solidity/ts/tests/zoltar.test.ts | 82 ++++++++- .../utils/contracts/deployPeripherals.ts | 43 +++-- .../simulator/utils/contracts/securityPool.ts | 16 ++ .../simulator/utils/contracts/zoltar.ts | 23 ++- .../simulator/utils/protocolConfig.ts | 92 ++++++++++ ui/ts/components/ForkAuctionSection.tsx | 11 +- .../components/SecurityPoolVaultDirectory.tsx | 7 + .../SecurityPoolWorkflowSection.tsx | 26 ++- .../SecurityPoolsOverviewSection.tsx | 30 +-- ui/ts/contracts.ts | 171 ++++++++++++------ ui/ts/contracts/deploymentHelpers.ts | 27 ++- ui/ts/contracts/securityPools.ts | 120 ++++++------ ui/ts/hooks/useSecurityPoolsOverview.ts | 14 +- ui/ts/tests/contracts.test.ts | 145 ++++++++++++++- .../forkAuctionChildPoolRecovery.test.tsx | 50 ++++- ui/ts/tests/openOracle.test.ts | 7 + ui/ts/tests/protocolConfig.test.ts | 74 ++++++++ .../securityPoolWorkflowSection.test.tsx | 2 + .../securityPoolsOverviewSection.test.tsx | 109 ++++++++++- ui/ts/types/contracts.ts | 2 + 37 files changed, 1505 insertions(+), 227 deletions(-) create mode 100644 shared/ts/protocolConfig.ts create mode 100644 solidity/contracts/SafeERC20Ops.sol create mode 100644 solidity/contracts/peripherals/test/FalseReturningERC20.sol create mode 100644 solidity/contracts/peripherals/test/SafeERC20OpsHarness.sol create mode 100644 solidity/ts/tests/safeErc20.test.ts create mode 100644 solidity/ts/testsuite/simulator/utils/protocolConfig.ts create mode 100644 ui/ts/tests/protocolConfig.test.ts diff --git a/shared/package.json b/shared/package.json index 6fe9b45b..5d8c3b91 100644 --- a/shared/package.json +++ b/shared/package.json @@ -14,6 +14,10 @@ "./deploymentAddresses": { "types": "./ts/deploymentAddresses.ts", "default": "./js/deploymentAddresses.js" + }, + "./protocolConfig": { + "types": "./ts/protocolConfig.ts", + "default": "./js/protocolConfig.js" } }, "files": [ diff --git a/shared/ts/protocolConfig.ts b/shared/ts/protocolConfig.ts new file mode 100644 index 00000000..842107da --- /dev/null +++ b/shared/ts/protocolConfig.ts @@ -0,0 +1,100 @@ +export type ProtocolConfig = { + forkBurnDivisor: bigint + forkThresholdDivisor: bigint + initialEscalationGameDeposit: bigint +} + +export type ProtocolConfigInput = Partial<{ + [key in keyof ProtocolConfig]: bigint | number | string | undefined +}> + +export const DEFAULT_FORK_THRESHOLD_DIVISOR = 20n +export const DEFAULT_FORK_BURN_DIVISOR = 5n +export const DEFAULT_INITIAL_ESCALATION_GAME_DEPOSIT = 10n ** 18n + +export const DEFAULT_PROTOCOL_CONFIG: ProtocolConfig = { + forkBurnDivisor: DEFAULT_FORK_BURN_DIVISOR, + forkThresholdDivisor: DEFAULT_FORK_THRESHOLD_DIVISOR, + initialEscalationGameDeposit: DEFAULT_INITIAL_ESCALATION_GAME_DEPOSIT, +} + +const PROTOCOL_CONFIG_GLOBAL_KEY = '__ZOLTAR_PROTOCOL_CONFIG__' + +function parseConfigBigInt(value: bigint | number | string | undefined, field: keyof ProtocolConfig): bigint | undefined { + if (value === undefined) return undefined + if (typeof value === 'bigint') return value + if (typeof value === 'number') { + if (!Number.isInteger(value)) throw new Error(`Protocol config ${field} must be an integer`) + return BigInt(value) + } + const trimmedValue = value.trim() + if (trimmedValue === '') return undefined + return BigInt(trimmedValue) +} + +function readProcessEnv(name: string): string | undefined { + const processValue = Reflect.get(globalThis, 'process') + if (typeof processValue !== 'object' || processValue === null) return undefined + const envValue = Reflect.get(processValue, 'env') + if (typeof envValue !== 'object' || envValue === null) return undefined + const rawValue = Reflect.get(envValue, name) + if (typeof rawValue !== 'string') return undefined + const trimmedValue = rawValue.trim() + return trimmedValue === '' ? undefined : trimmedValue +} + +function getEnvironmentProtocolConfigOverrides(): ProtocolConfigInput { + const forkBurnDivisor = readProcessEnv('ZOLTAR_FORK_BURN_DIVISOR') + const forkThresholdDivisor = readProcessEnv('ZOLTAR_FORK_THRESHOLD_DIVISOR') + const initialEscalationGameDeposit = readProcessEnv('ZOLTAR_INITIAL_ESCALATION_GAME_DEPOSIT') + return { + ...(forkBurnDivisor === undefined ? {} : { forkBurnDivisor }), + ...(forkThresholdDivisor === undefined ? {} : { forkThresholdDivisor }), + ...(initialEscalationGameDeposit === undefined ? {} : { initialEscalationGameDeposit }), + } +} + +function readProtocolConfigOverrideValue(source: object, field: keyof ProtocolConfig) { + const rawValue = Reflect.get(source, field) + if (typeof rawValue === 'bigint' || typeof rawValue === 'number' || typeof rawValue === 'string') return rawValue + return undefined +} + +function getGlobalProtocolConfigOverrides(): ProtocolConfigInput { + const rawConfig = Reflect.get(globalThis, PROTOCOL_CONFIG_GLOBAL_KEY) + if (typeof rawConfig !== 'object' || rawConfig === null) return {} + const forkBurnDivisor = readProtocolConfigOverrideValue(rawConfig, 'forkBurnDivisor') + const forkThresholdDivisor = readProtocolConfigOverrideValue(rawConfig, 'forkThresholdDivisor') + const initialEscalationGameDeposit = readProtocolConfigOverrideValue(rawConfig, 'initialEscalationGameDeposit') + return { + ...(forkBurnDivisor === undefined ? {} : { forkBurnDivisor }), + ...(forkThresholdDivisor === undefined ? {} : { forkThresholdDivisor }), + ...(initialEscalationGameDeposit === undefined ? {} : { initialEscalationGameDeposit }), + } +} + +export function validateProtocolConfig(config: ProtocolConfigInput): ProtocolConfig { + const forkBurnDivisor = parseConfigBigInt(config.forkBurnDivisor, 'forkBurnDivisor') + const forkThresholdDivisor = parseConfigBigInt(config.forkThresholdDivisor, 'forkThresholdDivisor') + const initialEscalationGameDeposit = parseConfigBigInt(config.initialEscalationGameDeposit, 'initialEscalationGameDeposit') + if (forkThresholdDivisor === undefined) throw new Error('Protocol config forkThresholdDivisor is required') + if (forkBurnDivisor === undefined) throw new Error('Protocol config forkBurnDivisor is required') + if (initialEscalationGameDeposit === undefined) throw new Error('Protocol config initialEscalationGameDeposit is required') + if (forkThresholdDivisor <= 1n) throw new Error('Protocol config forkThresholdDivisor must be greater than 1') + if (forkBurnDivisor <= 1n) throw new Error('Protocol config forkBurnDivisor must be greater than 1') + if (initialEscalationGameDeposit <= 0n) throw new Error('Protocol config initialEscalationGameDeposit must be greater than 0') + return { + forkBurnDivisor, + forkThresholdDivisor, + initialEscalationGameDeposit, + } +} + +export function getProtocolConfig(overrides: ProtocolConfigInput = {}): ProtocolConfig { + return validateProtocolConfig({ + ...DEFAULT_PROTOCOL_CONFIG, + ...getEnvironmentProtocolConfigOverrides(), + ...getGlobalProtocolConfigOverrides(), + ...overrides, + }) +} diff --git a/solidity/contracts/SafeERC20Ops.sol b/solidity/contracts/SafeERC20Ops.sol new file mode 100644 index 00000000..7c1508ab --- /dev/null +++ b/solidity/contracts/SafeERC20Ops.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity 0.8.35; + +import { IERC20 } from './IERC20.sol'; + +library SafeERC20Ops { + function safeApprove(IERC20 token, address spender, uint256 amount) internal { + _callOptionalReturn(token, abi.encodeCall(IERC20.approve, (spender, amount))); + } + + function safeTransfer(IERC20 token, address receiver, uint256 amount) internal { + _callOptionalReturn(token, abi.encodeCall(IERC20.transfer, (receiver, amount))); + } + + function safeTransferFrom(IERC20 token, address sender, address receiver, uint256 amount) internal { + _callOptionalReturn(token, abi.encodeCall(IERC20.transferFrom, (sender, receiver, amount))); + } + + function _callOptionalReturn(IERC20 token, bytes memory callData) private { + require(address(token).code.length > 0, 'token missing code'); + (bool success, bytes memory returnData) = address(token).call(callData); + require(success, 'token call failed'); + if (returnData.length > 0) { + require(abi.decode(returnData, (bool)), 'token returned false'); + } + } +} diff --git a/solidity/contracts/Zoltar.sol b/solidity/contracts/Zoltar.sol index 79cfcc20..e774f192 100644 --- a/solidity/contracts/Zoltar.sol +++ b/solidity/contracts/Zoltar.sol @@ -2,13 +2,14 @@ pragma solidity 0.8.35; import './Constants.sol'; +import './IERC20.sol'; import './ReputationToken.sol'; +import './SafeERC20Ops.sol'; import './ZoltarQuestionData.sol'; -uint256 constant FORK_THRESHOLD_DIVISOR = 20; // TODO, revisit, 5% of total supply atm -uint256 constant FORK_BURN_DIVISOR = 5; // TODO, revisit, 20% of fork threshold - contract Zoltar { + using SafeERC20Ops for IERC20; + struct Universe { uint256 forkTime; uint256 forkQuestionId; @@ -32,10 +33,16 @@ contract Zoltar { event UniverseForked(address forker, uint248 universeId, uint256 questionId); event DeployChild(address deployer, uint248 universeId, uint256 outcomeIndex, uint248 childUniverseId, ReputationToken childReputationToken); + uint256 public immutable forkThresholdDivisor; + uint256 public immutable forkBurnDivisor; ZoltarQuestionData public zoltarQuestionData; - constructor(ZoltarQuestionData _zoltarQuestionData) { + constructor(ZoltarQuestionData _zoltarQuestionData, uint256 _forkThresholdDivisor, uint256 _forkBurnDivisor) { + require(_forkThresholdDivisor > 1, 'fork threshold divisor'); + require(_forkBurnDivisor > 1, 'fork burn divisor'); zoltarQuestionData = _zoltarQuestionData; + forkThresholdDivisor = _forkThresholdDivisor; + forkBurnDivisor = _forkBurnDivisor; universes[0] = Universe(0, 0, 0, ReputationToken(Constants.GENESIS_REPUTATION_TOKEN), 0); if (Constants.GENESIS_REPUTATION_TOKEN.code.length != 0) { // The configured genesis token must expose `getTotalTheoreticalSupply()`. @@ -56,7 +63,7 @@ contract Zoltar { } function getForkThreshold(uint248 universeId) public view returns (uint256) { - return getUniverseTheoreticalSupply(universeId) / FORK_THRESHOLD_DIVISOR; + return getUniverseTheoreticalSupply(universeId) / forkThresholdDivisor; } function getUniverseTheoreticalSupply(uint248 universeId) public view returns (uint256) { @@ -79,7 +86,7 @@ contract Zoltar { burnRep(universes[universeId].reputationToken, msg.sender, forkThreshold); universeTheoreticalSupplies[universeId] -= forkThreshold; childUniverseTheoreticalSupplySnapshots[universeId] = universeTheoreticalSupplies[universeId]; - migrationRepBalances[msg.sender][universeId].migrationRepBalance = forkThreshold - forkThreshold / FORK_BURN_DIVISOR; // burn 20% + migrationRepBalances[msg.sender][universeId].migrationRepBalance = forkThreshold - forkThreshold / forkBurnDivisor; emit UniverseForked(msg.sender, universeId, questionId); } @@ -87,9 +94,9 @@ contract Zoltar { // Genesis is using REPv2 which we cannot actually burn if (address(reputationToken) == Constants.GENESIS_REPUTATION_TOKEN) { if (migrator == address(this)) { - reputationToken.transfer(Constants.BURN_ADDRESS, amount); + IERC20(address(reputationToken)).safeTransfer(Constants.BURN_ADDRESS, amount); } else { - reputationToken.transferFrom(migrator, Constants.BURN_ADDRESS, amount); + IERC20(address(reputationToken)).safeTransferFrom(migrator, Constants.BURN_ADDRESS, amount); } } else { ReputationToken(address(reputationToken)).burn(migrator, amount); diff --git a/solidity/contracts/peripherals/SecurityPool.sol b/solidity/contracts/peripherals/SecurityPool.sol index 5ee5371b..2e6cc75f 100644 --- a/solidity/contracts/peripherals/SecurityPool.sol +++ b/solidity/contracts/peripherals/SecurityPool.sol @@ -1,8 +1,10 @@ // SPDX-License-Identifier: Unlicense pragma solidity 0.8.35; -import { Zoltar, FORK_THRESHOLD_DIVISOR } from '../Zoltar.sol'; +import { IERC20 } from '../IERC20.sol'; import { ReputationToken } from '../ReputationToken.sol'; +import { SafeERC20Ops } from '../SafeERC20Ops.sol'; +import { Zoltar } from '../Zoltar.sol'; import { IShareToken } from './interfaces/IShareToken.sol'; import { SecurityPoolOracleCoordinator } from './SecurityPoolOracleCoordinator.sol'; import { ISecurityPool, SecurityVault, SystemState, QuestionOutcome, ISecurityPoolFactory } from './interfaces/ISecurityPool.sol'; @@ -15,12 +17,13 @@ import { SecurityPoolForker } from './SecurityPoolForker.sol'; import { ISecurityPoolForker } from './interfaces/ISecurityPoolForker.sol'; import { BinaryOutcomes } from './BinaryOutcomes.sol'; -uint256 constant TODO_INITIAL_ESCALATION_GAME_DEPOSIT = 1 ether; // TODO, how to get this value? - // Security pool for one question, one universe, one denomination (ETH) contract SecurityPool is ISecurityPool { + using SafeERC20Ops for IERC20; + uint256 public immutable questionId; uint248 public immutable universeId; + uint256 public immutable initialEscalationGameDeposit; Zoltar public immutable zoltar; ISecurityPool immutable public parent; @@ -51,6 +54,13 @@ contract SecurityPool is ISecurityPool { mapping(address => SecurityVault) public securityVaults; address[] private vaults; mapping(address => uint256) private vaultIndexesPlusOne; + // Active-vault paging is newest-first so UI previews remain stable after removals + // and can intentionally surface the most recently touched active vaults. + uint256 private activeVaultCount; + address private latestActiveVault; + mapping(address => address) private olderActiveVaults; + mapping(address => address) private newerActiveVaults; + mapping(address => bool) private isActiveVault; SystemState public systemState; @@ -88,11 +98,13 @@ contract SecurityPool is ISecurityPool { _; } - constructor(address _securityPoolForker, ISecurityPoolFactory _securityPoolFactory, ZoltarQuestionData _questionData, EscalationGameFactory _escalationGameFactory, SecurityPoolOracleCoordinator _priceOracleManagerAndOperatorQueuer, IShareToken _shareToken, OpenOracle _openOracle, ISecurityPool _parent, Zoltar _zoltar, uint248 _universeId, uint256 _questionId, uint256 _securityMultiplier, address _truthAuction) { + constructor(address _securityPoolForker, ISecurityPoolFactory _securityPoolFactory, ZoltarQuestionData _questionData, EscalationGameFactory _escalationGameFactory, SecurityPoolOracleCoordinator _priceOracleManagerAndOperatorQueuer, IShareToken _shareToken, OpenOracle _openOracle, ISecurityPool _parent, Zoltar _zoltar, uint248 _universeId, uint256 _questionId, uint256 _securityMultiplier, uint256 _initialEscalationGameDeposit, address _truthAuction) { + require(_initialEscalationGameDeposit > 0, 'initial escalation deposit'); universeId = _universeId; securityPoolFactory = _securityPoolFactory; questionId = _questionId; securityMultiplier = _securityMultiplier; + initialEscalationGameDeposit = _initialEscalationGameDeposit; zoltar = _zoltar; parent = _parent; openOracle = _openOracle; @@ -108,25 +120,49 @@ contract SecurityPool is ISecurityPool { } shareToken = _shareToken; repToken = zoltar.getRepToken(universeId); - repToken.approve(address(zoltar), type(uint256).max); + IERC20(address(repToken)).safeApprove(address(zoltar), type(uint256).max); } function getVaultCount() external view returns (uint256) { return vaults.length; } - function initialEscalationGameDeposit() external pure returns (uint256) { - return TODO_INITIAL_ESCALATION_GAME_DEPOSIT; + function getActiveVaultCount() external view returns (uint256) { + return activeVaultCount; } function getVaults(uint256 startIndex, uint256 count) external view returns (address[] memory vaultRange) { - if (startIndex >= vaults.length || count == 0) return new address[](0); + return _sliceVaults(vaults, startIndex, count); + } - uint256 availableCount = vaults.length - startIndex; + function getActiveVaults(uint256 startIndex, uint256 count) external view returns (address[] memory vaultRange) { + return _sliceActiveVaults(startIndex, count); + } + + function _sliceVaults(address[] storage sourceVaults, uint256 startIndex, uint256 count) private view returns (address[] memory vaultRange) { + if (startIndex >= sourceVaults.length || count == 0) return new address[](0); + + uint256 availableCount = sourceVaults.length - startIndex; uint256 resultCount = count < availableCount ? count : availableCount; vaultRange = new address[](resultCount); for (uint256 index = 0; index < resultCount; index++) { - vaultRange[index] = vaults[startIndex + index]; + vaultRange[index] = sourceVaults[startIndex + index]; + } + } + + function _sliceActiveVaults(uint256 startIndex, uint256 count) private view returns (address[] memory vaultRange) { + if (count == 0 || startIndex >= activeVaultCount) return new address[](0); + + uint256 availableCount = activeVaultCount - startIndex; + uint256 resultCount = count < availableCount ? count : availableCount; + vaultRange = new address[](resultCount); + address currentVault = latestActiveVault; + for (uint256 skipped = 0; skipped < startIndex && currentVault != address(0x0); skipped++) { + currentVault = olderActiveVaults[currentVault]; + } + for (uint256 index = 0; index < resultCount && currentVault != address(0x0); index++) { + vaultRange[index] = currentVault; + currentVault = olderActiveVaults[currentVault]; } } @@ -179,6 +215,7 @@ contract SecurityPool is ISecurityPool { uint256 fees = securityVaults[vault].securityBondAllowance * (feeIndex - securityVaults[vault].feeIndex) / SecurityPoolUtils.PRICE_PRECISION; securityVaults[vault].feeIndex = feeIndex; securityVaults[vault].unpaidEthFees += fees; + _syncActiveVault(vault); emit UpdateVaultFees(vault, securityVaults[vault].feeIndex, securityVaults[vault].unpaidEthFees); } @@ -186,6 +223,7 @@ contract SecurityPool is ISecurityPool { uint256 fees = securityVaults[vault].unpaidEthFees; securityVaults[vault].unpaidEthFees = 0; totalFeesOwedToVaults -= fees; + _syncActiveVault(vault); (bool sent, ) = payable(vault).call{ value: fees }(''); require(sent, 'failed to send Ether'); emit RedeemFees(vault, fees); @@ -211,7 +249,8 @@ contract SecurityPool is ISecurityPool { securityVaults[vault].poolOwnership -= withdrawOwnership; poolOwnershipDenominator -= withdrawOwnership; - repToken.transfer(vault, withdrawRepAmount); + _syncActiveVault(vault); + IERC20(address(repToken)).safeTransfer(vault, withdrawRepAmount); emit PerformWithdrawRep(vault, withdrawRepAmount); } @@ -250,11 +289,12 @@ contract SecurityPool is ISecurityPool { function depositRep(uint256 repAmount) external isOperational { require(!isEscalationResolved(), 'question resolved'); uint256 poolOwnership = repToPoolOwnership(repAmount); - repToken.transferFrom(msg.sender, address(this), repAmount); + IERC20(address(repToken)).safeTransferFrom(msg.sender, address(this), repAmount); _trackVault(msg.sender); securityVaults[msg.sender].poolOwnership += poolOwnership; poolOwnershipDenominator += poolOwnership; require(poolOwnershipToRep(securityVaults[msg.sender].poolOwnership) >= SecurityPoolUtils.MIN_REP_DEPOSIT, 'min rep'); + _syncActiveVault(msg.sender); emit DepositRep(msg.sender, repAmount, securityVaults[msg.sender].poolOwnership); } @@ -326,6 +366,8 @@ contract SecurityPool is ISecurityPool { securityVaults[callerVault].securityBondAllowance >= SecurityPoolUtils.MIN_SECURITY_BOND_DEBT, 'caller min deposit requirement' ); + _syncActiveVault(targetVaultAddress); + _syncActiveVault(callerVault); emit PerformLiquidation(callerVault, targetVaultAddress, debtAmount, debtToMove, repToMove); } @@ -349,6 +391,7 @@ contract SecurityPool is ISecurityPool { require(getTotalRepBalance() * SecurityPoolUtils.PRICE_PRECISION > totalSecurityBondAllowance * priceOracleManagerAndOperatorQueuer.lastPrice()); require(totalSecurityBondAllowance >= completeSetCollateralAmount, 'too many sets'); require(securityVaults[callerVault].securityBondAllowance >= SecurityPoolUtils.MIN_SECURITY_BOND_DEBT || securityVaults[callerVault].securityBondAllowance == 0, 'min bond'); + _syncActiveVault(callerVault); emit SecurityBondAllowanceChange(callerVault, oldAllowance, amount); updateRetentionRate(); } @@ -406,7 +449,8 @@ contract SecurityPool is ISecurityPool { require(repAmount > 0, 'no redeemable rep'); securityVaults[vault].poolOwnership = 0; poolOwnershipDenominator -= ownershipToRedeem; - repToken.transfer(vault, repAmount); + _syncActiveVault(vault); + IERC20(address(repToken)).safeTransfer(vault, repAmount); emit RedeemRep(msg.sender, vault, repAmount); } @@ -437,6 +481,7 @@ contract SecurityPool is ISecurityPool { totalOriginalDepositAmount += originalDepositAmount; } _applyForkedEscalationSettlement(beneficiaryVault, totalAmountToWithdraw, totalOriginalDepositAmount); + _syncActiveVault(beneficiaryVault); } //////////////////////////////////////// @@ -448,7 +493,7 @@ contract SecurityPool is ISecurityPool { if (address(escalationGame) == address(0x0)) { uint256 endTime = questionData.getQuestionEndDate(questionId); require(block.timestamp > endTime, 'question active'); - escalationGame = escalationGameFactory.deployEscalationGame(TODO_INITIAL_ESCALATION_GAME_DEPOSIT, zoltar.getForkThreshold(universeId) / 2); + escalationGame = escalationGameFactory.deployEscalationGame(initialEscalationGameDeposit, zoltar.getForkThreshold(universeId) / 2); } else { require(!escalationGame.forkContinuation() || escalationGame.forkContinuationResumed(), 'fork continuation not resumed'); } @@ -456,6 +501,7 @@ contract SecurityPool is ISecurityPool { securityVaults[msg.sender].lockedRepInEscalationGame += depositedAmount; totalLockedRepInEscalationGame += depositedAmount; require(poolOwnershipToRep(securityVaults[msg.sender].poolOwnership) >= securityVaults[msg.sender].lockedRepInEscalationGame, 'rep too low'); + _syncActiveVault(msg.sender); } function withdrawFromEscalationGame(BinaryOutcomes.BinaryOutcome outcome, uint256[] memory depositIndexes) external { @@ -490,13 +536,14 @@ contract SecurityPool is ISecurityPool { } else if (totalAmountToWithdraw < totalOriginalDepositAmount) { securityVaults[beneficiaryVault].poolOwnership -= repToPoolOwnership(totalOriginalDepositAmount - totalAmountToWithdraw); } + _syncActiveVault(beneficiaryVault); } function activateForkMode() external onlyForker { systemState = SystemState.PoolForked; updateCollateralAmount(); currentRetentionRate = 0; - repToken.transfer(msg.sender, repToken.balanceOf(address(this))); + IERC20(address(repToken)).safeTransfer(msg.sender, repToken.balanceOf(address(this))); } function initializeForkedEscalationGame(uint256 startBond, uint256 nonDecisionThreshold, uint256 elapsedAtFork) external onlyForker { @@ -538,6 +585,7 @@ contract SecurityPool is ISecurityPool { securityVaults[vault].poolOwnership = poolOwnership; securityVaults[vault].securityBondAllowance = securityBondAllowance; securityVaults[vault].feeIndex = vaultFeeIndex; + _syncActiveVault(vault); } function addEscalationLockForForkMigration(address vault, uint256 repAmount) external onlyForker { @@ -546,6 +594,7 @@ contract SecurityPool is ISecurityPool { _trackVault(vault); securityVaults[vault].lockedRepInEscalationGame += repAmount; totalLockedRepInEscalationGame += repAmount; + _syncActiveVault(vault); } function clearEscalationLockForForkMigration(address vault, uint256 repAmount) external onlyForker { @@ -553,6 +602,7 @@ contract SecurityPool is ISecurityPool { require(totalLockedRepInEscalationGame >= repAmount, 'total locked low'); securityVaults[vault].lockedRepInEscalationGame -= repAmount; totalLockedRepInEscalationGame -= repAmount; + _syncActiveVault(vault); } function _applyForkedEscalationSettlement(address beneficiaryVault, uint256 totalAmountToWithdraw, uint256 totalOriginalDepositAmount) private { @@ -571,6 +621,54 @@ contract SecurityPool is ISecurityPool { vaultIndexesPlusOne[vault] = vaults.length; } + function _syncActiveVault(address vault) private { + if (vault == address(0x0)) return; + bool shouldBeActive = + securityVaults[vault].poolOwnership > 0 || + securityVaults[vault].securityBondAllowance > 0 || + securityVaults[vault].unpaidEthFees > 0 || + securityVaults[vault].lockedRepInEscalationGame > 0; + if (shouldBeActive) { + if (isActiveVault[vault]) { + if (latestActiveVault == vault) return; + _detachActiveVault(vault); + _appendActiveVault(vault); + return; + } + isActiveVault[vault] = true; + activeVaultCount++; + _appendActiveVault(vault); + return; + } + if (!isActiveVault[vault]) return; + _detachActiveVault(vault); + delete isActiveVault[vault]; + activeVaultCount--; + } + + function _appendActiveVault(address vault) private { + if (latestActiveVault != address(0x0)) { + olderActiveVaults[vault] = latestActiveVault; + newerActiveVaults[latestActiveVault] = vault; + } + latestActiveVault = vault; + } + + function _detachActiveVault(address vault) private { + address olderVault = olderActiveVaults[vault]; + address newerVault = newerActiveVaults[vault]; + if (newerVault != address(0x0)) { + olderActiveVaults[newerVault] = olderVault; + } else { + latestActiveVault = olderVault; + } + if (olderVault != address(0x0)) { + newerActiveVaults[olderVault] = newerVault; + } + delete olderActiveVaults[vault]; + delete newerActiveVaults[vault]; + } + function setOwnershipDenominator(uint256 newDenominator) external onlyForker { poolOwnershipDenominator = newDenominator; } @@ -586,7 +684,7 @@ contract SecurityPool is ISecurityPool { } function drainAllRep() external onlyForker { - repToken.transfer(msg.sender, repToken.balanceOf(address(this))); + IERC20(address(repToken)).safeTransfer(msg.sender, repToken.balanceOf(address(this))); } function transferEth(address payable receiver, uint256 amount) external onlyForker { diff --git a/solidity/contracts/peripherals/SecurityPoolForker.sol b/solidity/contracts/peripherals/SecurityPoolForker.sol index f7cfd7e5..b4dadf1e 100644 --- a/solidity/contracts/peripherals/SecurityPoolForker.sol +++ b/solidity/contracts/peripherals/SecurityPoolForker.sol @@ -1,7 +1,9 @@ // SPDX-License-Identifier: Unlicense pragma solidity 0.8.35; +import { IERC20 } from '../IERC20.sol'; import { ReputationToken } from '../ReputationToken.sol'; +import { SafeERC20Ops } from '../SafeERC20Ops.sol'; import { Zoltar } from '../Zoltar.sol'; import { IUniformPriceDualCapBatchAuction } from './interfaces/IUniformPriceDualCapBatchAuction.sol'; import { UniformPriceDualCapBatchAuction } from './UniformPriceDualCapBatchAuction.sol'; @@ -32,6 +34,8 @@ struct ForkData { } contract SecurityPoolForker is ISecurityPoolForker { + using SafeERC20Ops for IERC20; + uint256 constant ESCALATION_TIME_LENGTH = 4233600; // 7 weeks Zoltar public immutable zoltar; address private immutable vaultMigrationDelegate; @@ -301,7 +305,7 @@ contract SecurityPoolForker is ISecurityPoolForker { uint256 previousMigrationBalance = zoltar.getMigrationRepBalance(address(migrationProxy), universe); uint256 repBalanceAfter = rep.balanceOf(address(this)); uint256 repToLock = repBalanceAfter - repBalanceBefore; - if (repToLock > 0) rep.transfer(address(migrationProxy), repToLock); + if (repToLock > 0) IERC20(address(rep)).safeTransfer(address(migrationProxy), repToLock); uint256 proxyRepBalance = rep.balanceOf(address(migrationProxy)); if (proxyRepBalance > 0) migrationProxy.lockRep(proxyRepBalance); data.repAtFork = previousMigrationBalance + proxyRepBalance; @@ -438,7 +442,7 @@ contract SecurityPoolForker is ISecurityPoolForker { SecurityPoolMigrationProxy migrationProxy = _getOrDeployMigrationProxy(securityPool); uint256 repBalanceAfter = rep.balanceOf(address(this)); uint256 repToFork = repBalanceAfter - repBalanceBefore; - if (repToFork > 0) rep.transfer(address(migrationProxy), repToFork); + if (repToFork > 0) IERC20(address(rep)).safeTransfer(address(migrationProxy), repToFork); migrationProxy.forkUniverse(securityPool.questionId()); initiateSecurityPoolFork(securityPool); } diff --git a/solidity/contracts/peripherals/SecurityPoolMigrationProxy.sol b/solidity/contracts/peripherals/SecurityPoolMigrationProxy.sol index e659c072..40e73f93 100644 --- a/solidity/contracts/peripherals/SecurityPoolMigrationProxy.sol +++ b/solidity/contracts/peripherals/SecurityPoolMigrationProxy.sol @@ -1,13 +1,17 @@ // SPDX-License-Identifier: Unlicense pragma solidity 0.8.35; +import { IERC20 } from '../IERC20.sol'; import { ReputationToken } from '../ReputationToken.sol'; +import { SafeERC20Ops } from '../SafeERC20Ops.sol'; import { Zoltar } from '../Zoltar.sol'; // Thin pool-specific adapter around Zoltar. Its only purpose is to give one // parent security pool one stable caller identity when interacting with // Zoltar's migration ledger, which is keyed by `msg.sender`. contract SecurityPoolMigrationProxy { + using SafeERC20Ops for IERC20; + Zoltar public immutable zoltar; ReputationToken public immutable parentRepToken; uint248 public immutable universeId; @@ -18,7 +22,7 @@ contract SecurityPoolMigrationProxy { parentRepToken = _parentRepToken; universeId = _universeId; owner = _owner; - _parentRepToken.approve(address(_zoltar), type(uint256).max); + IERC20(address(_parentRepToken)).safeApprove(address(_zoltar), type(uint256).max); } modifier onlyOwner { @@ -43,6 +47,6 @@ contract SecurityPoolMigrationProxy { } function sweepChildRep(address receiver, ReputationToken childRepToken, uint256 amount) external onlyOwner { - childRepToken.transfer(receiver, amount); + IERC20(address(childRepToken)).safeTransfer(receiver, amount); } } diff --git a/solidity/contracts/peripherals/SecurityPoolOracleCoordinator.sol b/solidity/contracts/peripherals/SecurityPoolOracleCoordinator.sol index e1ebfaef..774f6c48 100644 --- a/solidity/contracts/peripherals/SecurityPoolOracleCoordinator.sol +++ b/solidity/contracts/peripherals/SecurityPoolOracleCoordinator.sol @@ -56,8 +56,15 @@ contract SecurityPoolOracleCoordinator { // This is not a FIFO queue. We keep an append-only operation record and a single // pending slot that settlement can auto-execute once a fresh oracle price arrives. + // Active-operation paging is newest-first so UI previews remain stable after manual + // execution removes older entries from the set. uint256 public stagedOperationCounter; mapping(uint256 => StagedOperation) public stagedOperations; + uint256 private activeStagedOperationCount; + uint256 private latestActiveStagedOperationId; + mapping(uint256 => uint256) private olderActiveStagedOperationIds; + mapping(uint256 => uint256) private newerActiveStagedOperationIds; + mapping(uint256 => bool) private isActiveStagedOperation; constructor( OpenOracle _openOracle, @@ -181,18 +188,19 @@ contract SecurityPoolOracleCoordinator { (uint256 snapshotTargetOwnership, uint256 snapshotTargetAllowance, , , ) = securityPool.securityVaults(targetVault); uint256 snapshotTotalRep = securityPool.getTotalRepBalance(); uint256 snapshotDenominator = securityPool.poolOwnershipDenominator(); - stagedOperations[operationId] = StagedOperation({ - operation: operation, - initiatorVault: msg.sender, + stagedOperations[operationId] = StagedOperation({ + operation: operation, + initiatorVault: msg.sender, targetVault: targetVault, amount: amount, queuedAt: block.timestamp, validForSeconds: validForSeconds, snapshotTargetOwnership: snapshotTargetOwnership, snapshotTargetAllowance: snapshotTargetAllowance, - snapshotTotalRep: snapshotTotalRep, - snapshotDenominator: snapshotDenominator - }); + snapshotTotalRep: snapshotTotalRep, + snapshotDenominator: snapshotDenominator + }); + _trackActiveStagedOperation(operationId); uint256 retained = 0; // amount to retain from msg.value (cost incurred) @@ -229,6 +237,7 @@ contract SecurityPoolOracleCoordinator { require(stagedOperation.initiatorVault != address(0), 'no such operation'); require(isPriceValid(), 'price is not valid to execute'); require(block.timestamp <= stagedOperation.queuedAt + settlementTime + stagedOperation.validForSeconds, 'staged operation expired'); + _consumeActiveStagedOperation(operationId); stagedOperations[operationId].initiatorVault = address(0); if (stagedOperation.operation == OperationType.Liquidation) { try @@ -280,4 +289,56 @@ contract SecurityPoolOracleCoordinator { function getPendingOperationSlot() public view returns (StagedOperation memory) { return stagedOperations[pendingOperationSlotId]; } + + function getActiveStagedOperationCount() public view returns (uint256) { + return activeStagedOperationCount; + } + + function getActiveStagedOperations(uint256 startIndex, uint256 count) public view returns (uint256[] memory operationIds, StagedOperation[] memory operations) { + if (count == 0 || startIndex >= activeStagedOperationCount) { + return (new uint256[](0), new StagedOperation[](0)); + } + uint256 availableCount = activeStagedOperationCount - startIndex; + uint256 resultCount = count < availableCount ? count : availableCount; + operationIds = new uint256[](resultCount); + operations = new StagedOperation[](resultCount); + uint256 operationId = latestActiveStagedOperationId; + for (uint256 skipped = 0; skipped < startIndex && operationId != 0; skipped++) { + operationId = olderActiveStagedOperationIds[operationId]; + } + for (uint256 index = 0; index < resultCount && operationId != 0; index++) { + operationIds[index] = operationId; + operations[index] = stagedOperations[operationId]; + operationId = olderActiveStagedOperationIds[operationId]; + } + } + + function _trackActiveStagedOperation(uint256 operationId) private { + if (isActiveStagedOperation[operationId]) return; + isActiveStagedOperation[operationId] = true; + activeStagedOperationCount++; + if (latestActiveStagedOperationId != 0) { + olderActiveStagedOperationIds[operationId] = latestActiveStagedOperationId; + newerActiveStagedOperationIds[latestActiveStagedOperationId] = operationId; + } + latestActiveStagedOperationId = operationId; + } + + function _consumeActiveStagedOperation(uint256 operationId) private { + if (!isActiveStagedOperation[operationId]) return; + uint256 olderOperationId = olderActiveStagedOperationIds[operationId]; + uint256 newerOperationId = newerActiveStagedOperationIds[operationId]; + if (newerOperationId != 0) { + olderActiveStagedOperationIds[newerOperationId] = olderOperationId; + } else { + latestActiveStagedOperationId = olderOperationId; + } + if (olderOperationId != 0) { + newerActiveStagedOperationIds[olderOperationId] = newerOperationId; + } + delete olderActiveStagedOperationIds[operationId]; + delete newerActiveStagedOperationIds[operationId]; + delete isActiveStagedOperation[operationId]; + activeStagedOperationCount--; + } } diff --git a/solidity/contracts/peripherals/factories/SecurityPoolDeployer.sol b/solidity/contracts/peripherals/factories/SecurityPoolDeployer.sol index 1dd4da3e..9cb67a33 100644 --- a/solidity/contracts/peripherals/factories/SecurityPoolDeployer.sol +++ b/solidity/contracts/peripherals/factories/SecurityPoolDeployer.sol @@ -30,6 +30,7 @@ contract SecurityPoolDeployer { uint248 universeId, uint256 questionId, uint256 securityMultiplier, + uint256 initialEscalationGameDeposit, address truthAuction ) external returns (ISecurityPool securityPool) { require(msg.sender == factory, 'only factory'); @@ -47,6 +48,7 @@ contract SecurityPoolDeployer { universeId, questionId, securityMultiplier, + initialEscalationGameDeposit, truthAuction )))); } diff --git a/solidity/contracts/peripherals/factories/SecurityPoolFactory.sol b/solidity/contracts/peripherals/factories/SecurityPoolFactory.sol index 54b0d45b..3786e02d 100644 --- a/solidity/contracts/peripherals/factories/SecurityPoolFactory.sol +++ b/solidity/contracts/peripherals/factories/SecurityPoolFactory.sol @@ -26,11 +26,13 @@ contract SecurityPoolFactory is ISecurityPoolFactory { ZoltarQuestionData questionData; ISecurityPoolForker securityPoolForker; SecurityPoolDeployer securityPoolDeployer; + uint256 public immutable initialEscalationGameDeposit; SecurityPoolDeployment[] private securityPoolDeployments; event DeploySecurityPool(ISecurityPool securityPool, UniformPriceDualCapBatchAuction truthAuction, SecurityPoolOracleCoordinator priceOracleManagerAndOperatorQueuer, IShareToken shareToken, ISecurityPool parent, uint248 universeId, uint256 questionId, uint256 securityMultiplier, uint256 currentRetentionRate, uint256 completeSetCollateralAmount); - constructor(ISecurityPoolForker _securityPoolForker, ZoltarQuestionData _questionData, EscalationGameFactory _escalationGameFactory, OpenOracle _openOracle, Zoltar _zoltar, ShareTokenFactory _shareTokenFactory, UniformPriceDualCapBatchAuctionFactory _uniformPriceDualCapBatchAuctionFactory, PriceOracleManagerAndOperatorQueuerFactory _priceOracleManagerAndOperatorQueuerFactory) { + constructor(ISecurityPoolForker _securityPoolForker, ZoltarQuestionData _questionData, EscalationGameFactory _escalationGameFactory, OpenOracle _openOracle, Zoltar _zoltar, ShareTokenFactory _shareTokenFactory, UniformPriceDualCapBatchAuctionFactory _uniformPriceDualCapBatchAuctionFactory, PriceOracleManagerAndOperatorQueuerFactory _priceOracleManagerAndOperatorQueuerFactory, uint256 _initialEscalationGameDeposit) { + require(_initialEscalationGameDeposit > 0, 'initial escalation deposit'); securityPoolForker = _securityPoolForker; shareTokenFactory = _shareTokenFactory; uniformPriceDualCapBatchAuctionFactory = _uniformPriceDualCapBatchAuctionFactory; @@ -39,6 +41,7 @@ contract SecurityPoolFactory is ISecurityPoolFactory { openOracle = _openOracle; escalationGameFactory = _escalationGameFactory; questionData = _questionData; + initialEscalationGameDeposit = _initialEscalationGameDeposit; securityPoolDeployer = new SecurityPoolDeployer(); } @@ -137,6 +140,7 @@ contract SecurityPoolFactory is ISecurityPoolFactory { universeId, questionId, securityMultiplier, + initialEscalationGameDeposit, truthAuction ); diff --git a/solidity/contracts/peripherals/interfaces/ISecurityPool.sol b/solidity/contracts/peripherals/interfaces/ISecurityPool.sol index 72689554..5bebc390 100644 --- a/solidity/contracts/peripherals/interfaces/ISecurityPool.sol +++ b/solidity/contracts/peripherals/interfaces/ISecurityPool.sol @@ -49,6 +49,8 @@ interface ISecurityPool { function securityVaults(address vault) external view returns (uint256 poolOwnership, uint256 securityBondAllowance, uint256 unpaidEthFees, uint256 feeIndex, uint256 lockedRepInEscalationGame); function getVaultCount() external view returns (uint256); function getVaults(uint256 startIndex, uint256 count) external view returns (address[] memory vaults); + function getActiveVaultCount() external view returns (uint256); + function getActiveVaults(uint256 startIndex, uint256 count) external view returns (address[] memory vaults); function parent() external view returns (ISecurityPool); function systemState() external view returns (SystemState); function shareToken() external view returns (IShareToken); @@ -66,7 +68,7 @@ interface ISecurityPool { function getAvailableRepBalance() external view returns (uint256); function getTotalRepBalance() external view returns (uint256); function isEscalationResolved() external view returns (bool); - function initialEscalationGameDeposit() external pure returns (uint256); + function initialEscalationGameDeposit() external view returns (uint256); function setStartingParams(uint256 currentRetentionRate, uint256 completeSetCollateralAmount) external; diff --git a/solidity/contracts/peripherals/test/FalseReturningERC20.sol b/solidity/contracts/peripherals/test/FalseReturningERC20.sol new file mode 100644 index 00000000..8357e7ab --- /dev/null +++ b/solidity/contracts/peripherals/test/FalseReturningERC20.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity 0.8.35; + +import { IERC20 } from '../../IERC20.sol'; + +contract FalseReturningERC20 is IERC20 { + function totalSupply() external pure returns (uint256) { + return 0; + } + + function balanceOf(address) external pure returns (uint256) { + return 0; + } + + function transfer(address, uint256) external pure returns (bool) { + return false; + } + + function allowance(address, address) external pure returns (uint256) { + return 0; + } + + function approve(address, uint256) external pure returns (bool) { + return false; + } + + function transferFrom(address, address, uint256) external pure returns (bool) { + return false; + } +} diff --git a/solidity/contracts/peripherals/test/SafeERC20OpsHarness.sol b/solidity/contracts/peripherals/test/SafeERC20OpsHarness.sol new file mode 100644 index 00000000..2aca8400 --- /dev/null +++ b/solidity/contracts/peripherals/test/SafeERC20OpsHarness.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity 0.8.35; + +import { IERC20 } from '../../IERC20.sol'; +import { SafeERC20Ops } from '../../SafeERC20Ops.sol'; + +contract SafeERC20OpsHarness { + using SafeERC20Ops for IERC20; + + function safeApproveToken(IERC20 token, address spender, uint256 amount) external { + token.safeApprove(spender, amount); + } + + function safeTransferToken(IERC20 token, address receiver, uint256 amount) external { + token.safeTransfer(receiver, amount); + } + + function safeTransferFromToken(IERC20 token, address sender, address receiver, uint256 amount) external { + token.safeTransferFrom(sender, receiver, amount); + } +} diff --git a/solidity/ts/tests/escalationGame_forkThreshold.test.ts b/solidity/ts/tests/escalationGame_forkThreshold.test.ts index c73ac128..b88e8c76 100644 --- a/solidity/ts/tests/escalationGame_forkThreshold.test.ts +++ b/solidity/ts/tests/escalationGame_forkThreshold.test.ts @@ -1,5 +1,6 @@ import { test, beforeEach, describe, setDefaultTimeout } from 'bun:test' import { encodeAbiParameters, keccak256, type Address } from 'viem' +import { DEFAULT_PROTOCOL_CONFIG } from '@zoltar/shared/protocolConfig' import { AnvilWindowEthereum } from '../testsuite/simulator/AnvilWindowEthereum' import { TEST_TIMEOUT_MS, useIsolatedAnvilNode } from '../testsuite/simulator/useIsolatedAnvilNode' import { createWriteClient, WriteClient, writeContractAndWait } from '../testsuite/simulator/utils/viem' @@ -20,7 +21,6 @@ import { peripherals_SecurityPool_SecurityPool } from '../types/contractArtifact const DAY = 86400n const MAX_RETENTION_RATE = 999_999_996_848_000_000n // ≈90% yearly -const FORK_THRESHOLD_DIVISOR = 20n const ZOLTAR_UNIVERSE_THEORETICAL_SUPPLIES_SLOT = 2n setDefaultTimeout(TEST_TIMEOUT_MS) @@ -89,7 +89,7 @@ describe('Escalation Game Fork Threshold Test', () => { const initialTotalSupply = await getTotalTheoreticalSupply(client, repToken) // Ensure initial fork threshold > escalationThreshold (should be twice) - const initialForkThreshold = initialTotalSupply / FORK_THRESHOLD_DIVISOR + const initialForkThreshold = initialTotalSupply / DEFAULT_PROTOCOL_CONFIG.forkThresholdDivisor assert.ok(initialForkThreshold > escalationThreshold, 'initial fork threshold must be greater than escalation threshold') // Lower the tracked universe theoretical supply to make actual fork threshold less than escalationThreshold @@ -103,7 +103,7 @@ describe('Escalation Game Fork Threshold Test', () => { }, }) - const actualForkThreshold = newTotalSupply / FORK_THRESHOLD_DIVISOR + const actualForkThreshold = newTotalSupply / DEFAULT_PROTOCOL_CONFIG.forkThresholdDivisor assert.ok(actualForkThreshold < escalationThreshold, 'actual fork threshold should be lower after override') // Advance time to allow the escalation game to finish and outcome to be known @@ -135,7 +135,7 @@ describe('Escalation Game Fork Threshold Test', () => { const repToken = getRepTokenAddress(genesisUniverse) const initialTotalSupply = await getTotalTheoreticalSupply(client, repToken) const overriddenTotalSupply = initialTotalSupply / 10n - const expectedThreshold = overriddenTotalSupply / FORK_THRESHOLD_DIVISOR / 2n + const expectedThreshold = overriddenTotalSupply / DEFAULT_PROTOCOL_CONFIG.forkThresholdDivisor / 2n const universeSupplySlot = keccak256(encodeAbiParameters([{ type: 'uint248' }, { type: 'uint256' }], [genesisUniverse, ZOLTAR_UNIVERSE_THEORETICAL_SUPPLIES_SLOT])) await mockWindow.addStateOverrides({ @@ -149,6 +149,16 @@ describe('Escalation Game Fork Threshold Test', () => { await mockWindow.setTime(questionEndDate + 1n) await depositToEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, depositAmount) + assert.strictEqual( + await client.readContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + address: securityPoolAddresses.securityPool, + functionName: 'initialEscalationGameDeposit', + args: [], + }), + DEFAULT_PROTOCOL_CONFIG.initialEscalationGameDeposit, + 'initial escalation deposit should match deployment config', + ) assert.strictEqual(await getNonDecisionThreshold(client, securityPoolAddresses.escalationGame), expectedThreshold, 'escalation threshold should follow Zoltar tracked supply') }) }) diff --git a/solidity/ts/tests/peripherals.test.ts b/solidity/ts/tests/peripherals.test.ts index 0e4a5731..9613728e 100644 --- a/solidity/ts/tests/peripherals.test.ts +++ b/solidity/ts/tests/peripherals.test.ts @@ -48,6 +48,8 @@ import { getRepToken, getShareTokenSupply, getAwaitingForkContinuation, + getActiveVaultCount, + getActiveVaults, getSecurityPoolsEscalationGame, getSecurityVault, getSystemState, @@ -451,6 +453,46 @@ describe('Peripherals Contract Test Suite', () => { assert.deepStrictEqual(emptyPage, [], 'out of range paging should return an empty array') }) + test('active vault paging excludes zero-balance historical vaults', async () => { + const attackerClient = createWriteClient(mockWindow, TEST_ADDRESSES[1], 0) + + await approveAndDepositRep(attackerClient, repDeposit, questionId) + + strictEqualTypeSafe(await getVaultCount(client, securityPoolAddresses.securityPool), 2n, 'historical vault count should include both vaults') + strictEqualTypeSafe(await getActiveVaultCount(client, securityPoolAddresses.securityPool), 2n, 'active vault count should include both funded vaults') + + await manipulatePriceOracleAndPerformOperation(attackerClient, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.WithdrawRep, attackerClient.account.address, repDeposit, reportedRepEthPrice) + + const historicalVaultCount = await getVaultCount(client, securityPoolAddresses.securityPool) + const activeVaultCount = await getActiveVaultCount(client, securityPoolAddresses.securityPool) + const activeVaults = await getActiveVaults(client, securityPoolAddresses.securityPool, 0n, activeVaultCount) + + strictEqualTypeSafe(historicalVaultCount, 2n, 'historical vault count should remain append only') + strictEqualTypeSafe(activeVaultCount, 1n, 'active vault count should prune fully exited vaults') + assert.deepStrictEqual(activeVaults, [client.account.address], 'active vault paging should only return currently active vaults') + }) + + test('active vault paging stays newest-first after vault removal and later vault updates', async () => { + const attackerClient = createWriteClient(mockWindow, TEST_ADDRESSES[1], 0) + const thirdClient = createWriteClient(mockWindow, TEST_ADDRESSES[2], 0) + + await approveAndDepositRep(attackerClient, repDeposit, questionId) + await approveAndDepositRep(thirdClient, repDeposit, questionId) + + const newestFirstVaultsBeforeRemoval = await getActiveVaults(client, securityPoolAddresses.securityPool, 0n, 3n) + assert.deepStrictEqual(newestFirstVaultsBeforeRemoval, [thirdClient.account.address, attackerClient.account.address, client.account.address], 'active vault paging should list the most recently activated vaults first') + + await manipulatePriceOracleAndPerformOperation(attackerClient, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.WithdrawRep, attackerClient.account.address, repDeposit, reportedRepEthPrice) + + const newestFirstVaultsAfterRemoval = await getActiveVaults(client, securityPoolAddresses.securityPool, 0n, 3n) + assert.deepStrictEqual(newestFirstVaultsAfterRemoval, [thirdClient.account.address, client.account.address], 'removing a middle vault should preserve newest-first ordering for the remaining active vaults') + + await updateVaultFees(client, securityPoolAddresses.securityPool, client.account.address) + + const newestFirstVaultsAfterTouch = await getActiveVaults(client, securityPoolAddresses.securityPool, 0n, 3n) + assert.deepStrictEqual(newestFirstVaultsAfterTouch, [client.account.address, thirdClient.account.address], 'updating an active vault should move it to the front of the newest-first active vault preview') + }) + test('withdrawal after question end releases escalation lock without changing ownership in single-sided case', async () => { if (process.env.RUN_KNOWN_FAILURE_REPROS !== '1') return await manipulatePriceOracle(client, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer) diff --git a/solidity/ts/tests/priceOracleSecurity.test.ts b/solidity/ts/tests/priceOracleSecurity.test.ts index fa8dbcfc..84783f44 100644 --- a/solidity/ts/tests/priceOracleSecurity.test.ts +++ b/solidity/ts/tests/priceOracleSecurity.test.ts @@ -171,10 +171,12 @@ describe('Price Oracle Refund Security Tests', () => { ) }) - test('newer self operations do not replace an existing pending slot and stay manually executable', async () => { + test('active staged operations stay newest-first after pending-slot settlement and manual execution', async () => { const ethCost = await getRequestPriceEthCost(client, priceOracle) const firstAllowance = repDeposit / 4n const secondAllowance = repDeposit / 5n + const thirdAllowance = repDeposit / 6n + const fourthAllowance = repDeposit / 7n await writeContractAndWait( client, @@ -197,6 +199,26 @@ describe('Price Oracle Refund Security Tests', () => { args: [OperationType.SetSecurityBondsAllowance, client.account.address, secondAllowance, DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS], }), ) + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'requestPriceIfNeededAndStageOperation', + args: [OperationType.SetSecurityBondsAllowance, client.account.address, thirdAllowance, DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS], + }), + ) + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'requestPriceIfNeededAndStageOperation', + args: [OperationType.SetSecurityBondsAllowance, client.account.address, fourthAllowance, DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS], + }), + ) const pendingOperationSlotId = await client.readContract({ abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, @@ -204,7 +226,25 @@ describe('Price Oracle Refund Security Tests', () => { functionName: 'pendingOperationSlotId', args: [], }) + const activeStagedOperationCount = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'getActiveStagedOperationCount', + args: [], + }) + const [operationIds, activeOperations] = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'getActiveStagedOperations', + args: [0n, 4n], + }) assert.strictEqual(pendingOperationSlotId, 1n, 'first queued self operation should keep the auto-execute slot') + assert.strictEqual(activeStagedOperationCount, 4n, 'active staged operation count should track pending and manual operations') + assert.deepStrictEqual(Array.from(operationIds), [4n, 3n, 2n, 1n], 'active staged operations should enumerate newest queued operations first') + assert.strictEqual(activeOperations[0]?.amount, fourthAllowance, 'newest enumerated operation should retain its amount') + assert.strictEqual(activeOperations[1]?.amount, thirdAllowance, 'second newest enumerated operation should retain its amount') + assert.strictEqual(activeOperations[2]?.amount, secondAllowance, 'third newest enumerated operation should retain its amount') + assert.strictEqual(activeOperations[3]?.amount, firstAllowance, 'oldest enumerated operation should retain its amount') await handleOracleReporting(client, mockWindow, priceOracle, 10n ** 18n) await writeContractAndWait( @@ -214,9 +254,21 @@ describe('Price Oracle Refund Security Tests', () => { abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, address: priceOracle, functionName: 'executeStagedOperation', - args: [2n], + args: [3n], }), ) + const updatedActiveStagedOperationCount = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'getActiveStagedOperationCount', + args: [], + }) + const [remainingOperationIds, remainingOperations] = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'getActiveStagedOperations', + args: [0n, 4n], + }) const stagedOperation1 = await client.readContract({ abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, @@ -230,10 +282,27 @@ describe('Price Oracle Refund Security Tests', () => { functionName: 'stagedOperations', args: [2n], }) + const stagedOperation3 = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'stagedOperations', + args: [3n], + }) + const stagedOperation4 = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + address: priceOracle, + functionName: 'stagedOperations', + args: [4n], + }) assert.strictEqual(stagedOperation1[1], zeroAddress, 'pending-slot operation should be consumed after the oracle settles it') - assert.strictEqual(stagedOperation2[1], zeroAddress, 'manually executed operations should be consumed after success') - assert.strictEqual(stagedOperation2[3], secondAllowance, 'later self operation should retain its requested amount until manual execution') - assert.strictEqual(stagedOperation2[2], client.account.address, 'later self operation should still target the initiator vault') + assert.strictEqual(stagedOperation2[1], client.account.address, 'older still-active operations should remain staged after newer manual execution') + assert.strictEqual(stagedOperation3[1], zeroAddress, 'manually executed middle operations should be consumed after success') + assert.strictEqual(stagedOperation4[1], client.account.address, 'newest operations should remain active when older manual operations are consumed') + assert.strictEqual(stagedOperation4[3], fourthAllowance, 'newest operations should retain their requested amount until execution') + assert.strictEqual(updatedActiveStagedOperationCount, 2n, 'active staged operation count should shrink as operations are consumed') + assert.deepStrictEqual(Array.from(remainingOperationIds), [4n, 2n], 'active staged operations should stay newest first after middle entries are consumed') + assert.strictEqual(remainingOperations[0]?.amount, fourthAllowance, 'remaining newest operation should stay first in the preview') + assert.strictEqual(remainingOperations[1]?.amount, secondAllowance, 'older remaining operation should stay second in the preview') }) test('staged operations can only be executed once', async () => { diff --git a/solidity/ts/tests/safeErc20.test.ts b/solidity/ts/tests/safeErc20.test.ts new file mode 100644 index 00000000..f4751698 --- /dev/null +++ b/solidity/ts/tests/safeErc20.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, setDefaultTimeout, test } from 'bun:test' +import assert from 'node:assert/strict' +import { encodeDeployData, type Hex } from 'viem' +import { AnvilWindowEthereum } from '../testsuite/simulator/AnvilWindowEthereum' +import { TEST_TIMEOUT_MS, useIsolatedAnvilNode } from '../testsuite/simulator/useIsolatedAnvilNode' +import { TEST_ADDRESSES } from '../testsuite/simulator/utils/constants' +import { setupTestAccounts } from '../testsuite/simulator/utils/utilities' +import { createWriteClient, type WriteClient, writeContractAndWait } from '../testsuite/simulator/utils/viem' +import { peripherals_SecurityPoolMigrationProxy_SecurityPoolMigrationProxy, peripherals_test_FalseReturningERC20_FalseReturningERC20, peripherals_test_SafeERC20OpsHarness_SafeERC20OpsHarness } from '../types/contractArtifact' + +setDefaultTimeout(TEST_TIMEOUT_MS) + +describe('Safe ERC20 Operations', () => { + const { getAnvilWindowEthereum } = useIsolatedAnvilNode() + let mockWindow: AnvilWindowEthereum + let client: WriteClient + + const deployContract = async (deploymentData: Hex) => { + const hash = await client.sendTransaction({ data: deploymentData }) + const receipt = await client.waitForTransactionReceipt({ hash }) + const contractAddress = receipt.contractAddress + if (contractAddress === undefined || contractAddress === null) throw new Error('deployment address missing') + return contractAddress + } + + const deployFalseReturningToken = async () => + await deployContract( + encodeDeployData({ + abi: peripherals_test_FalseReturningERC20_FalseReturningERC20.abi, + bytecode: `0x${peripherals_test_FalseReturningERC20_FalseReturningERC20.evm.bytecode.object}`, + }), + ) + + const deployHarness = async () => + await deployContract( + encodeDeployData({ + abi: peripherals_test_SafeERC20OpsHarness_SafeERC20OpsHarness.abi, + bytecode: `0x${peripherals_test_SafeERC20OpsHarness_SafeERC20OpsHarness.evm.bytecode.object}`, + }), + ) + + beforeEach(async () => { + mockWindow = getAnvilWindowEthereum() + client = createWriteClient(mockWindow, TEST_ADDRESSES[0], 0) + await setupTestAccounts(mockWindow) + }) + + test('safe helper wrappers reject false-returning ERC20 calls', async () => { + const falseToken = await deployFalseReturningToken() + const harness = await deployHarness() + const receiver = client.account.address + if (receiver === null) throw new Error('receiver address missing') + + await assert.rejects( + writeContractAndWait(client, () => + client.writeContract({ + abi: peripherals_test_SafeERC20OpsHarness_SafeERC20OpsHarness.abi, + address: harness, + functionName: 'safeApproveToken', + args: [falseToken, receiver, 1n], + }), + ), + /token returned false/i, + ) + await assert.rejects( + writeContractAndWait(client, () => + client.writeContract({ + abi: peripherals_test_SafeERC20OpsHarness_SafeERC20OpsHarness.abi, + address: harness, + functionName: 'safeTransferToken', + args: [falseToken, receiver, 1n], + }), + ), + /token returned false/i, + ) + await assert.rejects( + writeContractAndWait(client, () => + client.writeContract({ + abi: peripherals_test_SafeERC20OpsHarness_SafeERC20OpsHarness.abi, + address: harness, + functionName: 'safeTransferFromToken', + args: [falseToken, receiver, receiver, 1n], + }), + ), + /token returned false/i, + ) + }) + + test('migration proxy constructor rejects false-returning approval tokens', async () => { + const falseToken = await deployFalseReturningToken() + const dummyZoltar = client.account.address + if (dummyZoltar === null) throw new Error('dummy zoltar address missing') + const owner = client.account.address + if (owner === null) throw new Error('owner address missing') + const deploymentData = encodeDeployData({ + abi: peripherals_SecurityPoolMigrationProxy_SecurityPoolMigrationProxy.abi, + bytecode: `0x${peripherals_SecurityPoolMigrationProxy_SecurityPoolMigrationProxy.evm.bytecode.object}`, + args: [dummyZoltar, falseToken, 0n, owner], + }) + + await assert.rejects( + writeContractAndWait(client, () => client.sendTransaction({ data: deploymentData })), + /token returned false/i, + ) + }) +}) diff --git a/solidity/ts/tests/zoltar.test.ts b/solidity/ts/tests/zoltar.test.ts index 07d914fa..7380919c 100644 --- a/solidity/ts/tests/zoltar.test.ts +++ b/solidity/ts/tests/zoltar.test.ts @@ -1,15 +1,32 @@ import { test, beforeEach, describe, setDefaultTimeout } from 'bun:test' import { AnvilWindowEthereum } from '../testsuite/simulator/AnvilWindowEthereum' import { TEST_TIMEOUT_MS, useIsolatedAnvilNode } from '../testsuite/simulator/useIsolatedAnvilNode' -import { createWriteClient, WriteClient } from '../testsuite/simulator/utils/viem' +import { DEFAULT_PROTOCOL_CONFIG } from '@zoltar/shared/protocolConfig' +import { createWriteClient, WriteClient, writeContractAndWait } from '../testsuite/simulator/utils/viem' import { GENESIS_REPUTATION_TOKEN, TEST_ADDRESSES } from '../testsuite/simulator/utils/constants' import { approveToken, setupTestAccounts, getERC20Balance, getChildUniverseId, contractExists, sortStringArrayByKeccak } from '../testsuite/simulator/utils/utilities' import assert from 'node:assert/strict' import { addressString } from '../testsuite/simulator/utils/bigint' -import { addRepToMigrationBalance, deployChild, ensureZoltarDeployed, forkUniverse, getMigrationRepBalance, getRepTokenAddress, getTotalTheoreticalSupply, getUniverseData, getZoltarAddress, getZoltarForkThreshold, isZoltarDeployed, splitMigrationRep } from '../testsuite/simulator/utils/contracts/zoltar' +import { encodeDeployData, hexToBytes } from 'viem' +import { + addRepToMigrationBalance, + deployChild, + ensureZoltarDeployed, + forkUniverse, + getMigrationRepBalance, + getRepTokenAddress, + getTotalTheoreticalSupply, + getUniverseData, + getZoltarAddress, + getZoltarForkBurnDivisor, + getZoltarForkThreshold, + getZoltarForkThresholdDivisor, + isZoltarDeployed, + splitMigrationRep, +} from '../testsuite/simulator/utils/contracts/zoltar' import { createQuestion, getAnswerOptionName, getQuestionId } from '../testsuite/simulator/utils/contracts/zoltarQuestionData' import { ensureDefined } from '../testsuite/simulator/utils/testUtils' -import { Zoltar_Zoltar } from '../types/contractArtifact' +import { peripherals_test_FalseReturningERC20_FalseReturningERC20, Zoltar_Zoltar } from '../types/contractArtifact' import { formatScalarOutcomeLabel, getScalarOutcomeIndex } from '../testsuite/simulator/utils/contracts/scalarOutcome' // Forker deposit fractions: deposit is 5% of total supply (1/20), and 20% of that deposit is burned (1/5 of deposit) @@ -38,6 +55,65 @@ describe('Contract Test Suite', () => { assert.strictEqual(BigInt(genesisUniverseData.reputationToken), GENESIS_REPUTATION_TOKEN, 'Genesis universe not recognized or not initialized properly') }) + test('exposes configured fork economics', async () => { + assert.strictEqual(await getZoltarForkThresholdDivisor(client), DEFAULT_PROTOCOL_CONFIG.forkThresholdDivisor, 'fork threshold divisor mismatch') + assert.strictEqual(await getZoltarForkBurnDivisor(client), DEFAULT_PROTOCOL_CONFIG.forkBurnDivisor, 'fork burn divisor mismatch') + }) + + test('constructor rejects invalid fork divisors', async () => { + const zoltarQuestionDataAddress = await client.readContract({ + abi: Zoltar_Zoltar.abi, + functionName: 'zoltarQuestionData', + address: getZoltarAddress(), + args: [], + }) + const invalidThresholdDeployment = encodeDeployData({ + abi: Zoltar_Zoltar.abi, + bytecode: `0x${Zoltar_Zoltar.evm.bytecode.object}`, + args: [zoltarQuestionDataAddress, 1n, DEFAULT_PROTOCOL_CONFIG.forkBurnDivisor], + }) + const invalidBurnDeployment = encodeDeployData({ + abi: Zoltar_Zoltar.abi, + bytecode: `0x${Zoltar_Zoltar.evm.bytecode.object}`, + args: [zoltarQuestionDataAddress, DEFAULT_PROTOCOL_CONFIG.forkThresholdDivisor, 1n], + }) + + await assert.rejects( + writeContractAndWait(client, () => client.sendTransaction({ data: invalidThresholdDeployment })), + /fork threshold divisor/i, + ) + await assert.rejects( + writeContractAndWait(client, () => client.sendTransaction({ data: invalidBurnDeployment })), + /fork burn divisor/i, + ) + }) + + test('forkUniverse rejects false-returning genesis REP transfers', async () => { + const falseReturningGenesisRep = hexToBytes(`0x${peripherals_test_FalseReturningERC20_FalseReturningERC20.evm.deployedBytecode.object}`) + if (falseReturningGenesisRep === undefined) throw new Error('false returning token bytecode missing') + const questionData = { + title: 'false-returning genesis rep fork test', + description: '', + startTime: 0n, + endTime: 0n, + numTicks: 0n, + displayValueMin: 0n, + displayValueMax: 0n, + answerUnit: '', + } + const outcomes = sortStringArrayByKeccak(['Yes', 'No']) + await createQuestion(client, questionData, outcomes) + const questionId = getQuestionId(questionData, outcomes) + + await mockWindow.addStateOverrides({ + [addressString(GENESIS_REPUTATION_TOKEN)]: { + code: falseReturningGenesisRep, + }, + }) + + await assert.rejects(forkUniverse(client, genesisUniverse, questionId), /token returned false/i) + }) + test('canForkQuestion', async () => { const client2 = createWriteClient(mockWindow, TEST_ADDRESSES[1], 0) const zoltar = getZoltarAddress() diff --git a/solidity/ts/testsuite/simulator/utils/contracts/deployPeripherals.ts b/solidity/ts/testsuite/simulator/utils/contracts/deployPeripherals.ts index 93900cb5..00f7fc41 100644 --- a/solidity/ts/testsuite/simulator/utils/contracts/deployPeripherals.ts +++ b/solidity/ts/testsuite/simulator/utils/contracts/deployPeripherals.ts @@ -2,6 +2,7 @@ import 'viem/window' import { concatHex, encodeAbiParameters, encodeDeployData, getCreate2Address, keccak256, type Address, type Hex, toHex } from 'viem' import { createSecurityPoolAddressHelper } from '@zoltar/shared/addressDerivation' import { createApplyLinkedLibrariesHelper, createDeploymentStatusOracleAddressHelper, createInfraContractAddressHelper, createZoltarAddressHelpers } from '@zoltar/shared/deploymentAddresses' +import { getProtocolConfig } from '../protocolConfig' import { WriteClient, writeContractAndWait } from '../viem' import { PROXY_DEPLOYER_ADDRESS } from '../constants' import { addressString } from '../bigint' @@ -96,11 +97,14 @@ const getSecurityPoolFactoryByteCode = ({ zoltar: Address zoltarQuestionData: Address }): Hex => - encodeDeployData({ - abi: peripherals_factories_SecurityPoolFactory_SecurityPoolFactory.abi, - bytecode: applyLibraries(peripherals_factories_SecurityPoolFactory_SecurityPoolFactory.evm.bytecode.object), - args: [securityPoolForker, zoltarQuestionData, escalationGameFactory, openOracle, zoltar, shareTokenFactory, uniformPriceDualCapBatchAuctionFactory, priceOracleManagerAndOperatorQueuerFactory], - }) + (() => { + const protocolConfig = getProtocolConfig() + return encodeDeployData({ + abi: peripherals_factories_SecurityPoolFactory_SecurityPoolFactory.abi, + bytecode: applyLibraries(peripherals_factories_SecurityPoolFactory_SecurityPoolFactory.evm.bytecode.object), + args: [securityPoolForker, zoltarQuestionData, escalationGameFactory, openOracle, zoltar, shareTokenFactory, uniformPriceDualCapBatchAuctionFactory, priceOracleManagerAndOperatorQueuerFactory, protocolConfig.initialEscalationGameDeposit], + }) + })() const getShareTokenFactoryByteCode = (zoltar: Address): Hex => encodeDeployData({ @@ -116,11 +120,14 @@ const getEscalationGameFactoryByteCode = (): Hex => }) const getZoltarInitCode = (zoltarQuestionDataAddress: Address): Hex => - encodeDeployData({ - abi: Zoltar_Zoltar.abi, - bytecode: `0x${Zoltar_Zoltar.evm.bytecode.object}`, - args: [zoltarQuestionDataAddress], - }) + (() => { + const protocolConfig = getProtocolConfig() + return encodeDeployData({ + abi: Zoltar_Zoltar.abi, + bytecode: `0x${Zoltar_Zoltar.evm.bytecode.object}`, + args: [zoltarQuestionDataAddress, protocolConfig.forkThresholdDivisor, protocolConfig.forkBurnDivisor], + }) + })() const getZoltarQuestionDataByteCode = (): Hex => encodeDeployData({ @@ -181,11 +188,14 @@ export const { getSecurityPoolAddresses } = createSecurityPoolAddressHelper({ ]), getRepTokenAddress, getSecurityPoolInitCode: ({ escalationGameFactory, openOracle, parent, priceOracleManagerAndOperatorQueuer, questionId, securityMultiplier, securityPoolFactory, securityPoolForker, shareToken, truthAuction, universeId, zoltar, zoltarQuestionData }) => - encodeDeployData({ - abi: peripherals_SecurityPool_SecurityPool.abi, - bytecode: applyLibraries(peripherals_SecurityPool_SecurityPool.evm.bytecode.object), - args: [securityPoolForker, securityPoolFactory, zoltarQuestionData, escalationGameFactory, priceOracleManagerAndOperatorQueuer, shareToken, openOracle, parent, zoltar, universeId, questionId, securityMultiplier, truthAuction], - }), + (() => { + const protocolConfig = getProtocolConfig() + return encodeDeployData({ + abi: peripherals_SecurityPool_SecurityPool.abi, + bytecode: applyLibraries(peripherals_SecurityPool_SecurityPool.evm.bytecode.object), + args: [securityPoolForker, securityPoolFactory, zoltarQuestionData, escalationGameFactory, priceOracleManagerAndOperatorQueuer, shareToken, openOracle, parent, zoltar, universeId, questionId, securityMultiplier, protocolConfig.initialEscalationGameDeposit, truthAuction], + }) + })(), getShareTokenInitCode: (securityPoolFactory, zoltarAddress, questionId) => encodeDeployData({ abi: peripherals_tokens_ShareToken_ShareToken.abi, @@ -280,10 +290,11 @@ export async function ensureInfraDeployed(client: WriteClient): Promise { if (!existence['openOracle']) await deployBytecode(`0x${peripherals_openOracle_OpenOracle_OpenOracle.evm.bytecode.object}`) if (!existence['zoltarQuestionData']) await deployBytecode(getZoltarQuestionDataByteCode()) if (!existence['zoltar']) { + const protocolConfig = getProtocolConfig() const initCode = encodeDeployData({ abi: Zoltar_Zoltar.abi, bytecode: `0x${Zoltar_Zoltar.evm.bytecode.object}`, - args: [contractAddresses.zoltarQuestionData], + args: [contractAddresses.zoltarQuestionData, protocolConfig.forkThresholdDivisor, protocolConfig.forkBurnDivisor], }) await deployBytecode(initCode) } diff --git a/solidity/ts/testsuite/simulator/utils/contracts/securityPool.ts b/solidity/ts/testsuite/simulator/utils/contracts/securityPool.ts index 27007c8e..247d42a8 100644 --- a/solidity/ts/testsuite/simulator/utils/contracts/securityPool.ts +++ b/solidity/ts/testsuite/simulator/utils/contracts/securityPool.ts @@ -170,6 +170,22 @@ export const getVaults = async (client: ReadClient, securityPoolAddress: Address args: [startIndex, count], }) +export const getActiveVaultCount = async (client: ReadClient, securityPoolAddress: Address) => + await client.readContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'getActiveVaultCount', + address: securityPoolAddress, + args: [], + }) + +export const getActiveVaults = async (client: ReadClient, securityPoolAddress: Address, startIndex: bigint, count: bigint) => + await client.readContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'getActiveVaults', + address: securityPoolAddress, + args: [startIndex, count], + }) + export const getSecurityPoolsEscalationGame = async (client: ReadClient, securityPoolAddress: Address) => await client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, diff --git a/solidity/ts/testsuite/simulator/utils/contracts/zoltar.ts b/solidity/ts/testsuite/simulator/utils/contracts/zoltar.ts index f44bdbd3..7f91c466 100644 --- a/solidity/ts/testsuite/simulator/utils/contracts/zoltar.ts +++ b/solidity/ts/testsuite/simulator/utils/contracts/zoltar.ts @@ -1,6 +1,7 @@ import { ReputationToken_ReputationToken, Zoltar_Zoltar, ZoltarQuestionData_ZoltarQuestionData } from '../../../../types/contractArtifact' import { createRepTokenAddressHelper } from '@zoltar/shared/addressDerivation' import { createZoltarAddressHelpers } from '@zoltar/shared/deploymentAddresses' +import { getProtocolConfig } from '../protocolConfig' import { ReadClient, WriteClient, writeContractAndWait } from '../viem' import { GENESIS_REPUTATION_TOKEN, PROXY_DEPLOYER_ADDRESS } from '../constants' import { encodeDeployData, getAddress, type Address, type Hex, toHex } from 'viem' @@ -10,10 +11,11 @@ import { ensureProxyDeployerDeployed } from '../utilities' const ZERO_SALT: Hex = toHex(0, { size: 32 }) function getZoltarInitCode(zoltarQuestionDataAddress: Address): Hex { + const protocolConfig = getProtocolConfig() return encodeDeployData({ abi: Zoltar_Zoltar.abi, bytecode: `0x${Zoltar_Zoltar.evm.bytecode.object}`, - args: [zoltarQuestionDataAddress], + args: [zoltarQuestionDataAddress, protocolConfig.forkThresholdDivisor, protocolConfig.forkBurnDivisor], }) } @@ -62,10 +64,9 @@ const ensureZoltarQuestionDataDeployed = async (client: WriteClient) => { } export const isZoltarDeployed = async (client: ReadClient) => { - const expectedDeployedBytecode: Hex = `0x${Zoltar_Zoltar.evm.deployedBytecode.object}` const address = getZoltarAddress() const deployedBytecode = await client.getCode({ address }) - return deployedBytecode === expectedDeployedBytecode + return deployedBytecode !== undefined && deployedBytecode !== '0x' } export const ensureZoltarDeployed = async (client: WriteClient) => { @@ -138,6 +139,22 @@ export const getZoltarForkThreshold = async (client: ReadClient, universeId: big args: [universeId], }) +export const getZoltarForkThresholdDivisor = async (client: ReadClient) => + await client.readContract({ + abi: Zoltar_Zoltar.abi, + functionName: 'forkThresholdDivisor', + address: getZoltarAddress(), + args: [], + }) + +export const getZoltarForkBurnDivisor = async (client: ReadClient) => + await client.readContract({ + abi: Zoltar_Zoltar.abi, + functionName: 'forkBurnDivisor', + address: getZoltarAddress(), + args: [], + }) + export const deployChild = async (client: WriteClient, universeId: bigint, outcomeIndex: bigint) => await writeContractAndWait(client, () => client.writeContract({ diff --git a/solidity/ts/testsuite/simulator/utils/protocolConfig.ts b/solidity/ts/testsuite/simulator/utils/protocolConfig.ts new file mode 100644 index 00000000..e6f8207c --- /dev/null +++ b/solidity/ts/testsuite/simulator/utils/protocolConfig.ts @@ -0,0 +1,92 @@ +import { DEFAULT_PROTOCOL_CONFIG } from '@zoltar/shared/protocolConfig' + +type ProtocolConfig = { + forkBurnDivisor: bigint + forkThresholdDivisor: bigint + initialEscalationGameDeposit: bigint +} + +type ProtocolConfigInput = Partial<{ + [key in keyof ProtocolConfig]: bigint | number | string | undefined +}> + +const PROTOCOL_CONFIG_GLOBAL_KEY = '__ZOLTAR_PROTOCOL_CONFIG__' + +function readProcessEnv(name: string): string | undefined { + const processValue = Reflect.get(globalThis, 'process') + if (typeof processValue !== 'object' || processValue === null) return undefined + const envValue = Reflect.get(processValue, 'env') + if (typeof envValue !== 'object' || envValue === null) return undefined + const rawValue = Reflect.get(envValue, name) + if (typeof rawValue !== 'string') return undefined + const trimmedValue = rawValue.trim() + return trimmedValue === '' ? undefined : trimmedValue +} + +function getEnvironmentProtocolConfigOverrides(): ProtocolConfigInput { + const forkBurnDivisor = readProcessEnv('ZOLTAR_FORK_BURN_DIVISOR') + const forkThresholdDivisor = readProcessEnv('ZOLTAR_FORK_THRESHOLD_DIVISOR') + const initialEscalationGameDeposit = readProcessEnv('ZOLTAR_INITIAL_ESCALATION_GAME_DEPOSIT') + return { + ...(forkBurnDivisor === undefined ? {} : { forkBurnDivisor }), + ...(forkThresholdDivisor === undefined ? {} : { forkThresholdDivisor }), + ...(initialEscalationGameDeposit === undefined ? {} : { initialEscalationGameDeposit }), + } +} + +function readProtocolConfigOverrideValue(source: object, field: keyof ProtocolConfig) { + const rawValue = Reflect.get(source, field) + if (typeof rawValue === 'bigint' || typeof rawValue === 'number' || typeof rawValue === 'string') return rawValue + return undefined +} + +function getGlobalProtocolConfigOverrides(): ProtocolConfigInput { + const rawConfig = Reflect.get(globalThis, PROTOCOL_CONFIG_GLOBAL_KEY) + if (typeof rawConfig !== 'object' || rawConfig === null) return {} + const forkBurnDivisor = readProtocolConfigOverrideValue(rawConfig, 'forkBurnDivisor') + const forkThresholdDivisor = readProtocolConfigOverrideValue(rawConfig, 'forkThresholdDivisor') + const initialEscalationGameDeposit = readProtocolConfigOverrideValue(rawConfig, 'initialEscalationGameDeposit') + return { + ...(forkBurnDivisor === undefined ? {} : { forkBurnDivisor }), + ...(forkThresholdDivisor === undefined ? {} : { forkThresholdDivisor }), + ...(initialEscalationGameDeposit === undefined ? {} : { initialEscalationGameDeposit }), + } +} + +function parseConfigBigInt(value: bigint | number | string | undefined, field: keyof ProtocolConfig): bigint | undefined { + if (value === undefined) return undefined + if (typeof value === 'bigint') return value + if (typeof value === 'number') { + if (!Number.isInteger(value)) throw new Error(`Protocol config ${field} must be an integer`) + return BigInt(value) + } + const trimmedValue = value.trim() + if (trimmedValue === '') return undefined + return BigInt(trimmedValue) +} + +function validateProtocolConfig(config: ProtocolConfigInput): ProtocolConfig { + const forkBurnDivisor = parseConfigBigInt(config.forkBurnDivisor, 'forkBurnDivisor') + const forkThresholdDivisor = parseConfigBigInt(config.forkThresholdDivisor, 'forkThresholdDivisor') + const initialEscalationGameDeposit = parseConfigBigInt(config.initialEscalationGameDeposit, 'initialEscalationGameDeposit') + if (forkThresholdDivisor === undefined) throw new Error('Protocol config forkThresholdDivisor is required') + if (forkBurnDivisor === undefined) throw new Error('Protocol config forkBurnDivisor is required') + if (initialEscalationGameDeposit === undefined) throw new Error('Protocol config initialEscalationGameDeposit is required') + if (forkThresholdDivisor <= 1n) throw new Error('Protocol config forkThresholdDivisor must be greater than 1') + if (forkBurnDivisor <= 1n) throw new Error('Protocol config forkBurnDivisor must be greater than 1') + if (initialEscalationGameDeposit <= 0n) throw new Error('Protocol config initialEscalationGameDeposit must be greater than 0') + return { + forkBurnDivisor, + forkThresholdDivisor, + initialEscalationGameDeposit, + } +} + +export function getProtocolConfig(overrides: ProtocolConfigInput = {}): ProtocolConfig { + return validateProtocolConfig({ + ...DEFAULT_PROTOCOL_CONFIG, + ...getEnvironmentProtocolConfigOverrides(), + ...getGlobalProtocolConfigOverrides(), + ...overrides, + }) +} diff --git a/ui/ts/components/ForkAuctionSection.tsx b/ui/ts/components/ForkAuctionSection.tsx index 986d6898..4925910c 100644 --- a/ui/ts/components/ForkAuctionSection.tsx +++ b/ui/ts/components/ForkAuctionSection.tsx @@ -1417,7 +1417,14 @@ export function ForkAuctionSection({ return } let cancelled = false - void loadAllSecurityPools(fullTruthAuctionReadClient ?? createConnectedReadClient()) + void loadAllSecurityPools( + fullTruthAuctionReadClient ?? createConnectedReadClient(), + accountState.address === undefined + ? {} + : { + accountAddress: accountState.address, + }, + ) .then(allPools => { if (cancelled) return const recoveredPool = allPools.find(pool => sameAddress(pool.parent, securityPoolAddress) && pool.questionOutcome === forkAuctionForm.selectedOutcome) @@ -1430,7 +1437,7 @@ export function ForkAuctionSection({ return () => { cancelled = true } - }, [embedInCard, forkAuctionForm.selectedOutcome, forkAuctionResult?.hash, fullTruthAuctionReadClient, securityPoolAddress, selectedOutcomeMigrationChildPool, selectedStage]) + }, [accountState.address, embedInCard, forkAuctionForm.selectedOutcome, forkAuctionResult?.hash, fullTruthAuctionReadClient, securityPoolAddress, selectedOutcomeMigrationChildPool, selectedStage]) useEffect(() => { if ((selectedStage !== 'auction' && selectedStage !== 'settlement') || selectedAuctionPoolAddress === undefined) { setSelectedAuctionDetails(undefined) diff --git a/ui/ts/components/SecurityPoolVaultDirectory.tsx b/ui/ts/components/SecurityPoolVaultDirectory.tsx index c0e9cfdf..d7050638 100644 --- a/ui/ts/components/SecurityPoolVaultDirectory.tsx +++ b/ui/ts/components/SecurityPoolVaultDirectory.tsx @@ -18,9 +18,16 @@ type SecurityPoolVaultDirectoryProps = { export function SecurityPoolVaultDirectory({ emptyState, pool, renderActions, renderBadge, renderTitle, repPerEthPrice, repPerEthSource, repPerEthSourceUrl }: SecurityPoolVaultDirectoryProps) { if (pool === undefined || pool.vaults.length === 0) return <>{emptyState} + const loadedVaultCount = BigInt(pool.vaults.length) + const showingPartialDirectory = loadedVaultCount < pool.vaultCount return (
+ {showingPartialDirectory ? ( +

+ Showing {loadedVaultCount.toString()} of {pool.vaultCount.toString()} active vaults, newest activity first. Enter a vault address above to inspect any specific vault. +

+ ) : null} {pool.vaults.map(vault => { const collateralizationPercent = getVaultCollateralizationPercent(vault.repDepositShare, vault.securityBondAllowance, repPerEthPrice) const collateralizationTarget = pool.securityMultiplier * 100n * 10n ** 18n diff --git a/ui/ts/components/SecurityPoolWorkflowSection.tsx b/ui/ts/components/SecurityPoolWorkflowSection.tsx index f40cb91b..539a7229 100644 --- a/ui/ts/components/SecurityPoolWorkflowSection.tsx +++ b/ui/ts/components/SecurityPoolWorkflowSection.tsx @@ -405,6 +405,8 @@ export function SecurityPoolWorkflowSection({ resolvedPendingOperationId, }) const pendingOperation = currentPoolOracleManagerDetails?.pendingOperation + const stagedOperations = currentPoolOracleManagerDetails?.stagedOperations ?? (pendingOperation === undefined ? [] : [pendingOperation]) + const activeStagedOperationCount = currentPoolOracleManagerDetails?.activeStagedOperationCount ?? BigInt(stagedOperations.length) const selectedPoolBrowsePresentation = selectedPool === undefined ? getPoolRegistryPresentation({ mode: 'selection', state: selectedPoolLookupState }) : undefined const selectedVaultLoadNotice = (() => { if (securityVault.loadingSecurityVault) @@ -983,28 +985,34 @@ export function SecurityPoolWorkflowSection({ - {pendingOperation === undefined ? null : ( - + {stagedOperations.map(operation => ( +
-

{getPendingOperationLabel(pendingOperation.operation)}

+

{getPendingOperationLabel(operation.operation)}

+ {currentPoolOracleManagerDetails?.pendingOperationSlotId === operation.operationId ?

Auto-exec slot

:

Manual execution

}
- {pendingOperation.operationId.toString()} + {operation.operationId.toString()} - + - + - +
- )} - {currentPoolOracleManagerDetails === undefined || currentPoolOracleManagerDetails.pendingOperation !== undefined ? null : } + ))} + {activeStagedOperationCount > BigInt(stagedOperations.length) ? ( +

+ Showing {stagedOperations.length.toString()} of {activeStagedOperationCount.toString()} active staged operations, newest first. +

+ ) : null} + {currentPoolOracleManagerDetails === undefined || stagedOperations.length > 0 ? null : }
{currentPoolOracleManagerDetails === undefined ? undefined : (
) }) + })() )} + {pool.vaultCount > BigInt(pool.vaults.length) ? ( +

+ Showing {pool.vaults.length.toString()} of {pool.vaultCount.toString()} active vaults in this preview, newest activity first. +

+ ) : undefined} )} - {pool.vaultCount > 3n ? ( + {pool.vaultCount > BigInt(pool.vaults.length) ? (

- +{(pool.vaultCount - 3n).toString()} more vault - {pool.vaultCount - 3n === 1n ? '' : 's'} + +{(pool.vaultCount - BigInt(pool.vaults.length)).toString()} more vault + {pool.vaultCount - BigInt(pool.vaults.length) === 1n ? '' : 's'}

) : undefined} diff --git a/ui/ts/contracts.ts b/ui/ts/contracts.ts index d35df092..d9fafeda 100644 --- a/ui/ts/contracts.ts +++ b/ui/ts/contracts.ts @@ -123,6 +123,7 @@ type CarryLeafViewStruct = { sourceNodeId: bigint } type LoadAllSecurityPoolsOptions = { + accountAddress?: Address selectedSecurityPoolAddress?: Address | string vaultDetailMode?: 'all' | 'selected' } @@ -138,6 +139,9 @@ type SecurityPoolDeploymentQueryResult = { truthAuction: Address universeId: bigint } + +const ACTIVE_SECURITY_POOL_VAULT_PREVIEW_LIMIT = 50n +const ACTIVE_STAGED_OPERATION_PREVIEW_LIMIT = 25n function getOracleQueueOperationFromEventOperation(operation: bigint) { switch (operation) { case 0n: @@ -803,7 +807,7 @@ export async function loadReportingDetails(client: ReadClient, securityPoolAddre async function getSecurityPoolVaultCount(client: ReadClient, securityPoolAddress: Address) { return await client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'getVaultCount', + functionName: 'getActiveVaultCount', address: securityPoolAddress, args: [], }) @@ -811,71 +815,74 @@ async function getSecurityPoolVaultCount(client: ReadClient, securityPoolAddress async function getSecurityPoolVaults(client: ReadClient, securityPoolAddress: Address, startIndex: bigint, count: bigint) { return await client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'getVaults', + functionName: 'getActiveVaults', address: securityPoolAddress, args: [startIndex, count], }) } + +function isActiveSecurityVaultTuple(vaultData: readonly [bigint, bigint, bigint, bigint, bigint]) { + const [poolOwnership, securityBondAllowance, unpaidEthFees, , lockedRepInEscalationGame] = vaultData + return poolOwnership > 0n || securityBondAllowance > 0n || unpaidEthFees > 0n || lockedRepInEscalationGame > 0n +} + async function loadSecurityPoolVaultSummaries( client: ReadClient, securityPoolAddress: Address, + options: { + accountAddress?: Address + previewLimit?: bigint + } = {}, ): Promise<{ hasLoadedVaults: boolean vaultCount: bigint vaults: SecurityPoolVaultSummary[] }> { const vaultCount = await getSecurityPoolVaultCount(client, securityPoolAddress) - const vaultAddresses = vaultCount === 0n ? [] : await getSecurityPoolVaults(client, securityPoolAddress, 0n, vaultCount) - if (vaultAddresses.length === 0) return { hasLoadedVaults: true, vaultCount, vaults: [] } - const vaultDataContracts: ContractFunctionParameters[] = vaultAddresses.map(vaultAddress => ({ + const previewLimit = options.previewLimit ?? ACTIVE_SECURITY_POOL_VAULT_PREVIEW_LIMIT + const previewCount = vaultCount < previewLimit ? vaultCount : previewLimit + const previewVaultAddresses = previewCount === 0n ? [] : await getSecurityPoolVaults(client, securityPoolAddress, 0n, previewCount) + const summaryVaultAddresses = [...previewVaultAddresses] + if (options.accountAddress !== undefined && !summaryVaultAddresses.some(vaultAddress => sameAddress(vaultAddress, options.accountAddress))) { + summaryVaultAddresses.push(options.accountAddress) + } + if (summaryVaultAddresses.length === 0) return { hasLoadedVaults: true, vaultCount, vaults: [] } + const vaultDataContracts: ContractFunctionParameters[] = summaryVaultAddresses.map(vaultAddress => ({ abi: peripherals_SecurityPool_SecurityPool.abi, functionName: 'securityVaults', address: securityPoolAddress, args: [vaultAddress], })) - const vaultDataResults = await readRequiredMulticall(client, vaultDataContracts) + const [vaultDataResults, totalRepBalance, poolOwnershipDenominator] = await Promise.all([ + readRequiredMulticall(client, vaultDataContracts), + client.readContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'getTotalRepBalance', + address: securityPoolAddress, + args: [], + }), + client.readContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'poolOwnershipDenominator', + address: securityPoolAddress, + args: [], + }), + ]) const vaultData = requireSecurityVaultTupleArray(vaultDataResults, 'security vault tuple') - const poolOwnershipContracts: { - contract: ContractFunctionParameters - index: number - }[] = [] - for (const [index, currentVaultData] of vaultData.entries()) { - const poolOwnership = currentVaultData[0] - if (poolOwnership === undefined || poolOwnership === 0n) continue - poolOwnershipContracts.push({ - index, - contract: { - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'poolOwnershipToRep', - address: securityPoolAddress, - args: [poolOwnership], - }, - }) - } - const repDeposits = new Map() - if (poolOwnershipContracts.length > 0) { - const repDepositResults = await readRequiredMulticall( - client, - poolOwnershipContracts.map(current => current.contract), - ) - for (const [resultIndex, repDepositShare] of repDepositResults.entries()) { - const poolOwnershipContract = poolOwnershipContracts[resultIndex] - if (poolOwnershipContract === undefined) throw new Error('Unexpected pool ownership contract result') - if (typeof repDepositShare !== 'bigint') throw new Error('Unexpected rep deposit result') - repDeposits.set(poolOwnershipContract.index, repDepositShare) - } - } - const vaults = vaultAddresses.map((vaultAddress, index) => { + const vaults = summaryVaultAddresses.flatMap((vaultAddress, index) => { const currentVaultData = vaultData[index] if (currentVaultData === undefined) throw new Error('Unexpected vault data response') - const [, securityBondAllowance, unpaidEthFees, , lockedRepInEscalationGame] = currentVaultData - return { - lockedRepInEscalationGame, - repDepositShare: repDeposits.get(index) ?? 0n, - securityBondAllowance, - unpaidEthFees, - vaultAddress, - } satisfies SecurityPoolVaultSummary + if (!previewVaultAddresses.some(currentPreviewAddress => sameAddress(currentPreviewAddress, vaultAddress)) && !isActiveSecurityVaultTuple(currentVaultData)) return [] + const [poolOwnership, securityBondAllowance, unpaidEthFees, , lockedRepInEscalationGame] = currentVaultData + return [ + { + lockedRepInEscalationGame, + repDepositShare: poolOwnershipDenominator === 0n || poolOwnership === 0n ? 0n : (poolOwnership * totalRepBalance) / poolOwnershipDenominator, + securityBondAllowance, + unpaidEthFees, + vaultAddress, + } satisfies SecurityPoolVaultSummary, + ] }) return { hasLoadedVaults: true, vaultCount, vaults } } @@ -937,7 +944,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] = await readRequiredMulticall(client, [ + const [lastPrice, pendingOperationSlotId, pendingReportId, requestPriceEthCost, rawIsPriceValid, lastSettlementTimestamp, activeStagedOperationCount] = await readRequiredMulticall(client, [ { abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, functionName: 'lastPrice', @@ -974,28 +981,62 @@ export async function loadOracleManagerDetails(client: ReadClient, managerAddres address: managerAddress, args: [], }, + { + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + functionName: 'getActiveStagedOperationCount', + address: managerAddress, + args: [], + }, ]) const resolvedOracleAddress = openOracleAddress ?? getInfraContractAddresses().openOracle let callbackStateHash: Hex | undefined let exactToken1Report: bigint | undefined let pendingOperation: import('./types/contracts.js').StagedOracleOperation | undefined + let stagedOperations: import('./types/contracts.js').StagedOracleOperation[] = [] let token1: Address | undefined let token2: Address | undefined - if (pendingOperationSlotId > 0n) { - const stagedOperation = await client.readContract({ + if (activeStagedOperationCount > 0n) { + const previewCount = activeStagedOperationCount < ACTIVE_STAGED_OPERATION_PREVIEW_LIMIT ? activeStagedOperationCount : ACTIVE_STAGED_OPERATION_PREVIEW_LIMIT + const [operationIds, activeOperations] = await client.readContract({ abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, - functionName: 'getPendingOperationSlot', + functionName: 'getActiveStagedOperations', address: managerAddress, - args: [], + args: [0n, previewCount], }) - if (stagedOperation.initiatorVault !== zeroAddress) - pendingOperation = { - amount: stagedOperation.amount, - initiatorVault: stagedOperation.initiatorVault, - operation: resolveOracleQueueOperation(stagedOperation.operation), - operationId: pendingOperationSlotId, - targetVault: stagedOperation.targetVault, + stagedOperations = operationIds + .map((operationId, index) => { + const stagedOperation = activeOperations[index] + if (stagedOperation === undefined) throw new Error('Missing staged operation details') + return { + amount: stagedOperation.amount, + initiatorVault: stagedOperation.initiatorVault, + operation: resolveOracleQueueOperation(stagedOperation.operation), + operationId, + targetVault: stagedOperation.targetVault, + } + }) + .sort(compareStagedOperationIdsDescending) + pendingOperation = stagedOperations.find(operation => operation.operationId === pendingOperationSlotId) + if (pendingOperation === undefined && pendingOperationSlotId > 0n) { + const stagedOperation = await client.readContract({ + abi: peripherals_SecurityPoolOracleCoordinator_SecurityPoolOracleCoordinator.abi, + functionName: 'getPendingOperationSlot', + address: managerAddress, + args: [], + }) + if (stagedOperation.initiatorVault !== zeroAddress) { + pendingOperation = { + amount: stagedOperation.amount, + initiatorVault: stagedOperation.initiatorVault, + operation: resolveOracleQueueOperation(stagedOperation.operation), + operationId: pendingOperationSlotId, + targetVault: stagedOperation.targetVault, + } + if (!stagedOperations.some(operation => operation.operationId === pendingOperationSlotId)) { + stagedOperations = [pendingOperation, ...stagedOperations].sort(compareStagedOperationIdsDescending) + } } + } } if (pendingReportId > 0n) { const [extraData, reportMeta] = await readRequiredMulticall(client, [ @@ -1018,6 +1059,7 @@ export async function loadOracleManagerDetails(client: ReadClient, managerAddres token2 = reportMeta[6] } return { + activeStagedOperationCount, callbackStateHash, exactToken1Report, isPriceValid: lastSettlementTimestamp > 0n && rawIsPriceValid, @@ -1030,6 +1072,7 @@ export async function loadOracleManagerDetails(client: ReadClient, managerAddres pendingReportId, priceValidUntilTimestamp: getOracleManagerPriceValidUntilTimestamp(lastSettlementTimestamp), requestPriceEthCost, + stagedOperations, token1, token2, } @@ -1047,6 +1090,12 @@ function resolveOracleQueueOperation(operation: bigint | number): OracleQueueOpe } } +function compareStagedOperationIdsDescending(left: { operationId: bigint }, right: { operationId: bigint }) { + if (left.operationId > right.operationId) return -1 + if (left.operationId < right.operationId) return 1 + return 0 +} + function calculateOpenOraclePrice(amount1: bigint, amount2: bigint) { return amount2 === 0n ? 0n : (amount1 * 10n ** OPEN_ORACLE_PRICE_UNITS) / amount2 } @@ -2036,6 +2085,7 @@ export async function finalizeSecurityPoolTruthAuction(client: WriteClient, secu ) } export async function loadAllSecurityPools(client: ReadClient, options: LoadAllSecurityPoolsOptions = {}): Promise { + const accountAddress = options.accountAddress const vaultDetailMode = options.vaultDetailMode ?? 'all' const selectedSecurityPoolAddress = options.selectedSecurityPoolAddress const deploymentCount = await client.readContract({ @@ -2121,7 +2171,12 @@ export async function loadAllSecurityPools(client: ReadClient, options: LoadAllS }, ]), loadMarketDetails(client, questionId), - shouldLoadVaults ? loadSecurityPoolVaultSummaries(client, securityPoolAddress) : Promise.all([getSecurityPoolVaultCount(client, securityPoolAddress)]).then(([vaultCount]) => ({ hasLoadedVaults: vaultCount === 0n, vaultCount, vaults: [] })), + shouldLoadVaults + ? loadSecurityPoolVaultSummaries(client, securityPoolAddress, { + ...(accountAddress === undefined ? {} : { accountAddress }), + previewLimit: ACTIVE_SECURITY_POOL_VAULT_PREVIEW_LIMIT, + }) + : Promise.all([getSecurityPoolVaultCount(client, securityPoolAddress)]).then(([vaultCount]) => ({ hasLoadedVaults: vaultCount === 0n, vaultCount, vaults: [] })), ]) const forkDataTuple: ForkDataTuple = forkData const [, , truthAuctionStartedAt, migratedRep, , , , , forkOwnSecurityPool, , forkOutcomeIndex] = forkDataTuple diff --git a/ui/ts/contracts/deploymentHelpers.ts b/ui/ts/contracts/deploymentHelpers.ts index f36dff8f..268f5e79 100644 --- a/ui/ts/contracts/deploymentHelpers.ts +++ b/ui/ts/contracts/deploymentHelpers.ts @@ -1,5 +1,6 @@ import { concatHex, encodeAbiParameters, encodeDeployData, getCreate2Address, keccak256, toHex, type Address, type Hex } from 'viem' import { createApplyLinkedLibrariesHelper, createInfraContractAddressHelper, createZoltarAddressHelpers } from '@zoltar/shared/deploymentAddresses' +import { getProtocolConfig } from '@zoltar/shared/protocolConfig' import { bigintToAddress } from './helpers.js' import { ScalarOutcomes_ScalarOutcomes, @@ -93,11 +94,14 @@ export const getSecurityPoolForkerByteCode = (zoltarAddress: Address) => }) export const getZoltarInitCode = (zoltarQuestionDataAddress: Address): Hex => - encodeDeployData({ - abi: Zoltar_Zoltar.abi, - bytecode: `0x${Zoltar_Zoltar.evm.bytecode.object}`, - args: [zoltarQuestionDataAddress], - }) + (() => { + const protocolConfig = getProtocolConfig() + return encodeDeployData({ + abi: Zoltar_Zoltar.abi, + bytecode: `0x${Zoltar_Zoltar.evm.bytecode.object}`, + args: [zoltarQuestionDataAddress, protocolConfig.forkThresholdDivisor, protocolConfig.forkBurnDivisor], + }) + })() export const getSecurityPoolFactoryByteCode = ({ escalationGameFactory, @@ -118,11 +122,14 @@ export const getSecurityPoolFactoryByteCode = ({ zoltar: Address zoltarQuestionData: Address }) => - encodeDeployData({ - abi: peripherals_factories_SecurityPoolFactory_SecurityPoolFactory.abi, - bytecode: applyLibraries(peripherals_factories_SecurityPoolFactory_SecurityPoolFactory.evm.bytecode.object), - args: [securityPoolForker, zoltarQuestionData, escalationGameFactory, openOracle, zoltar, shareTokenFactory, uniformPriceDualCapBatchAuctionFactory, priceOracleManagerAndOperatorQueuerFactory], - }) + (() => { + const protocolConfig = getProtocolConfig() + return encodeDeployData({ + abi: peripherals_factories_SecurityPoolFactory_SecurityPoolFactory.abi, + bytecode: applyLibraries(peripherals_factories_SecurityPoolFactory_SecurityPoolFactory.evm.bytecode.object), + args: [securityPoolForker, zoltarQuestionData, escalationGameFactory, openOracle, zoltar, shareTokenFactory, uniformPriceDualCapBatchAuctionFactory, priceOracleManagerAndOperatorQueuerFactory, protocolConfig.initialEscalationGameDeposit], + }) + })() export const { getZoltarAddress, getZoltarQuestionDataAddress } = createZoltarAddressHelpers({ getZoltarInitCode, diff --git a/ui/ts/contracts/securityPools.ts b/ui/ts/contracts/securityPools.ts index 86563c9a..c1f2b666 100644 --- a/ui/ts/contracts/securityPools.ts +++ b/ui/ts/contracts/securityPools.ts @@ -9,6 +9,7 @@ import { } from '../contractArtifact.js' import { isIgnorableLogDecodeError } from '../lib/errors.js' import { deriveHasForkActivity } from '../lib/forkAuction.js' +import { sameAddress } from '../lib/address.js' import type { ListedSecurityPool, SecurityPoolCreationResult, SecurityPoolPage, SecurityVaultDetails, WriteClient, ReadClient } from '../types/contracts.js' import { readRequiredMulticall, writeContractAndWaitForReceipt } from './core.js' import { getForkOutcomeKey, getQuestionIdHex, getReportingOutcomeKey, getSecurityPoolSystemState, requireSecurityVaultTupleArray } from './helpers.js' @@ -18,6 +19,8 @@ import { loadMarketDetails } from './zoltar.js' const QUESTION_OUTCOME_ABI = [parseAbiItem('function getQuestionOutcome(address securityPool) view returns (uint8 outcome)')] +const ACTIVE_SECURITY_POOL_VAULT_PREVIEW_LIMIT = 3n + type ForkDataTuple = readonly [bigint, Address, bigint, bigint, bigint, bigint, bigint, bigint, boolean, boolean, number] type SecurityPoolDeploymentQueryResult = { parent: Address @@ -77,19 +80,10 @@ async function securityPoolExists(client: Pick, securityP return code !== undefined && code !== '0x' } -async function poolOwnershipToRep(client: ReadClient, securityPoolAddress: Address, poolOwnership: bigint) { - return await client.readContract({ - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'poolOwnershipToRep', - address: securityPoolAddress, - args: [poolOwnership], - }) -} - async function getSecurityPoolVaultCount(client: Pick, securityPoolAddress: Address) { return await client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'getVaultCount', + functionName: 'getActiveVaultCount', address: securityPoolAddress, args: [], }) @@ -98,32 +92,47 @@ async function getSecurityPoolVaultCount(client: Pick, securityPoolAddress: Address, startIndex: bigint, count: bigint) { return await client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'getVaults', + functionName: 'getActiveVaults', address: securityPoolAddress, args: [startIndex, count], }) } +function isActiveSecurityVaultTuple(vaultData: readonly [bigint, bigint, bigint, bigint, bigint]) { + const [poolOwnership, securityBondAllowance, unpaidEthFees, , lockedRepInEscalationGame] = vaultData + return poolOwnership > 0n || securityBondAllowance > 0n || unpaidEthFees > 0n || lockedRepInEscalationGame > 0n +} + async function loadSecurityPoolVaultSummaries( client: ReadClient, securityPoolAddress: Address, + options: { + accountAddress?: Address + previewLimit?: bigint + } = {}, ): Promise<{ hasLoadedVaults: boolean vaultCount: bigint vaults: ListedSecurityPool['vaults'] }> { const vaultCount = await getSecurityPoolVaultCount(client, securityPoolAddress) - const vaultAddresses = vaultCount === 0n ? [] : await getSecurityPoolVaults(client, securityPoolAddress, 0n, vaultCount) - if (vaultAddresses.length === 0) { + const previewLimit = options.previewLimit ?? ACTIVE_SECURITY_POOL_VAULT_PREVIEW_LIMIT + const previewCount = vaultCount < previewLimit ? vaultCount : previewLimit + const previewVaultAddresses = previewCount === 0n ? [] : await getSecurityPoolVaults(client, securityPoolAddress, 0n, previewCount) + const summaryVaultAddresses = [...previewVaultAddresses] + if (options.accountAddress !== undefined && !summaryVaultAddresses.some(vaultAddress => sameAddress(vaultAddress, options.accountAddress))) { + summaryVaultAddresses.push(options.accountAddress) + } + if (summaryVaultAddresses.length === 0) { return { hasLoadedVaults: true, vaultCount, vaults: [], } } - const vaultData = requireSecurityVaultTupleArray( - await Promise.all( - vaultAddresses.map( + const [vaultData, totalRepBalance, poolOwnershipDenominator] = await Promise.all([ + Promise.all( + summaryVaultAddresses.map( async vaultAddress => await client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, @@ -132,52 +141,37 @@ async function loadSecurityPoolVaultSummaries( args: [vaultAddress], }), ), - ), - 'security vault tuple', - ) - const poolOwnershipContracts: Array<{ - index: number - poolOwnership: bigint - }> = [] - for (const [index, currentVaultData] of vaultData.entries()) { - const [poolOwnership] = currentVaultData - if (poolOwnership === 0n) continue - poolOwnershipContracts.push({ index, poolOwnership }) - } - const repDeposits = new Map() - if (poolOwnershipContracts.length > 0) { - const repDepositResults = await Promise.all( - poolOwnershipContracts.map( - async ({ poolOwnership }) => - await client.readContract({ - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'poolOwnershipToRep', - address: securityPoolAddress, - args: [poolOwnership], - }), - ), - ) - for (const [resultIndex, repDepositShare] of repDepositResults.entries()) { - const poolOwnershipContract = poolOwnershipContracts[resultIndex] - if (poolOwnershipContract === undefined) throw new Error('Unexpected pool ownership contract result') - if (typeof repDepositShare !== 'bigint') throw new Error('Unexpected rep deposit result') - repDeposits.set(poolOwnershipContract.index, repDepositShare) - } - } + ).then(result => requireSecurityVaultTupleArray(result, 'security vault tuple')), + client.readContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'getTotalRepBalance', + address: securityPoolAddress, + args: [], + }), + client.readContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'poolOwnershipDenominator', + address: securityPoolAddress, + args: [], + }), + ]) return { hasLoadedVaults: true, vaultCount, - vaults: vaultAddresses.map((vaultAddress, index) => { + vaults: summaryVaultAddresses.flatMap((vaultAddress, index) => { const currentVaultData = vaultData[index] if (currentVaultData === undefined) throw new Error('Unexpected vault data response') - const [, securityBondAllowance, unpaidEthFees, , lockedRepInEscalationGame] = currentVaultData - return { - lockedRepInEscalationGame, - repDepositShare: repDeposits.get(index) ?? 0n, - securityBondAllowance, - unpaidEthFees, - vaultAddress, - } + if (!previewVaultAddresses.some(currentPreviewAddress => sameAddress(currentPreviewAddress, vaultAddress)) && !isActiveSecurityVaultTuple(currentVaultData)) return [] + const [poolOwnership, securityBondAllowance, unpaidEthFees, , lockedRepInEscalationGame] = currentVaultData + return [ + { + lockedRepInEscalationGame, + repDepositShare: poolOwnershipDenominator === 0n || poolOwnership === 0n ? 0n : (poolOwnership * totalRepBalance) / poolOwnershipDenominator, + securityBondAllowance, + unpaidEthFees, + vaultAddress, + }, + ] }), } } @@ -212,7 +206,7 @@ export async function originSecurityPoolExists(client: Pick { +export async function loadSecurityPoolPage(client: ReadClient, pageIndex: number, pageSize: number, accountAddress?: Address): Promise { if (!Number.isInteger(pageIndex) || pageIndex < 0) throw new Error('Security pool page index must be a non-negative integer') if (!Number.isInteger(pageSize) || pageSize <= 0) throw new Error('Security pool page size must be a positive integer') const poolCount = await client.readContract({ @@ -304,7 +298,10 @@ export async function loadSecurityPoolPage(client: ReadClient, pageIndex: number }, ]), loadMarketDetails(client, questionId), - loadSecurityPoolVaultSummaries(client, securityPoolAddress), + loadSecurityPoolVaultSummaries(client, securityPoolAddress, { + ...(accountAddress === undefined ? {} : { accountAddress }), + previewLimit: ACTIVE_SECURITY_POOL_VAULT_PREVIEW_LIMIT, + }), ]) const [, , truthAuctionStartedAt, migratedRep, , , , , forkOwnSecurityPool, , forkOutcomeIndex] = forkData as ForkDataTuple const forkOutcome = getForkOutcomeKey(forkOutcomeIndex, parent) @@ -355,18 +352,19 @@ export async function loadSecurityPoolPage(client: ReadClient, pageIndex: number export async function loadSecurityVaultDetails(client: ReadClient, securityPoolAddress: Address, vaultAddress: Address): Promise { if (!(await securityPoolExists(client, securityPoolAddress))) return undefined - const [currentRetentionRate, managerAddress, poolOwnershipDenominator, repToken, totalSecurityBondAllowance, universeId, vaultData] = await Promise.all([ + const [currentRetentionRate, managerAddress, poolOwnershipDenominator, repToken, totalRepBalance, totalSecurityBondAllowance, universeId, vaultData] = await Promise.all([ client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, functionName: 'currentRetentionRate', address: securityPoolAddress, args: [] }), client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, functionName: 'priceOracleManagerAndOperatorQueuer', address: securityPoolAddress, args: [] }), client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, functionName: 'poolOwnershipDenominator', address: securityPoolAddress, args: [] }), client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, functionName: 'repToken', address: securityPoolAddress, args: [] }), + client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, functionName: 'getTotalRepBalance', address: securityPoolAddress, args: [] }), client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, functionName: 'totalSecurityBondAllowance', address: securityPoolAddress, args: [] }), client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, functionName: 'universeId', address: securityPoolAddress, args: [] }), client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, functionName: 'securityVaults', address: securityPoolAddress, args: [vaultAddress] }), ]) const [poolOwnership, securityBondAllowance, unpaidEthFees, , lockedRepInEscalationGame] = vaultData - const repDepositShare = poolOwnershipDenominator === 0n || poolOwnership === 0n ? 0n : await poolOwnershipToRep(client, securityPoolAddress, poolOwnership) + const repDepositShare = poolOwnershipDenominator === 0n || poolOwnership === 0n ? 0n : (poolOwnership * totalRepBalance) / poolOwnershipDenominator return { currentRetentionRate, diff --git a/ui/ts/hooks/useSecurityPoolsOverview.ts b/ui/ts/hooks/useSecurityPoolsOverview.ts index f90dc2f6..872ec77e 100644 --- a/ui/ts/hooks/useSecurityPoolsOverview.ts +++ b/ui/ts/hooks/useSecurityPoolsOverview.ts @@ -61,7 +61,17 @@ export function useSecurityPoolsOverview({ accountAddress, onTransactionFailed, securityPoolOverviewError.value = undefined }, load: async () => { - const loadOptions = nextCheckedAddress === undefined ? { vaultDetailMode: 'selected' as const } : { selectedSecurityPoolAddress: nextCheckedAddress, vaultDetailMode: 'selected' as const } + const loadOptions = + nextCheckedAddress === undefined + ? { + ...(accountAddress === undefined ? {} : { accountAddress }), + vaultDetailMode: 'selected' as const, + } + : { + ...(accountAddress === undefined ? {} : { accountAddress }), + selectedSecurityPoolAddress: nextCheckedAddress, + vaultDetailMode: 'selected' as const, + } return await loadAllSecurityPools(createConnectedReadClient(), loadOptions) }, onSuccess: pools => { @@ -83,7 +93,7 @@ export function useSecurityPoolsOverview({ accountAddress, onTransactionFailed, if (!isCurrent()) return securityPoolOverviewError.value = undefined }, - load: async () => await loadSecurityPoolPage(createConnectedReadClient(), pageIndex, pageSize), + load: async () => await loadSecurityPoolPage(createConnectedReadClient(), pageIndex, pageSize, accountAddress), onSuccess: page => { hasLoadedSecurityPoolPage.value = true securityPoolBrowseCount.value = page.poolCount diff --git a/ui/ts/tests/contracts.test.ts b/ui/ts/tests/contracts.test.ts index b02e0e33..239c9f4c 100644 --- a/ui/ts/tests/contracts.test.ts +++ b/ui/ts/tests/contracts.test.ts @@ -7,6 +7,7 @@ import { loadAllSecurityPools, loadEscalationDeposits, loadForkAuctionDetails, + loadOracleManagerDetails, loadOpenOracleReportSummaries, loadReportingDetails, loadSecurityPoolPage, @@ -257,7 +258,7 @@ describe('contracts helpers', () => { }, ] } - if (request.functionName === 'getVaultCount') return 0n + if (request.functionName === 'getVaultCount' || request.functionName === 'getActiveVaultCount') return 0n if (request.functionName === 'getOutcomeLabels') return ['Yes', 'No'] throw new Error(`Unexpected readContract function: ${request.functionName}`) }, @@ -321,7 +322,7 @@ describe('contracts helpers', () => { }, ] } - if (request.functionName === 'getVaultCount') return 0n + if (request.functionName === 'getVaultCount' || request.functionName === 'getActiveVaultCount') return 0n if (request.functionName === 'getOutcomeLabels') return ['Yes', 'No'] throw new Error(`Unexpected readContract function: ${request.functionName}`) }, @@ -335,6 +336,76 @@ describe('contracts helpers', () => { expect(parentPool.universeHasForked).toBe(true) }) + test('loadSecurityPoolPage caps active vault previews and appends the connected wallet vault when needed', async () => { + const questionId = 1n + const questionTuple = ['Question', 'Description', 1n, 2n, 2n, 0n, 100n, ''] as const + const viewerVaultAddress = getAddress('0x00000000000000000000000000000000000000c4') + const previewVaultAddresses = [getAddress('0x00000000000000000000000000000000000000c1'), getAddress('0x00000000000000000000000000000000000000c2'), getAddress('0x00000000000000000000000000000000000000c3')] as const + const loadedVaultAddresses: Address[] = [] + let capturedVaultRangeArgs: readonly [bigint, bigint] | undefined + const client = createMockLoaderClient({ + getBlock: async () => createBlockWithTimestamp(0n), + multicall: async request => { + const firstContract = request.contracts[0] + if (getContractFunctionName(firstContract) === 'completeSetCollateralAmount') { + return [0n, 10n, [0n, zeroAddress, 0n, 0n, 0n, false, 0], 0n, 0n, 3n, 0n, 100n, 0n, 0n] + } + if (getContractFunctionName(firstContract) === 'questions') return [questionTuple, 1n] + throw new Error(`Unexpected multicall contract: ${getContractFunctionName(firstContract)}`) + }, + readContract: async request => { + if (request.functionName === 'securityPoolDeploymentCount') return 1n + if (request.functionName === 'securityPoolDeploymentsRange') { + return [ + { + parent: zeroAddress, + priceOracleManagerAndOperatorQueuer: zeroAddress, + questionId, + securityMultiplier: 2n, + securityPool: securityPoolAddress, + truthAuction: zeroAddress, + universeId: 1n, + }, + ] + } + if (request.functionName === 'getActiveVaultCount') return 5n + if (request.functionName === 'getActiveVaults') { + const args = request.args + if (args === undefined) throw new Error('Expected getActiveVaults args') + const startIndex = args[0] + const count = args[1] + if (typeof startIndex !== 'bigint' || typeof count !== 'bigint') throw new Error('Expected bigint vault range args') + capturedVaultRangeArgs = [startIndex, count] + return previewVaultAddresses + } + if (request.functionName === 'securityVaults') { + const args = request.args + if (args === undefined) throw new Error('Expected securityVaults args') + const rawVaultAddress = args[0] + if (typeof rawVaultAddress !== 'string') throw new Error('Expected vault address argument') + const currentVaultAddress = getAddress(rawVaultAddress) + loadedVaultAddresses.push(currentVaultAddress) + if (currentVaultAddress === viewerVaultAddress) return [1n, 0n, 0n, 0n, 0n] + return [2n, 0n, 0n, 0n, 0n] + } + if (request.functionName === 'getTotalRepBalance') return 100n + if (request.functionName === 'poolOwnershipDenominator') return 10n + if (request.functionName === 'getOutcomeLabels') return ['Yes', 'No'] + throw new Error(`Unexpected readContract function: ${request.functionName}`) + }, + }) + + const page = await loadSecurityPoolPage(client, 0, 1, viewerVaultAddress) + const [pool] = page.pools + if (pool === undefined) throw new Error('Expected one paged security pool') + + expect(capturedVaultRangeArgs).toEqual([0n, 3n]) + expect(loadedVaultAddresses).toEqual([...previewVaultAddresses, viewerVaultAddress]) + expect(pool.vaults.map(vault => vault.vaultAddress)).toEqual([...previewVaultAddresses, viewerVaultAddress]) + expect(pool.vaults.at(-1)?.repDepositShare).toBe(10n) + expect(pool.vaultCount).toBe(5n) + }) + test('loadAllSecurityPools defers vault detail loading for unselected pools in selected mode', async () => { const questionId = 1n const questionTuple = ['Question', 'Description', 1n, 2n, 2n, 0n, 100n, ''] as const @@ -388,12 +459,12 @@ describe('contracts helpers', () => { }, ] } - if (request.functionName === 'getVaultCount') { + if (request.functionName === 'getVaultCount' || request.functionName === 'getActiveVaultCount') { const address = Reflect.get(request, 'address') if (typeof address !== 'string') throw new Error('Expected security pool address') return getAddress(address) === securityPoolAddress ? 1n : 2n } - if (request.functionName === 'getVaults') { + if (request.functionName === 'getVaults' || request.functionName === 'getActiveVaults') { const address = Reflect.get(request, 'address') if (typeof address !== 'string') throw new Error('Expected security pool address') const normalizedAddress = getAddress(address) @@ -401,6 +472,8 @@ describe('contracts helpers', () => { if (normalizedAddress === alternateSecurityPoolAddress) throw new Error('Unexpected vault load for unselected pool') return [vaultAddress] } + if (request.functionName === 'getTotalRepBalance') return 5n + if (request.functionName === 'poolOwnershipDenominator') return 1n if (request.functionName === 'getOutcomeLabels') return ['Yes', 'No'] throw new Error(`Unexpected readContract function: ${request.functionName}`) }, @@ -425,6 +498,70 @@ describe('contracts helpers', () => { expect(deferredPool.vaultCount).toBe(2n) }) + test('loadOracleManagerDetails caps active staged operation previews and preserves the pending slot outside the preview window', async () => { + const managerAddress = getAddress('0x00000000000000000000000000000000000000d4') + const pendingOperationSlotId = 12n + const previewOperationIds = Array.from({ length: 25 }, (_, index) => 40n - BigInt(index)) + let capturedActiveOperationArgs: readonly [bigint, bigint] | undefined + const client = createMockLoaderClient({ + getBlock: async () => createBlockWithTimestamp(0n), + 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] + }, + readContract: async request => { + if (request.functionName === 'getActiveStagedOperations') { + const args = request.args + if (args === undefined) throw new Error('Expected getActiveStagedOperations args') + const startIndex = args[0] + const count = args[1] + if (typeof startIndex !== 'bigint' || typeof count !== 'bigint') throw new Error('Expected bigint staged operation args') + capturedActiveOperationArgs = [startIndex, count] + return [ + previewOperationIds, + previewOperationIds.map(operationId => ({ + amount: operationId, + initiatorVault: vaultAddress, + operation: 1, + queuedAt: 0n, + snapshotDenominator: 0n, + snapshotTargetAllowance: 0n, + snapshotTargetOwnership: 0n, + snapshotTotalRep: 0n, + targetVault: vaultAddress, + validForSeconds: 60n, + })), + ] + } + if (request.functionName === 'getPendingOperationSlot') { + return { + amount: 999n, + initiatorVault: vaultAddress, + operation: 0, + queuedAt: 0n, + snapshotDenominator: 0n, + snapshotTargetAllowance: 0n, + snapshotTargetOwnership: 0n, + snapshotTotalRep: 0n, + targetVault: alternateSecurityPoolAddress, + validForSeconds: 60n, + } + } + throw new Error(`Unexpected readContract function: ${request.functionName}`) + }, + }) + + const details = await loadOracleManagerDetails(client, managerAddress) + + expect(capturedActiveOperationArgs).toEqual([0n, 25n]) + expect(details.activeStagedOperationCount).toBe(40n) + expect(details.pendingOperation?.operationId).toBe(pendingOperationSlotId) + expect(details.stagedOperations?.[0]?.operationId).toBe(40n) + expect(details.stagedOperations?.at(-1)?.operationId).toBe(pendingOperationSlotId) + expect(details.stagedOperations).toHaveLength(26) + }) + test('settleOracleReport sends settle with an explicit gas limit', async () => { let capturedData: Hex | undefined let capturedGas: bigint | undefined diff --git a/ui/ts/tests/forkAuctionChildPoolRecovery.test.tsx b/ui/ts/tests/forkAuctionChildPoolRecovery.test.tsx index b0d0199c..e682029e 100644 --- a/ui/ts/tests/forkAuctionChildPoolRecovery.test.tsx +++ b/ui/ts/tests/forkAuctionChildPoolRecovery.test.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' import { waitFor, within } from '@testing-library/dom' import { h, render } from 'preact' import { act } from 'preact/test-utils' -import { type Address, zeroAddress } from 'viem' +import { getAddress, type Address, zeroAddress } from 'viem' import type { ForkAuctionSectionProps } from '../types/components.js' import type { AccountState, ForkAuctionFormState } from '../types/app.js' import type { ForkAuctionDetails, ListedSecurityPool, MarketDetails } from '../types/contracts.js' @@ -21,12 +21,17 @@ const YES_TRUTH_AUCTION_ADDRESS: Address = '0x0000000000000000000000000000000000 const STALE_TRUTH_AUCTION_ADDRESS: Address = '0x0000000000000000000000000000000000000aa2' let recoveredPools: ListedSecurityPool[] = [] +let loadAllSecurityPoolsCallAddresses: (Address | undefined)[] = [] let loadForkAuctionDetailsCalls = 0 let childAuctionDetailsFactory = (securityPoolAddress: Address) => createChildAuctionDetails(securityPoolAddress) +const loadAllSecurityPoolsMock = mock(async (_client: unknown, options?: { accountAddress?: Address }) => { + loadAllSecurityPoolsCallAddresses.push(options?.accountAddress) + return recoveredPools +}) mock.module('../contracts.js', () => ({ ...actualContracts, - loadAllSecurityPools: mock(async () => recoveredPools), + loadAllSecurityPools: loadAllSecurityPoolsMock, loadForkAuctionDetails: mock(async (_client: unknown, securityPoolAddress: Address) => { loadForkAuctionDetailsCalls += 1 return childAuctionDetailsFactory(securityPoolAddress) @@ -232,6 +237,8 @@ describe('ForkAuctionSection child pool recovery', () => { beforeEach(() => { recoveredPools = [] + loadAllSecurityPoolsCallAddresses = [] + loadAllSecurityPoolsMock.mockClear() loadForkAuctionDetailsCalls = 0 childAuctionDetailsFactory = securityPoolAddress => createChildAuctionDetails(securityPoolAddress) cleanupDom = installDomEnvironment().cleanup @@ -326,4 +333,43 @@ describe('ForkAuctionSection child pool recovery', () => { expect(within(document.body).queryByRole('button', { name: `Copy address ${secondTruthAuctionAddress}` })).not.toBeNull() }) }) + + test('reloads recovered child pool previews when the connected wallet changes', async () => { + const firstWallet = getAddress('0x0000000000000000000000000000000000000ba1') + const secondWallet = getAddress('0x0000000000000000000000000000000000000ba2') + recoveredPools = [createChildPool()] + const renderedComponent = await renderIntoDocument( + h( + ForkAuctionSection, + createProps({ + accountState: createAccountState({ address: firstWallet }), + currentStageView: 'auction', + selectedStageView: 'auction', + }), + ), + ) + cleanupRenderedComponent = renderedComponent.cleanup + + await waitFor(() => { + expect(loadAllSecurityPoolsCallAddresses).toEqual([firstWallet]) + }) + + await act(() => { + render( + h( + ForkAuctionSection, + createProps({ + accountState: createAccountState({ address: secondWallet }), + currentStageView: 'auction', + selectedStageView: 'auction', + }), + ), + renderedComponent.container, + ) + }) + + await waitFor(() => { + expect(loadAllSecurityPoolsCallAddresses).toEqual([firstWallet, secondWallet]) + }) + }) }) diff --git a/ui/ts/tests/openOracle.test.ts b/ui/ts/tests/openOracle.test.ts index 1816b3f1..a2f4ebd0 100644 --- a/ui/ts/tests/openOracle.test.ts +++ b/ui/ts/tests/openOracle.test.ts @@ -938,6 +938,7 @@ describe('Open Oracle helpers', () => { expect(details.managerAddress).toBe(managerAddress) expect(details.openOracleAddress).toBe(getOpenOracleAddress()) + expect(details.activeStagedOperationCount).toBe(0n) expect(details.pendingReportId).toBe(0n) expect(details.pendingOperation).toBe(undefined) expect(details.pendingOperationSlotId).toBe(0n) @@ -976,6 +977,7 @@ describe('Open Oracle helpers', () => { const details = await loadOracleManagerDetails(uiReadClient, managerAddress) expect(details.pendingOperationSlotId).toBeGreaterThan(0n) + expect(details.activeStagedOperationCount).toBe(1n) expect(details.pendingOperation).toBeDefined() expect(details.pendingOperation?.operation).toBe('setSecurityBondsAllowance') expect(details.pendingOperation?.amount).toBe(0n) @@ -997,14 +999,19 @@ describe('Open Oracle helpers', () => { const secondResult = await queueOracleManagerOperation(uiWriteClient, managerAddress, 'liquidation', addressString(TEST_ADDRESSES[1]), 1n, DEFAULT_SELF_OPERATION_TIMEOUT_SECONDS) const details = await loadOracleManagerDetails(uiReadClient, managerAddress) const firstOperationId = firstResult.queuedOperation?.operationId + const secondOperationId = secondResult.queuedOperation?.operationId if (firstOperationId === undefined) throw new Error('Expected the first queued operation id to be defined') + if (secondOperationId === undefined) throw new Error('Expected the second queued operation id to be defined') expect(firstResult.queuedOperation?.isPendingSlot).toBe(true) expect(secondResult.queuedOperation).toBeDefined() expect(secondResult.queuedOperation?.isPendingSlot).toBe(false) expect(secondResult.queuedOperation?.operationId).toBeGreaterThan(firstOperationId) + expect(details.activeStagedOperationCount).toBe(2n) expect(details.pendingOperationSlotId).toBe(firstOperationId) expect(details.pendingOperation?.operationId).toBe(firstOperationId) + expect(details.stagedOperations?.map(operation => operation.operationId)).toEqual([secondOperationId, firstOperationId]) + expect(details.stagedOperations?.map(operation => operation.operation)).toEqual(['liquidation', 'setSecurityBondsAllowance']) }) test('submitted and settled reports are tracked in loadOpenOracleReportDetails', async () => { diff --git a/ui/ts/tests/protocolConfig.test.ts b/ui/ts/tests/protocolConfig.test.ts new file mode 100644 index 00000000..7a370a85 --- /dev/null +++ b/ui/ts/tests/protocolConfig.test.ts @@ -0,0 +1,74 @@ +/// + +import { afterEach, describe, expect, test } from 'bun:test' +import { DEFAULT_PROTOCOL_CONFIG, getProtocolConfig, validateProtocolConfig } from '@zoltar/shared/protocolConfig' + +const PROTOCOL_CONFIG_GLOBAL_KEY = '__ZOLTAR_PROTOCOL_CONFIG__' +const FORK_BURN_ENV = 'ZOLTAR_FORK_BURN_DIVISOR' +const FORK_THRESHOLD_ENV = 'ZOLTAR_FORK_THRESHOLD_DIVISOR' +const INITIAL_ESCALATION_DEPOSIT_ENV = 'ZOLTAR_INITIAL_ESCALATION_GAME_DEPOSIT' + +function getProcessEnv(name: string) { + const processValue = Reflect.get(globalThis, 'process') + if (typeof processValue !== 'object' || processValue === null) throw new Error('process is unavailable') + const envValue = Reflect.get(processValue, 'env') + if (typeof envValue !== 'object' || envValue === null) throw new Error('process.env is unavailable') + const rawValue = Reflect.get(envValue, name) + return typeof rawValue === 'string' ? rawValue : undefined +} + +function setProcessEnv(name: string, value: string | undefined) { + const processValue = Reflect.get(globalThis, 'process') + if (typeof processValue !== 'object' || processValue === null) throw new Error('process is unavailable') + const envValue = Reflect.get(processValue, 'env') + if (typeof envValue !== 'object' || envValue === null) throw new Error('process.env is unavailable') + if (value === undefined) { + Reflect.deleteProperty(envValue, name) + return + } + Reflect.set(envValue, name, value) +} + +const originalForkBurnDivisor = getProcessEnv(FORK_BURN_ENV) +const originalForkThresholdDivisor = getProcessEnv(FORK_THRESHOLD_ENV) +const originalInitialEscalationDeposit = getProcessEnv(INITIAL_ESCALATION_DEPOSIT_ENV) +const originalGlobalProtocolConfig = Reflect.get(globalThis, PROTOCOL_CONFIG_GLOBAL_KEY) + +describe('protocolConfig', () => { + afterEach(() => { + setProcessEnv(FORK_BURN_ENV, originalForkBurnDivisor) + setProcessEnv(FORK_THRESHOLD_ENV, originalForkThresholdDivisor) + setProcessEnv(INITIAL_ESCALATION_DEPOSIT_ENV, originalInitialEscalationDeposit) + if (originalGlobalProtocolConfig === undefined) { + Reflect.deleteProperty(globalThis, PROTOCOL_CONFIG_GLOBAL_KEY) + return + } + Reflect.set(globalThis, PROTOCOL_CONFIG_GLOBAL_KEY, originalGlobalProtocolConfig) + }) + + test('getProtocolConfig resolves defaults, environment values, global overrides, and explicit overrides in precedence order', () => { + setProcessEnv(FORK_BURN_ENV, '7') + setProcessEnv(FORK_THRESHOLD_ENV, '23') + setProcessEnv(INITIAL_ESCALATION_DEPOSIT_ENV, '4') + Reflect.set(globalThis, PROTOCOL_CONFIG_GLOBAL_KEY, { + forkBurnDivisor: '9', + initialEscalationGameDeposit: '5', + }) + + expect( + getProtocolConfig({ + forkThresholdDivisor: '11', + }), + ).toEqual({ + forkBurnDivisor: 9n, + forkThresholdDivisor: 11n, + initialEscalationGameDeposit: 5n, + }) + }) + + test('validateProtocolConfig rejects invalid economic bounds', () => { + expect(() => validateProtocolConfig({ ...DEFAULT_PROTOCOL_CONFIG, forkThresholdDivisor: 1n })).toThrow('forkThresholdDivisor must be greater than 1') + expect(() => validateProtocolConfig({ ...DEFAULT_PROTOCOL_CONFIG, forkBurnDivisor: 1n })).toThrow('forkBurnDivisor must be greater than 1') + expect(() => validateProtocolConfig({ ...DEFAULT_PROTOCOL_CONFIG, initialEscalationGameDeposit: 0n })).toThrow('initialEscalationGameDeposit must be greater than 0') + }) +}) diff --git a/ui/ts/tests/securityPoolWorkflowSection.test.tsx b/ui/ts/tests/securityPoolWorkflowSection.test.tsx index 5038ca01..a85835e4 100644 --- a/ui/ts/tests/securityPoolWorkflowSection.test.tsx +++ b/ui/ts/tests/securityPoolWorkflowSection.test.tsx @@ -2227,6 +2227,7 @@ describe('SecurityPoolWorkflowSection', () => { {...createSecurityPoolWorkflowProps({ checkedSecurityPoolAddress: zeroAddress, poolOracleManagerDetails: { + activeStagedOperationCount: 4n, callbackStateHash: undefined, exactToken1Report: undefined, isPriceValid: true, @@ -2260,6 +2261,7 @@ describe('SecurityPoolWorkflowSection', () => { const documentQueries = within(document.body) expect(documentQueries.getByText('Withdraw REP')).not.toBeNull() expect(documentQueries.getByText('7')).not.toBeNull() + expect(documentQueries.getByText('Showing 1 of 4 active staged operations, newest first.')).not.toBeNull() expect(documentQueries.queryByText('Pending Price Request')).toBeNull() }) diff --git a/ui/ts/tests/securityPoolsOverviewSection.test.tsx b/ui/ts/tests/securityPoolsOverviewSection.test.tsx index 3522750f..8287002b 100644 --- a/ui/ts/tests/securityPoolsOverviewSection.test.tsx +++ b/ui/ts/tests/securityPoolsOverviewSection.test.tsx @@ -525,7 +525,7 @@ describe('SecurityPoolsOverviewSection', () => { createSecurityPool({ marketDetails: createMarketDetails({ title: 'Pool with preview vaults' }), securityPoolAddress: '0x0000000000000000000000000000000000000500', - vaultCount: 1n, + vaultCount: 5n, vaults: [ { lockedRepInEscalationGame: 0n, @@ -547,5 +547,112 @@ describe('SecurityPoolsOverviewSection', () => { expect(poolCardQueries.queryByText('Open this pool to load 1 vault.')).toBeNull() expect(poolCardQueries.getAllByRole('button', { name: 'Copy address 0x0000000000000000000000000000000000000501' }).length).toBeGreaterThan(0) expect(poolCardQueries.getByRole('button', { name: 'Liquidate Vault' })).not.toBeNull() + expect(poolCardQueries.getByText('Showing 1 of 5 active vaults in this preview, newest activity first.')).not.toBeNull() + expect(poolCardQueries.getByText('+4 more vaults')).not.toBeNull() + }) + + test('preserves the loader-provided vault preview order instead of re-ranking by allowance', async () => { + const renderedComponent = await renderIntoDocument( + , + ) + cleanupRenderedComponent = renderedComponent.cleanup + + const poolCard = getSecurityPoolCard('Ordered vault preview pool') + const previewRows = Array.from(poolCard.querySelectorAll('.security-pool-browse-vault-row')) + expect(previewRows).toHaveLength(3) + const previewAddresses = previewRows.map(row => { + if (!(row instanceof HTMLElement)) throw new Error('Expected vault preview row element') + const copyButton = within(row).getByRole('button', { name: /Copy address / }) + return copyButton.getAttribute('aria-label')?.replace('Copy address ', '') + }) + expect(previewAddresses).toEqual(['0x0000000000000000000000000000000000000701', '0x0000000000000000000000000000000000000702', '0x0000000000000000000000000000000000000703']) + }) + + test('keeps the connected wallet vault visible when it is appended outside the top browse preview', async () => { + const viewerVaultAddress = '0x0000000000000000000000000000000000000604' + const renderedComponent = await renderIntoDocument( + , + ) + cleanupRenderedComponent = renderedComponent.cleanup + + const poolCard = getSecurityPoolCard('Viewer vault preview pool') + const poolCardQueries = within(poolCard) + expect(poolCardQueries.getAllByRole('button', { name: `Copy address ${viewerVaultAddress}` }).length).toBeGreaterThan(0) + expect(poolCardQueries.getByText('Showing 4 of 6 active vaults in this preview, newest activity first.')).not.toBeNull() + expect(poolCardQueries.getByText('+2 more vaults')).not.toBeNull() }) }) diff --git a/ui/ts/types/contracts.ts b/ui/ts/types/contracts.ts index ae703de2..7ade5c98 100644 --- a/ui/ts/types/contracts.ts +++ b/ui/ts/types/contracts.ts @@ -181,6 +181,7 @@ export type SecurityVaultActionResult = ActionResult & { } export type OracleManagerDetails = { + activeStagedOperationCount?: bigint callbackStateHash: Hex | undefined exactToken1Report: bigint | undefined isPriceValid: boolean @@ -193,6 +194,7 @@ export type OracleManagerDetails = { pendingReportId: bigint priceValidUntilTimestamp: bigint | undefined requestPriceEthCost: bigint + stagedOperations?: StagedOracleOperation[] token1: Address | undefined token2: Address | undefined }