From 65bcfcaeb31d83f1a308bf19b6b3de213c3faa86 Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:53:57 +0000 Subject: [PATCH 1/4] Fix SecurityPool fee rounding residue accounting - Preserve fee and fee-index remainders across collateral updates - Add fork migration coverage for repeated public collateral updates - Refresh mainnet deployment addresses for SecurityPool components --- docs/mainnet-deployment-addresses.json | 4 +-- .../contracts/peripherals/SecurityPool.sol | 11 +++++-- solidity/ts/tests/peripherals/fixture.ts | 3 ++ .../tests/peripherals/forkMigration.test.ts | 29 +++++++++++++++++-- .../simulator/utils/contracts/securityPool.ts | 11 +++++++ 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/docs/mainnet-deployment-addresses.json b/docs/mainnet-deployment-addresses.json index 863a9fac..100d9ee1 100644 --- a/docs/mainnet-deployment-addresses.json +++ b/docs/mainnet-deployment-addresses.json @@ -13,7 +13,7 @@ { "id": "deploymentStatusOracle", "label": "Deployment Status Oracle", - "address": "0xFc6aE690a62398A427b05C929c718d7E3702F070" + "address": "0xB6B37eC271F199d1643c376c64ab4CF8727e7AC7" }, { "id": "multicall3", @@ -73,7 +73,7 @@ { "id": "securityPoolFactory", "label": "Security Pool Factory", - "address": "0xE4232859250cB576F8C8955466798c922D359C66" + "address": "0x9aF172B18aa2E3045DAD138613106cB51bAD5949" } ], "derivedContracts": [ diff --git a/solidity/contracts/peripherals/SecurityPool.sol b/solidity/contracts/peripherals/SecurityPool.sol index e6e6bd37..3fcc6281 100644 --- a/solidity/contracts/peripherals/SecurityPool.sol +++ b/solidity/contracts/peripherals/SecurityPool.sol @@ -54,6 +54,8 @@ contract SecurityPool is ISecurityPool { uint256 public totalFeesOwedToVaults; uint256 public lastUpdatedFeeAccumulator; uint256 public feeIndex; + uint256 private feeIndexRemainder; + uint256 private totalFeesOwedRemainder; uint256 public currentRetentionRate; bool public awaitingForkContinuation; @@ -295,8 +297,13 @@ contract SecurityPool is ISecurityPool { SecurityPoolUtils.rpow(currentRetentionRate, timeDelta, SecurityPoolUtils.PRICE_PRECISION)) / SecurityPoolUtils.PRICE_PRECISION; uint256 delta = completeSetCollateralAmount - newCompleteSetCollateralAmount; - totalFeesOwedToVaults += delta; - feeIndex += (delta * SecurityPoolUtils.PRICE_PRECISION) / totalSecurityBondAllowance; + uint256 scaledFeeDelta = (delta * SecurityPoolUtils.PRICE_PRECISION) + feeIndexRemainder; + uint256 feeIndexDelta = scaledFeeDelta / totalSecurityBondAllowance; + feeIndexRemainder = scaledFeeDelta % totalSecurityBondAllowance; + feeIndex += feeIndexDelta; + uint256 feesOwedDelta = (feeIndexDelta * totalSecurityBondAllowance) + totalFeesOwedRemainder; + totalFeesOwedToVaults += feesOwedDelta / SecurityPoolUtils.PRICE_PRECISION; + totalFeesOwedRemainder = feesOwedDelta % SecurityPoolUtils.PRICE_PRECISION; completeSetCollateralAmount = newCompleteSetCollateralAmount; lastUpdatedFeeAccumulator = feeEndDate < block.timestamp ? feeEndDate : block.timestamp; diff --git a/solidity/ts/tests/peripherals/fixture.ts b/solidity/ts/tests/peripherals/fixture.ts index 64e3567a..e6d60cc0 100644 --- a/solidity/ts/tests/peripherals/fixture.ts +++ b/solidity/ts/tests/peripherals/fixture.ts @@ -73,6 +73,7 @@ import { redeemRep, redeemShares, sharesToCash, + updateCollateralAmount, updateVaultFees, withdrawFromEscalationGame, } from '../../testsuite/simulator/utils/contracts/securityPool' @@ -396,6 +397,7 @@ function usePeripheralsTestFixture() { redeemRep, redeemShares, sharesToCash, + updateCollateralAmount, updateVaultFees, withdrawFromEscalationGame, peripherals_EscalationGame_EscalationGame, @@ -676,6 +678,7 @@ export function usePeripheralsForkMigrationFixture() { 'redeemRep', 'redeemShares', 'sharesToCash', + 'updateCollateralAmount', 'updateVaultFees', 'withdrawFromEscalationGame', 'peripherals_EscalationGame_EscalationGame', diff --git a/solidity/ts/tests/peripherals/forkMigration.test.ts b/solidity/ts/tests/peripherals/forkMigration.test.ts index 359e8751..63274f52 100644 --- a/solidity/ts/tests/peripherals/forkMigration.test.ts +++ b/solidity/ts/tests/peripherals/forkMigration.test.ts @@ -92,6 +92,7 @@ describe('Peripherals: fork migration', () => { redeemRep, redeemShares, sharesToCash, + updateCollateralAmount, updateVaultFees, withdrawFromEscalationGame, peripherals_EscalationGame_EscalationGame, @@ -519,6 +520,28 @@ describe('Peripherals: fork migration', () => { strictEqualTypeSafe(contractBalance + ethBalanceAfter - ethBalanceBefore, openInterestAmount, 'contract balance + fees should equal initial open interest') }) + test('frequent public collateral updates do not strand extra fee residue', async () => { + const securityPoolAllowance = repDeposit / 4n + 1n + await manipulatePriceOracleAndPerformOperation(client, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.SetSecurityBondsAllowance, client.account.address, securityPoolAllowance) + + const openInterestAmount = 100n * 10n ** 18n + await createCompleteSet(client, securityPoolAddresses.securityPool, openInterestAmount) + + const splitUpdateCount = 128n + for (let index = 1n; index <= splitUpdateCount; index++) { + await mockWindow.advanceTime(1n) + await updateCollateralAmount(client, securityPoolAddresses.securityPool) + } + await updateVaultFees(client, securityPoolAddresses.securityPool, client.account.address) + + const splitVault = await getSecurityVault(client, securityPoolAddresses.securityPool, client.account.address) + const totalFeesOwed = await getTotalFeesOwedToVaults(client, securityPoolAddresses.securityPool) + + assert.ok(totalFeesOwed > 0n, 'repeated public collateral updates should accrue nonzero fees in this setup') + strictEqualTypeSafe(totalFeesOwed, splitVault.unpaidEthFees, 'pool fee accounting should only record fees that the vault index can actually credit') + await redeemFees(client, securityPoolAddresses.securityPool, client.account.address) + }) + test('redeemCompleteSet exits at the fee-adjusted share exchange rate', async () => { const securityPoolAllowance = repDeposit / 4n await manipulatePriceOracleAndPerformOperation(client, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.SetSecurityBondsAllowance, client.account.address, securityPoolAllowance) @@ -549,9 +572,10 @@ describe('Peripherals: fork migration', () => { const firstHolderSharesAfterRedeem = await balanceOfShares(firstHolder, securityPoolAddresses.shareToken, genesisUniverse, firstHolder.account.address) const secondHolderSharesAfterRedeem = await balanceOfShares(secondHolder, securityPoolAddresses.shareToken, genesisUniverse, secondHolder.account.address) const shareSupplyAfterRedeem = await getShareTokenSupply(client, securityPoolAddresses.securityPool) + const feeDustTolerance = securityPoolAllowance / PRICE_PRECISION assert.ok(firstHolderPayout > 0n, 'redeeming complete sets should pay ETH to the holder') - strictEqualTypeSafe(collateralAfterRedeem + firstHolderPayout + feeDelta, initialCollateral, 'complete-set redemption should conserve collateral after fee accrual') + approximatelyEqual(collateralAfterRedeem + firstHolderPayout + feeDelta, initialCollateral, feeDustTolerance, 'complete-set redemption should conserve collateral after fee accrual up to bounded fee dust') strictEqualTypeSafe(shareSupplyAfterRedeem, initialShareSupply - redeemAmount, 'complete-set redemption should reduce share supply by the burned set amount') strictEqualTypeSafe(firstHolderSharesAfterRedeem[0], firstHolderShares[0] - redeemAmount, 'redeeming should burn the holders invalid-side share') strictEqualTypeSafe(firstHolderSharesAfterRedeem[1], firstHolderShares[1] - redeemAmount, 'redeeming should burn the holders yes-side share') @@ -669,9 +693,10 @@ describe('Peripherals: fork migration', () => { const feesAfterFirstRedemption = await getTotalFeesOwedToVaults(client, securityPoolAddresses.securityPool) const firstHolderPayout = (await getETHBalance(client, firstHolder.account.address)) - firstHolderBalanceBeforeRedemption const feeDelta = feesAfterFirstRedemption - initialFeesOwed + const feeDustTolerance = securityPoolAllowance / PRICE_PRECISION assert.ok(feeDelta > 0n, 'first redemption should accrue open-interest fees') - strictEqualTypeSafe(collateralAfterFirstRedemption + firstHolderPayout + feeDelta, initialCollateral, 'collateral should shrink by fees and first winning redemption') + approximatelyEqual(collateralAfterFirstRedemption + firstHolderPayout + feeDelta, initialCollateral, feeDustTolerance, 'collateral should shrink by fees and first winning redemption up to bounded fee dust') strictEqualTypeSafe(await getShareTokenSupply(client, securityPoolAddresses.securityPool), initialShareSupply - firstWinningShares, 'share supply should shrink after first winning redemption') approximatelyEqual(await sharesToCash(client, securityPoolAddresses.securityPool, secondWinningShares), collateralAfterFirstRedemption, 10n, 'remaining winning shares should not be double counted') diff --git a/solidity/ts/testsuite/simulator/utils/contracts/securityPool.ts b/solidity/ts/testsuite/simulator/utils/contracts/securityPool.ts index 0b98c834..8bc0a925 100644 --- a/solidity/ts/testsuite/simulator/utils/contracts/securityPool.ts +++ b/solidity/ts/testsuite/simulator/utils/contracts/securityPool.ts @@ -87,6 +87,17 @@ export const createCompleteSet = async (client: WriteClient, securityPoolAddress }), ) +export const updateCollateralAmount = async (client: WriteClient, securityPoolAddress: Address) => + await writeContractAndWait(client, () => + client.writeContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'updateCollateralAmount', + address: securityPoolAddress, + args: [], + gas: HIGH_GAS_SIMULATOR_WRITE_GAS, + }), + ) + export const redeemCompleteSet = async (client: WriteClient, securityPoolAddress: Address, completeSetsToRedeem: bigint) => await writeContractAndWait(client, () => client.writeContract({ From 66b4c1a9662fe79795ce35344a949d3619becd8d Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:56:34 +0000 Subject: [PATCH 2/4] Fix SecurityPool fee residue settlement --- docs/mainnet-deployment-addresses.json | 4 ++-- solidity/contracts/peripherals/SecurityPool.sol | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/mainnet-deployment-addresses.json b/docs/mainnet-deployment-addresses.json index d5e209f8..0d1a06fd 100644 --- a/docs/mainnet-deployment-addresses.json +++ b/docs/mainnet-deployment-addresses.json @@ -13,7 +13,7 @@ { "id": "deploymentStatusOracle", "label": "Deployment Status Oracle", - "address": "0x987dfc3E454042494E2e4c59444Dc29168b64901" + "address": "0x269cF9C4275CB8850a2a0eC1820e73e9E66Ac79A" }, { "id": "multicall3", @@ -73,7 +73,7 @@ { "id": "securityPoolFactory", "label": "Security Pool Factory", - "address": "0xdb684571949545A881Ceef33d530F2cb72eFe616" + "address": "0x98CcabbA4d4C53cF89Ac4D7ecE593d4d38b83960" } ], "derivedContracts": [ diff --git a/solidity/contracts/peripherals/SecurityPool.sol b/solidity/contracts/peripherals/SecurityPool.sol index 6a175313..e8754c4c 100644 --- a/solidity/contracts/peripherals/SecurityPool.sol +++ b/solidity/contracts/peripherals/SecurityPool.sol @@ -55,6 +55,8 @@ contract SecurityPool is ISecurityPool { uint256 public lastUpdatedFeeAccumulator; uint256 public feeIndex; uint256 private feeIndexRemainder; + // This carry is always below PRICE_PRECISION, so any residual value left here at the + // end of accrual is strictly sub-wei and cannot strand whole ETH. uint256 private totalFeesOwedRemainder; uint256 public currentRetentionRate; bool public awaitingForkContinuation; @@ -161,7 +163,7 @@ contract SecurityPool is ISecurityPool { } modifier onlyValidOracle() { - require(msg.sender == address(priceOracleManagerAndOperatorQueuer), 'Only oracle queue'); + require(msg.sender == address(priceOracleManagerAndOperatorQueuer), 'Only oracle'); require(priceOracleManagerAndOperatorQueuer.isPriceValid(), 'Oracle price is stale'); _; } @@ -349,12 +351,14 @@ contract SecurityPool is ISecurityPool { function _sweepFeeResidueIfAllVaultFeesSettled() internal { if (totalFeesOwedToVaults == 0) return; - for (uint256 index = 0; index < vaults.length; index++) { - SecurityVault storage trackedVault = securityVaults[vaults[index]]; + address trackedVaultAddress = latestActiveVault; + while (trackedVaultAddress != address(0x0)) { + SecurityVault storage trackedVault = securityVaults[trackedVaultAddress]; if ( (trackedVault.securityBondAllowance > 0 && trackedVault.feeIndex != feeIndex) || trackedVault.unpaidEthFees > 0 ) return; + trackedVaultAddress = olderActiveVaults[trackedVaultAddress]; } uint256 sweptResidue = totalFeesOwedToVaults; totalFeesOwedToVaults = 0; From b65860348c1b9069f30a871ba2f05d15a034c38c Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:04:27 +0000 Subject: [PATCH 3/4] Fix fee rounding residue in SecurityPool - Distribute leftover whole wei at fee period end - Update regression tests for fee settlement and stale-price errors - Refresh mainnet deployment addresses --- docs/mainnet-deployment-addresses.json | 4 +- .../contracts/peripherals/SecurityPool.sol | 37 +++++++++++-------- .../tests/peripherals/forkMigration.test.ts | 10 ++++- solidity/ts/tests/securityRegression.test.ts | 4 +- 4 files changed, 33 insertions(+), 22 deletions(-) diff --git a/docs/mainnet-deployment-addresses.json b/docs/mainnet-deployment-addresses.json index 0d1a06fd..27782cb2 100644 --- a/docs/mainnet-deployment-addresses.json +++ b/docs/mainnet-deployment-addresses.json @@ -13,7 +13,7 @@ { "id": "deploymentStatusOracle", "label": "Deployment Status Oracle", - "address": "0x269cF9C4275CB8850a2a0eC1820e73e9E66Ac79A" + "address": "0xe9549a00283536448cDcDCF4a8F552feD102CBF3" }, { "id": "multicall3", @@ -73,7 +73,7 @@ { "id": "securityPoolFactory", "label": "Security Pool Factory", - "address": "0x98CcabbA4d4C53cF89Ac4D7ecE593d4d38b83960" + "address": "0x39cD7D5538f88A9280C54865DC84B4357DF4d6B7" } ], "derivedContracts": [ diff --git a/solidity/contracts/peripherals/SecurityPool.sol b/solidity/contracts/peripherals/SecurityPool.sol index e8754c4c..a453ed11 100644 --- a/solidity/contracts/peripherals/SecurityPool.sol +++ b/solidity/contracts/peripherals/SecurityPool.sol @@ -164,12 +164,12 @@ contract SecurityPool is ISecurityPool { modifier onlyValidOracle() { require(msg.sender == address(priceOracleManagerAndOperatorQueuer), 'Only oracle'); - require(priceOracleManagerAndOperatorQueuer.isPriceValid(), 'Oracle price is stale'); + require(priceOracleManagerAndOperatorQueuer.isPriceValid(), 'Stale price'); _; } modifier onlyForker() { - require(msg.sender == securityPoolForker, 'Only pool forker'); + require(msg.sender == securityPoolForker, 'Only forker'); _; } @@ -302,6 +302,11 @@ contract SecurityPool is ISecurityPool { uint256 scaledFeeDelta = (delta * SecurityPoolUtils.PRICE_PRECISION) + feeIndexRemainder; uint256 feeIndexDelta = scaledFeeDelta / totalSecurityBondAllowance; feeIndexRemainder = scaledFeeDelta % totalSecurityBondAllowance; + if (clampedCurrentTimestamp == feeEndDate && feeIndexRemainder >= SecurityPoolUtils.PRICE_PRECISION) { + uint256 nondistributableWholeWei = feeIndexRemainder / SecurityPoolUtils.PRICE_PRECISION; + completeSetCollateralAmount += nondistributableWholeWei; + feeIndexRemainder %= SecurityPoolUtils.PRICE_PRECISION; + } feeIndex += feeIndexDelta; uint256 feesOwedDelta = (feeIndexDelta * totalSecurityBondAllowance) + totalFeesOwedRemainder; totalFeesOwedToVaults += feesOwedDelta / SecurityPoolUtils.PRICE_PRECISION; @@ -370,7 +375,7 @@ contract SecurityPool is ISecurityPool { //////////////////////////////////////// function performWithdrawRep(address vault, uint256 repAmount) external isOperational onlyValidOracle { - require(!isEscalationResolved(), 'Question resolved'); + require(!isEscalationResolved(), 'Resolved'); if (address(escalationGame) != address(0x0)) { require(escalationGame.escrowedRepByVault(vault) == 0, 'Escrow locked'); } @@ -384,7 +389,7 @@ contract SecurityPool is ISecurityPool { uint256 totalRepBalance = getTotalRepBalance(); uint256 oldRep = poolOwnershipToRep(securityVaults[vault].poolOwnership); - require(oldRep >= withdrawRepAmount, 'Withdraw exceeds REP'); + require(oldRep >= withdrawRepAmount, 'Withdraw REP'); uint256 repEthPrice = priceOracleManagerAndOperatorQueuer.lastPrice(); _requireVaultBondCoverage(oldRep - withdrawRepAmount, securityVaults[vault].securityBondAllowance, repEthPrice); _requirePoolBondCoverage(totalRepBalance - withdrawRepAmount, totalSecurityBondAllowance, repEthPrice); @@ -488,7 +493,7 @@ contract SecurityPool is ISecurityPool { uint256 totalSecurityBondAllowanceValue, uint256 collateralAmount ) private pure { - require(totalSecurityBondAllowanceValue >= collateralAmount, 'Capacity exceeded'); + require(totalSecurityBondAllowanceValue >= collateralAmount, 'Over capacity'); } function sharesToCash(uint256 completeSetAmount) public view returns (uint256) { @@ -506,7 +511,7 @@ contract SecurityPool is ISecurityPool { } function depositRep(uint256 repAmount) external isOperational { - require(!isEscalationResolved(), 'Question resolved'); + require(!isEscalationResolved(), 'Resolved'); uint256 poolOwnership = repToPoolOwnership(repAmount); IERC20(address(repToken)).safeTransferFrom(msg.sender, address(this), repAmount); _trackVault(msg.sender); @@ -538,7 +543,7 @@ contract SecurityPool is ISecurityPool { uint256 snapshotTotalRep, uint256 snapshotDenominator ) external isOperational onlyValidOracle { - require(!isEscalationResolved(), 'Question resolved'); + require(!isEscalationResolved(), 'Resolved'); _trackVault(callerVault); updateVaultFees(targetVaultAddress); updateVaultFees(callerVault); @@ -570,7 +575,7 @@ contract SecurityPool is ISecurityPool { } uint256 maxDebtToMove = repairDebtToMove > snapshotTargetAllowance ? snapshotTargetAllowance : repairDebtToMove; uint256 debtToMove = debtAmount > maxDebtToMove ? maxDebtToMove : debtAmount; - require(debtToMove > 0, 'No debt to liquidate'); + require(debtToMove > 0, 'No debt'); uint256 repToMove = 0; uint256 ownershipToMove = repToPoolOwnership(repToMove); require( @@ -632,7 +637,7 @@ contract SecurityPool is ISecurityPool { address callerVault, uint256 amount ) external isOperational onlyValidOracle { - require(!isEscalationResolved(), 'Question resolved'); + require(!isEscalationResolved(), 'Resolved'); updateVaultFees(callerVault); uint256 oldAllowance = securityVaults[callerVault].securityBondAllowance; @@ -660,8 +665,8 @@ contract SecurityPool is ISecurityPool { function createCompleteSet() external payable isOperational { // Child pools mint complete sets only after migration and truth-auction // accounting have restored `SystemState.Operational`. - require(!isEscalationResolved(), 'Question resolved'); - require(msg.value > 0, 'ETH required'); + require(!isEscalationResolved(), 'Resolved'); + require(msg.value > 0, 'Need ETH'); updateCollateralAmount(); uint256 completeSetsToMint = cashToShares(msg.value); uint256 nextCompleteSetCollateralAmount = completeSetCollateralAmount + msg.value; @@ -727,7 +732,7 @@ contract SecurityPool is ISecurityPool { BinaryOutcomes.BinaryOutcome questionOutcome = ISecurityPoolForker(securityPoolForker).getQuestionOutcome(this); require(questionOutcome != BinaryOutcomes.BinaryOutcome.None, 'Question open'); BinaryOutcomes.BinaryOutcome withdrawalOutcome = BinaryOutcomes.BinaryOutcome(uint8(outcome)); - require(withdrawalOutcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid None outcome'); + require(withdrawalOutcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid outcome'); EscalationGame escalationGameContract = EscalationGame(payable(address(escalationGame))); address beneficiaryVault = address(0x0); @@ -737,7 +742,7 @@ contract SecurityPool is ISecurityPool { if (beneficiaryVault == address(0x0)) { beneficiaryVault = depositor; } - require(depositor == beneficiaryVault, 'Deposits need one vault'); + require(depositor == beneficiaryVault, 'One vault'); } _syncActiveVault(beneficiaryVault); } @@ -766,7 +771,7 @@ contract SecurityPool is ISecurityPool { ); require(depositedAmount > 0, 'No escalation deposit'); if (totalSecurityBondAllowance > 0) { - require(priceOracleManagerAndOperatorQueuer.isPriceValid(), 'Oracle price is stale'); + require(priceOracleManagerAndOperatorQueuer.isPriceValid(), 'Stale price'); } uint256 ownershipToEscrow = repToPoolOwnershipRoundUp(depositedAmount); uint256 currentRep = poolOwnershipToRep(securityVaults[msg.sender].poolOwnership); @@ -807,7 +812,7 @@ contract SecurityPool is ISecurityPool { ) external { require(address(escalationGame) != address(0x0), 'Game missing'); require(systemState == SystemState.Operational, 'Pool inactive'); - require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid None outcome'); + require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid outcome'); BinaryOutcomes.BinaryOutcome questionOutcome = ISecurityPoolForker(securityPoolForker).getQuestionOutcome(this); uint256 forkTime = zoltar.getForkTime(universeId); if ( @@ -825,7 +830,7 @@ contract SecurityPool is ISecurityPool { if (beneficiaryVault == address(0x0)) { beneficiaryVault = depositor; } - require(depositor == beneficiaryVault, 'Deposits need one vault'); + require(depositor == beneficiaryVault, 'One vault'); } _syncActiveVault(beneficiaryVault); } diff --git a/solidity/ts/tests/peripherals/forkMigration.test.ts b/solidity/ts/tests/peripherals/forkMigration.test.ts index e89b281e..aac63553 100644 --- a/solidity/ts/tests/peripherals/forkMigration.test.ts +++ b/solidity/ts/tests/peripherals/forkMigration.test.ts @@ -564,13 +564,16 @@ describe('Peripherals: fork migration', () => { await manipulatePriceOracleAndPerformOperation(client, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.SetSecurityBondsAllowance, client.account.address, securityPoolAllowance) const openInterestAmount = 100n * 10n ** 18n + const splitUpdateCount = 128n + const endTime = await getQuestionEndDate(client, questionId) + await mockWindow.setTime(endTime - splitUpdateCount - 10n) await createCompleteSet(client, securityPoolAddresses.securityPool, openInterestAmount) - const splitUpdateCount = 128n for (let index = 1n; index <= splitUpdateCount; index++) { await mockWindow.advanceTime(1n) await updateCollateralAmount(client, securityPoolAddresses.securityPool) } + await mockWindow.setTime(endTime + 10000n) await updateVaultFees(client, securityPoolAddresses.securityPool, client.account.address) const splitVault = await getSecurityVault(client, securityPoolAddresses.securityPool, client.account.address) @@ -579,6 +582,9 @@ describe('Peripherals: fork migration', () => { assert.ok(totalFeesOwed > 0n, 'repeated public collateral updates should accrue nonzero fees in this setup') strictEqualTypeSafe(totalFeesOwed, splitVault.unpaidEthFees, 'pool fee accounting should only record fees that the vault index can actually credit') await redeemFees(client, securityPoolAddresses.securityPool, client.account.address) + const contractBalance = await getETHBalance(client, securityPoolAddresses.securityPool) + const remainingCollateral = await getCompleteSetCollateralAmount(client, securityPoolAddresses.securityPool) + strictEqualTypeSafe(contractBalance, remainingCollateral + (await getTotalFeesOwedToVaults(client, securityPoolAddresses.securityPool)), 'final fee settlement should leave every remaining wei in either collateral or redeemable fees') }) test('frequent public collateral updates keep multi-vault fee accounting sweepable', async () => { @@ -1276,7 +1282,7 @@ describe('Peripherals: fork migration', () => { await triggerOwnGameFork(client, securityPoolAddresses.securityPool) strictEqualTypeSafe(await getSystemState(client, securityPoolAddresses.securityPool), SystemState.PoolForked, 'parent pool should enter PoolForked after the universe fork is activated') - await assert.rejects(depositRep(client, securityPoolAddresses.securityPool, 1n), /Universe already forked|Universe forked|Question resolved/) + await assert.rejects(depositRep(client, securityPoolAddresses.securityPool, 1n), /Universe already forked|Universe forked|Question resolved|Resolved/) await migrateRepToZoltar(client, securityPoolAddresses.securityPool, [QuestionOutcome.Yes]) await migrateVault(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes) diff --git a/solidity/ts/tests/securityRegression.test.ts b/solidity/ts/tests/securityRegression.test.ts index 04b88741..f7196f55 100644 --- a/solidity/ts/tests/securityRegression.test.ts +++ b/solidity/ts/tests/securityRegression.test.ts @@ -316,7 +316,7 @@ describe('security regression coverage', () => { await mockWindow.setTime(questionEndDate + 1n) assert.equal(await getIsPriceValid(client, securityPoolAddresses.priceOracleManagerAndOperatorQueuer), false) - await assert.rejects(depositToEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, initialEscalationGameDeposit), /Oracle price is stale/) + await assert.rejects(depositToEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, initialEscalationGameDeposit), /Oracle price is stale|Stale price/) }) test('large escalation deposits reject stale oracle prices while bond allowance is active', async () => { @@ -328,6 +328,6 @@ describe('security regression coverage', () => { await mockWindow.setTime(questionEndDate + 1n) assert.equal(await getIsPriceValid(client, securityPoolAddresses.priceOracleManagerAndOperatorQueuer), false) - await assert.rejects(depositToEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, largeEscalationGameDeposit), /Oracle price is stale/) + await assert.rejects(depositToEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, largeEscalationGameDeposit), /Oracle price is stale|Stale price/) }) }) From a50245b3c2a1ad363e2a595a4f8d4a3aeefeaba0 Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:18:03 +0000 Subject: [PATCH 4/4] Fix fee residue carryover in SecurityPool accounting - clear stale fee remainders on allowance changes and zero-allowance gaps - reconcile collateral after fee redemption to preserve pool balance - add regression tests for handoff and zero-allowance accrual --- docs/mainnet-deployment-addresses.json | 4 +- .../contracts/peripherals/SecurityPool.sol | 50 ++++++------ .../tests/peripherals/forkMigration.test.ts | 79 ++++++++++++++++++- .../tests/peripherals/vaultAccounting.test.ts | 10 +-- 4 files changed, 110 insertions(+), 33 deletions(-) diff --git a/docs/mainnet-deployment-addresses.json b/docs/mainnet-deployment-addresses.json index 27782cb2..efcb03b5 100644 --- a/docs/mainnet-deployment-addresses.json +++ b/docs/mainnet-deployment-addresses.json @@ -13,7 +13,7 @@ { "id": "deploymentStatusOracle", "label": "Deployment Status Oracle", - "address": "0xe9549a00283536448cDcDCF4a8F552feD102CBF3" + "address": "0x22D512D74a15b69D43e23B2fC7c899F0495d4525" }, { "id": "multicall3", @@ -73,7 +73,7 @@ { "id": "securityPoolFactory", "label": "Security Pool Factory", - "address": "0x39cD7D5538f88A9280C54865DC84B4357DF4d6B7" + "address": "0x1c157c8CF501aFeBa567514661a191dee26CEd82" } ], "derivedContracts": [ diff --git a/solidity/contracts/peripherals/SecurityPool.sol b/solidity/contracts/peripherals/SecurityPool.sol index a453ed11..88e27ec5 100644 --- a/solidity/contracts/peripherals/SecurityPool.sol +++ b/solidity/contracts/peripherals/SecurityPool.sol @@ -285,7 +285,6 @@ contract SecurityPool is ISecurityPool { } function updateCollateralAmount() public { - if (totalSecurityBondAllowance == 0) return; uint256 forkTime = zoltar.getForkTime(universeId); uint256 endTime = questionData.getQuestionEndDate(questionId); uint256 feeEndDate = forkTime == 0 ? endTime : forkTime; @@ -293,6 +292,11 @@ contract SecurityPool is ISecurityPool { if (lastUpdatedFeeAccumulator > clampedCurrentTimestamp) return; uint256 timeDelta = clampedCurrentTimestamp - lastUpdatedFeeAccumulator; if (timeDelta == 0) return; + if (totalSecurityBondAllowance == 0) { + _clearFeeIndexRemainder(); + lastUpdatedFeeAccumulator = feeEndDate < block.timestamp ? feeEndDate : block.timestamp; + return; + } uint256 newCompleteSetCollateralAmount = (completeSetCollateralAmount * @@ -302,16 +306,12 @@ contract SecurityPool is ISecurityPool { uint256 scaledFeeDelta = (delta * SecurityPoolUtils.PRICE_PRECISION) + feeIndexRemainder; uint256 feeIndexDelta = scaledFeeDelta / totalSecurityBondAllowance; feeIndexRemainder = scaledFeeDelta % totalSecurityBondAllowance; - if (clampedCurrentTimestamp == feeEndDate && feeIndexRemainder >= SecurityPoolUtils.PRICE_PRECISION) { - uint256 nondistributableWholeWei = feeIndexRemainder / SecurityPoolUtils.PRICE_PRECISION; - completeSetCollateralAmount += nondistributableWholeWei; - feeIndexRemainder %= SecurityPoolUtils.PRICE_PRECISION; - } feeIndex += feeIndexDelta; uint256 feesOwedDelta = (feeIndexDelta * totalSecurityBondAllowance) + totalFeesOwedRemainder; - totalFeesOwedToVaults += feesOwedDelta / SecurityPoolUtils.PRICE_PRECISION; + uint256 creditedFees = feesOwedDelta / SecurityPoolUtils.PRICE_PRECISION; + totalFeesOwedToVaults += creditedFees; totalFeesOwedRemainder = feesOwedDelta % SecurityPoolUtils.PRICE_PRECISION; - completeSetCollateralAmount = newCompleteSetCollateralAmount; + completeSetCollateralAmount -= creditedFees; lastUpdatedFeeAccumulator = feeEndDate < block.timestamp ? feeEndDate : block.timestamp; emit UpdateCollateralAmount( @@ -348,26 +348,26 @@ contract SecurityPool is ISecurityPool { uint256 fees = securityVaults[vault].unpaidEthFees; securityVaults[vault].unpaidEthFees = 0; totalFeesOwedToVaults -= fees; - _sweepFeeResidueIfAllVaultFeesSettled(); + _reconcileCollateralBalanceAfterFeeRedemption(fees); _syncActiveVault(vault); _sendEth(payable(vault), fees); emit RedeemFees(vault, fees, totalFeesOwedToVaults); } - function _sweepFeeResidueIfAllVaultFeesSettled() internal { - if (totalFeesOwedToVaults == 0) return; - address trackedVaultAddress = latestActiveVault; - while (trackedVaultAddress != address(0x0)) { - SecurityVault storage trackedVault = securityVaults[trackedVaultAddress]; - if ( - (trackedVault.securityBondAllowance > 0 && trackedVault.feeIndex != feeIndex) || - trackedVault.unpaidEthFees > 0 - ) return; - trackedVaultAddress = olderActiveVaults[trackedVaultAddress]; + function _reconcileCollateralBalanceAfterFeeRedemption(uint256 pendingFeePayout) internal { + uint256 balanceAfterPayout = address(this).balance - pendingFeePayout; + uint256 accountedBalance = completeSetCollateralAmount + totalFeesOwedToVaults; + if (balanceAfterPayout > accountedBalance) { + completeSetCollateralAmount += balanceAfterPayout - accountedBalance; } - uint256 sweptResidue = totalFeesOwedToVaults; - totalFeesOwedToVaults = 0; - completeSetCollateralAmount += sweptResidue; + } + + function _clearFeeIndexRemainder() internal { + // This carry is scoped to the allowance denominator that produced it. + // Collateral already retains the undistributed value, so once allowance + // ownership changes the old denominator-specific remainder must not be + // attributed to the new allowance holders. + feeIndexRemainder = 0; } //////////////////////////////////////// @@ -586,6 +586,7 @@ contract SecurityPool is ISecurityPool { ); // Update target's allowance based on snapshot to prevent blocking via allowance changes + _clearFeeIndexRemainder(); securityVaults[targetVaultAddress].securityBondAllowance = snapshotTargetAllowance - debtToMove; securityVaults[targetVaultAddress].poolOwnership -= ownershipToMove; securityVaults[callerVault].securityBondAllowance += debtToMove; @@ -641,6 +642,7 @@ contract SecurityPool is ISecurityPool { updateVaultFees(callerVault); uint256 oldAllowance = securityVaults[callerVault].securityBondAllowance; + _clearFeeIndexRemainder(); totalSecurityBondAllowance += amount; totalSecurityBondAllowance -= oldAllowance; securityVaults[callerVault].securityBondAllowance = amount; @@ -914,6 +916,9 @@ contract SecurityPool is ISecurityPool { require(vault != address(0x0), 'Zero vault'); _trackVault(vault); securityVaults[vault].poolOwnership = poolOwnership; + if (securityVaults[vault].securityBondAllowance != securityBondAllowance) { + _clearFeeIndexRemainder(); + } securityVaults[vault].securityBondAllowance = securityBondAllowance; securityVaults[vault].feeIndex = vaultFeeIndex; _syncActiveVault(vault); @@ -995,6 +1000,7 @@ contract SecurityPool is ISecurityPool { require(newTotalBondAllowance >= newCollateral, 'Bond below collateral'); completeSetCollateralAmount = newCollateral; totalSecurityBondAllowance = newTotalBondAllowance; + _clearFeeIndexRemainder(); emit PoolFinancialsSet(completeSetCollateralAmount, totalSecurityBondAllowance); } diff --git a/solidity/ts/tests/peripherals/forkMigration.test.ts b/solidity/ts/tests/peripherals/forkMigration.test.ts index aac63553..a30b6a2f 100644 --- a/solidity/ts/tests/peripherals/forkMigration.test.ts +++ b/solidity/ts/tests/peripherals/forkMigration.test.ts @@ -625,6 +625,77 @@ describe('Peripherals: fork migration', () => { strictEqualTypeSafe(await getTotalFeesOwedToVaults(client, securityPoolAddresses.securityPool), 0n, 'pool fee accounting should fully clear once every credited vault fee is redeemed') }) + test('allowance handoff does not pay old fee residue to the new vault', async () => { + const firstVaultAllowance = repDeposit / 4n + 1n + await manipulatePriceOracleAndPerformOperation(client, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.SetSecurityBondsAllowance, client.account.address, firstVaultAllowance) + + const openInterestAmount = 100n * 10n ** 18n + const splitUpdateCount = 128n + const endTime = await getQuestionEndDate(client, questionId) + await mockWindow.setTime(endTime - splitUpdateCount - 20n) + await createCompleteSet(client, securityPoolAddresses.securityPool, openInterestAmount) + + for (let index = 1n; index <= splitUpdateCount; index++) { + await mockWindow.advanceTime(1n) + await updateCollateralAmount(client, securityPoolAddresses.securityPool) + } + + await manipulatePriceOracleAndPerformOperation(client, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.SetSecurityBondsAllowance, client.account.address, 0n) + + const secondVaultClient = createWriteClient(mockWindow, TEST_ADDRESSES[1], 0) + await approveAndDepositRep(secondVaultClient, repDeposit, questionId) + const secondVaultAllowance = repDeposit / 4n + 3n + await manipulatePriceOracleAndPerformOperation(secondVaultClient, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.SetSecurityBondsAllowance, secondVaultClient.account.address, secondVaultAllowance) + + const collateralBeforeSecondAccrual = await getCompleteSetCollateralAmount(client, securityPoolAddresses.securityPool) + const retentionRate = await getCurrentRetentionRate(client, securityPoolAddresses.securityPool) + const expectedNextSecondDelta = collateralBeforeSecondAccrual - (collateralBeforeSecondAccrual * rpow(retentionRate, 1n, PRICE_PRECISION)) / PRICE_PRECISION + + await mockWindow.advanceTime(1n) + await updateVaultFees(secondVaultClient, securityPoolAddresses.securityPool, secondVaultClient.account.address) + + const secondVault = await getSecurityVault(secondVaultClient, securityPoolAddresses.securityPool, secondVaultClient.account.address) + assert.ok(secondVault.unpaidEthFees <= expectedNextSecondDelta, 'new allowance holder should not inherit denominator residue that accrued before the handoff') + }) + + test('zero-allowance gaps do not charge the next vault for idle time', async () => { + const firstVaultAllowance = repDeposit / 4n + 1n + await manipulatePriceOracleAndPerformOperation(client, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.SetSecurityBondsAllowance, client.account.address, firstVaultAllowance) + + const openInterestAmount = 100n * 10n ** 18n + const splitUpdateCount = 128n + const endTime = await getQuestionEndDate(client, questionId) + await mockWindow.setTime(endTime - splitUpdateCount - 40n) + await createCompleteSet(client, securityPoolAddresses.securityPool, openInterestAmount) + + for (let index = 1n; index <= splitUpdateCount; index++) { + await mockWindow.advanceTime(1n) + await updateCollateralAmount(client, securityPoolAddresses.securityPool) + } + + await manipulatePriceOracleAndPerformOperation(client, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.SetSecurityBondsAllowance, client.account.address, 0n) + const collateralAtZeroAllowance = await getCompleteSetCollateralAmount(client, securityPoolAddresses.securityPool) + + await mockWindow.advanceTime(30n) + await updateCollateralAmount(client, securityPoolAddresses.securityPool) + strictEqualTypeSafe(await getCompleteSetCollateralAmount(client, securityPoolAddresses.securityPool), collateralAtZeroAllowance, 'collateral should not decay while no vault backs the pool') + + const secondVaultClient = createWriteClient(mockWindow, TEST_ADDRESSES[1], 0) + await approveAndDepositRep(secondVaultClient, repDeposit, questionId) + const secondVaultAllowance = repDeposit / 4n + 3n + await manipulatePriceOracleAndPerformOperation(secondVaultClient, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.SetSecurityBondsAllowance, secondVaultClient.account.address, secondVaultAllowance) + + const collateralBeforeSecondAccrual = await getCompleteSetCollateralAmount(client, securityPoolAddresses.securityPool) + const retentionRate = await getCurrentRetentionRate(client, securityPoolAddresses.securityPool) + const expectedNextSecondDelta = collateralBeforeSecondAccrual - (collateralBeforeSecondAccrual * rpow(retentionRate, 1n, PRICE_PRECISION)) / PRICE_PRECISION + + await mockWindow.advanceTime(1n) + await updateVaultFees(secondVaultClient, securityPoolAddresses.securityPool, secondVaultClient.account.address) + + const secondVault = await getSecurityVault(secondVaultClient, securityPoolAddresses.securityPool, secondVaultClient.account.address) + assert.ok(secondVault.unpaidEthFees <= expectedNextSecondDelta, 'new allowance holder should only accrue fees for time after the zero-allowance gap ends') + }) + test('redeemCompleteSet exits at the fee-adjusted share exchange rate', async () => { const securityPoolAllowance = repDeposit / 4n await manipulatePriceOracleAndPerformOperation(client, mockWindow, securityPoolAddresses.priceOracleManagerAndOperatorQueuer, OperationType.SetSecurityBondsAllowance, client.account.address, securityPoolAllowance) @@ -1282,7 +1353,7 @@ describe('Peripherals: fork migration', () => { await triggerOwnGameFork(client, securityPoolAddresses.securityPool) strictEqualTypeSafe(await getSystemState(client, securityPoolAddresses.securityPool), SystemState.PoolForked, 'parent pool should enter PoolForked after the universe fork is activated') - await assert.rejects(depositRep(client, securityPoolAddresses.securityPool, 1n), /Universe already forked|Universe forked|Question resolved|Resolved/) + await assert.rejects(depositRep(client, securityPoolAddresses.securityPool, 1n), /Universe forked/) await migrateRepToZoltar(client, securityPoolAddresses.securityPool, [QuestionOutcome.Yes]) await migrateVault(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes) @@ -1331,7 +1402,7 @@ describe('Peripherals: fork migration', () => { const yesSecurityPool = getSecurityPoolAddresses(securityPoolAddresses.securityPool, yesUniverse, questionId, securityMultiplier) strictEqualTypeSafe(await getSystemState(client, yesSecurityPool.securityPool), SystemState.ForkMigration, 'child pool should wait in migration state before accounting is settled') - await assert.rejects(createCompleteSet(client, yesSecurityPool.securityPool, 1n), /Pool not operational|Pool inactive/) + await assert.rejects(createCompleteSet(client, yesSecurityPool.securityPool, 1n), /Pool not operational/) await mockWindow.advanceTime(8n * 7n * DAY + DAY) await startTruthAuction(client, yesSecurityPool.securityPool) @@ -1779,8 +1850,8 @@ describe('Peripherals: fork migration', () => { const clientVaultAfterFork = await getSecurityVault(client, securityPoolAddresses.securityPool, client.account.address) const attackerVaultAfterFork = await getSecurityVault(client, securityPoolAddresses.securityPool, attackerClient.account.address) - await assert.rejects(withdrawFromEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [0n]), /Pool not operational|Pool inactive/) - await assert.rejects(withdrawFromEscalationGame(attackerClient, securityPoolAddresses.securityPool, QuestionOutcome.No, [0n]), /Pool not operational|Pool inactive/) + await assert.rejects(withdrawFromEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [0n]), /Pool inactive/) + await assert.rejects(withdrawFromEscalationGame(attackerClient, securityPoolAddresses.securityPool, QuestionOutcome.No, [0n]), /Pool inactive/) const clientVaultAfterFailedWithdrawal = await getSecurityVault(client, securityPoolAddresses.securityPool, client.account.address) const attackerVaultAfterFailedWithdrawal = await getSecurityVault(client, securityPoolAddresses.securityPool, attackerClient.account.address) diff --git a/solidity/ts/tests/peripherals/vaultAccounting.test.ts b/solidity/ts/tests/peripherals/vaultAccounting.test.ts index 9bcb8272..ab4f18ca 100644 --- a/solidity/ts/tests/peripherals/vaultAccounting.test.ts +++ b/solidity/ts/tests/peripherals/vaultAccounting.test.ts @@ -343,7 +343,7 @@ describe('Peripherals: vault accounting', () => { // The Anvil harness mines mutating transactions one second after the latest block timestamp. // Setting time to endTime - 1 makes the next transaction execute exactly at endTime. await mockWindow.setTime(endTime - 1n) - await assert.rejects(depositToEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, reportBond), /Question still active|Question active/) + await assert.rejects(depositToEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, reportBond), /Question active/) // Resetting to endTime makes the next transaction execute at endTime + 1, the first valid second. await mockWindow.setTime(endTime) @@ -516,7 +516,7 @@ describe('Peripherals: vault accounting', () => { await finalizeQuestionAsYesWithoutFork() const walletRepBeforeRedeem = await getERC20Balance(client, addressString(GENESIS_REPUTATION_TOKEN), client.account.address) - await assert.rejects(redeemRep(client, securityPoolAddresses.securityPool, client.account.address), /Escalation deposits locked|Escrow locked/) + await assert.rejects(redeemRep(client, securityPoolAddresses.securityPool, client.account.address), /Escrow locked/) await withdrawFromEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [0n]) const vaultAfterSettlement = await getSecurityVault(client, securityPoolAddresses.securityPool, client.account.address) @@ -765,8 +765,8 @@ describe('Peripherals: vault accounting', () => { await forkUniverse(attackerClient, genesisUniverse, otherQuestionId) strictEqualTypeSafe(await getQuestionOutcome(client, securityPoolAddresses.securityPool), QuestionOutcome.None, 'external fork should leave the parent question unresolved') - await assert.rejects(withdrawFromEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [aliceDeposit.depositIndex]), /Forked deposits must migrate first|Migrate deposits first/) - await assert.rejects(withdrawFromEscalationGame(attackerClient, securityPoolAddresses.securityPool, QuestionOutcome.No, [bobDeposit.depositIndex]), /Forked deposits must migrate first|Migrate deposits first/) + await assert.rejects(withdrawFromEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [aliceDeposit.depositIndex]), /Migrate deposits first/) + await assert.rejects(withdrawFromEscalationGame(attackerClient, securityPoolAddresses.securityPool, QuestionOutcome.No, [bobDeposit.depositIndex]), /Migrate deposits first/) const aliceVaultAfter = await getSecurityVault(client, securityPoolAddresses.securityPool, client.account.address) const bobVaultAfter = await getSecurityVault(client, securityPoolAddresses.securityPool, attackerClient.account.address) @@ -828,7 +828,7 @@ describe('Peripherals: vault accounting', () => { await approveToken(attackerClient, addressString(GENESIS_REPUTATION_TOKEN), getZoltarAddress()) await forkUniverse(attackerClient, genesisUniverse, otherQuestionId) - await assert.rejects(withdrawFromEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.None, [0n]), /Invalid None outcome/) + await assert.rejects(withdrawFromEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.None, [0n]), /Invalid outcome/) }) test('losing escalation deposits can be settled after resolution and stop counting as locked collateral', async () => {