diff --git a/solidity/contracts/peripherals/EscalationGame.sol b/solidity/contracts/peripherals/EscalationGame.sol index fd67e1c6..10b4ffa8 100644 --- a/solidity/contracts/peripherals/EscalationGame.sol +++ b/solidity/contracts/peripherals/EscalationGame.sol @@ -1,10 +1,19 @@ // SPDX-License-Identifier: Unlicense pragma solidity 0.8.35; -import { ReputationToken } from '../ReputationToken.sol'; -import { Zoltar } from '../Zoltar.sol'; import { ISecurityPool } from './interfaces/ISecurityPool.sol'; import { BinaryOutcomes } from './BinaryOutcomes.sol'; +import { MerkleMountainRange } from './MerkleMountainRange.sol'; + +uint256 constant ESCALATION_TIME_LENGTH = 4233600; // 7 weeks +uint256 constant SCALE = 1e6; +uint256 constant LN2_SCALED = 693147; +uint256 constant MAX_ATANH_ITERATIONS = 16; +uint256 constant MAX_EXP_ITERATIONS = 16; +uint256 constant EXCESS_REWARD_WINDOW_DIVISOR = 2; +uint256 constant FORK_CONTINUATION_LOCAL_DEPOSIT_INDEX_PREFIX = 1 << 255; +uint256 constant MERKLE_MOUNTAIN_RANGE_MAX_PEAKS = 64; +uint256 constant NULLIFIER_DEPTH = 64; struct Deposit { address depositor; @@ -12,73 +21,118 @@ struct Deposit { uint256 cumulativeAmount; } -struct ImportedDeposit { +struct CarryLeafView { address depositor; uint256 amount; + uint256 parentDepositIndex; uint256 cumulativeAmount; - bool settled; + uint256 sourceNodeId; } struct OutcomeState { - // Total principal currently assigned to this outcome, including imported continuation deposits. + // Snapshot fields are the inherited proof baseline for this outcome. + // currentNullifierRoot tracks which inherited proof indexes have been consumed in this instance. + // localHeadNodeId/localUnresolvedTotal track append-only local carry added after the inherited snapshot. + // Descendant carry export is rebuilt lazily from the snapshot plus unresolved local nodes. + // Total principal currently assigned to this outcome by local deposits placed directly in this escalation game. uint256 balance; // Local deposits placed directly in this escalation game, preserved in arrival order for payout ordering. Deposit[] deposits; - // [parentDepositIndex] => imported deposit record carried from a parent or ancestor continuation game. - mapping(uint256 => ImportedDeposit) importedDeposits; - // [fenwickNodeIndex] => Fenwick tree node sum used to compute imported principal before a given parentDepositIndex. - mapping(uint256 => uint256) importedPrefixTree; - // [depositor] => unsettled imported parentDepositIndexes owned by that depositor, used for bounded discovery and pagination. - mapping(address => uint256[]) unsettledImportedDepositIndexesByDepositor; - // [depositor][parentDepositIndex] => 1-based position inside unsettledImportedDepositIndexesByDepositor for O(1) swap-and-pop removal. - mapping(address => mapping(uint256 => uint256)) importedDepositorIndexPosition; - // Total imported principal tracked in this outcome's imported prefix accounting. - uint256 importedTotalAmount; - // Imported principal sitting at the sentinel max key, excluded from normal Fenwick prefix traversal. - uint256 importedMaxKeyAmount; - // Total imported continuation principal currently assigned to this outcome. - uint256 importedBalance; + // The inherited carry snapshot this escalation game started with for this outcome. + uint256 snapshotLeafCount; + bytes32[MERKLE_MOUNTAIN_RANGE_MAX_PEAKS] snapshotPeaks; + uint256 inheritedUnresolvedTotal; + // The current unresolved carry state after local and inherited deposits are consumed. + bytes32 currentNullifierRoot; + uint256 localHeadNodeId; + uint256 localUnresolvedTotal; + // Authoritative settled-set for inherited and local carried parentDepositIndexes in this instance. + mapping(uint256 => bool) consumedParentDepositIndexes; + // Enumerable mirror of consumed proof indexes used for recursive offchain proof reconstruction. + uint256[] proofConsumedDepositIndexes; } -uint256 constant escalationTimeLength = 4233600; // 7 weeks -uint256 constant SCALE = 1e6; -uint256 constant LN2_SCALED = 693147; -uint256 constant MAX_ATANH_ITERATIONS = 16; -uint256 constant MAX_EXP_ITERATIONS = 16; -uint256 constant EXCESS_REWARD_WINDOW_DIVISOR = 2; -uint256 constant FORK_CONTINUATION_LOCAL_DEPOSIT_INDEX_PREFIX = 1 << 255; +struct OutcomeStateView { + uint256 balance; + uint256 snapshotLeafCount; + bytes32[MERKLE_MOUNTAIN_RANGE_MAX_PEAKS] snapshotPeaks; + uint256 inheritedUnresolvedTotal; + bytes32 currentNullifierRoot; + uint256 localHeadNodeId; + uint256 currentLeafCount; + bytes32[MERKLE_MOUNTAIN_RANGE_MAX_PEAKS] currentPeaks; + uint256 localUnresolvedTotal; + bytes32 currentCarryRoot; + uint256 currentCarryTotal; +} + +struct Node { + // Previous unresolved node for this same outcome inside this escalation game instance. + uint256 parentNodeId; + address depositor; + BinaryOutcomes.BinaryOutcome outcome; + uint256 amount; + // Stable ordering key inherited from the source escalation game. + uint256 parentDepositIndex; + // Prefix-position data needed for payout-order proofs. + uint256 cumulativeAmount; +} + +struct CarriedDepositProof { + address depositor; + uint256 amount; + uint256 parentDepositIndex; + uint256 cumulativeAmount; + uint256 sourceNodeId; + uint256 leafIndex; + bytes32[] merkleMountainRangeSiblings; + uint256 merkleMountainRangePeakIndex; + bytes32[] nullifierSiblings; +} contract EscalationGame { uint256 public constant activationDelay = 3 days; uint256 public activationTime; - ISecurityPool public securityPool; + ISecurityPool public immutable securityPool; uint256 public nonDecisionThreshold; uint256 public startBond; uint256 public lnRatioScaled; - address public owner; + address public immutable owner; uint256 public nonDecisionTimestamp; bool public forkContinuation; - bool public forkContinuationResumed; uint256 public forkElapsedAtStart; uint256 public forkResumedAt; + bytes32 private immutable EMPTY_NULLIFIER_ROOT; // Outcome-indexed state uses 0 = Invalid, 1 = Yes, 2 = No. - // Each bucket owns its local deposits, imported continuation deposits, and aggregate balances. OutcomeState[3] private outcomeState; + uint256 public nextNodeId = 1; + mapping(uint256 => Node) public nodes; event GameStarted(uint256 activationTime, uint256 startBond, uint256 nonDecisionThreshold); event GameContinuedFromFork(uint256 startBond, uint256 nonDecisionThreshold, uint256 elapsedAtFork); + event ForkCarrySnapshotInitialized(uint256[3] snapshotLeafCounts, uint256[3] inheritedTotals, bytes32[3] inheritedNullifierRoots); event ForkContinuationResumed(uint256 resumedAt); event DepositOnOutcome(address depositor, BinaryOutcomes.BinaryOutcome outcome, uint256 amount, uint256 depositIndex, uint256 cumulativeAmount); event WithdrawDeposit(address depositor, BinaryOutcomes.BinaryOutcome outcome, uint256 amountToWithdraw, uint256 depositIndex); event ClaimDeposit(uint256 amountToWithdraw, uint256 burnAmount); - event ImportedForkDeposit(address depositor, BinaryOutcomes.BinaryOutcome outcome, uint256 parentDepositIndex, uint256 amount); + event LocalDepositAppended(uint256 indexed nodeId, BinaryOutcomes.BinaryOutcome outcome, address depositor, uint256 amount, uint256 parentDepositIndex, uint256 cumulativeAmount); + event CarriedDepositClaimed(BinaryOutcomes.BinaryOutcome outcome, address depositor, uint256 amount, uint256 parentDepositIndex, uint256 sourceNodeId, bytes32 leafHash); + + modifier onlySecurityPoolOrForker() { + require( + msg.sender == address(securityPool) || msg.sender == address(securityPool.securityPoolForker()), + 'Only Security Pool or designated forker' + ); + _; + } constructor(ISecurityPool _securityPool) { securityPool = _securityPool; owner = msg.sender; + EMPTY_NULLIFIER_ROOT = _computeEmptyNullifierRoot(); } - function start(uint256 _startBond, uint256 _nonDecisionThreshold) public { + function start(uint256 _startBond, uint256 _nonDecisionThreshold) external { require(owner == msg.sender, 'only owner can start'); require(activationTime == 0, 'already started'); require(_nonDecisionThreshold > _startBond, 'threshold must exceed start bond'); @@ -92,16 +146,15 @@ contract EscalationGame { emit GameStarted(activationTime, startBond, nonDecisionThreshold); } - function startFromFork(uint256 _startBond, uint256 _nonDecisionThreshold, uint256 elapsedAtFork) public { + function startFromFork(uint256 _startBond, uint256 _nonDecisionThreshold, uint256 elapsedAtFork) external { require(owner == msg.sender, 'only owner can start'); require(activationTime == 0, 'already started'); require(_nonDecisionThreshold > _startBond, 'threshold must exceed start bond'); require(_startBond > 0, 'start bond must be positive'); require(_startBond >= 1e18, 'start bond must be at least 1 ether'); require(_nonDecisionThreshold >= 1e18, 'threshold must be at least 1 ether'); - require(elapsedAtFork <= escalationTimeLength, 'Invalid time'); + require(elapsedAtFork <= ESCALATION_TIME_LENGTH, 'Invalid time'); forkContinuation = true; - forkContinuationResumed = false; forkElapsedAtStart = elapsedAtFork; startBond = _startBond; nonDecisionThreshold = _nonDecisionThreshold; @@ -109,35 +162,80 @@ contract EscalationGame { emit GameContinuedFromFork(startBond, nonDecisionThreshold, elapsedAtFork); } - function resumeFromFork() public { - require(owner == msg.sender || address(securityPool) == msg.sender, 'only owner can resume'); + function initializeForkCarrySnapshot(bytes32[MERKLE_MOUNTAIN_RANGE_MAX_PEAKS][3] memory snapshotPeaksInput, uint256[3] memory snapshotLeafCountsInput, uint256[3] memory snapshotCarryTotals, bytes32[3] memory snapshotNullifierRoots) external { + require(msg.sender == address(securityPool), 'only security pool can initialize carry snapshot'); require(forkContinuation, 'not fork continuation'); - require(!forkContinuationResumed, 'already resumed'); - forkContinuationResumed = true; - forkResumedAt = block.timestamp; - emit ForkContinuationResumed(block.timestamp); - } + require(!forkCarrySnapshotInitialized(), 'carry snapshot already initialized'); + + bytes32[3] memory normalizedNullifierRoots; + for (uint256 outcomeIndex = 0; outcomeIndex < 3; outcomeIndex++) { + OutcomeState storage state = outcomeState[outcomeIndex]; + bytes32 normalizedNullifierRoot = snapshotNullifierRoots[outcomeIndex] == bytes32(0) ? EMPTY_NULLIFIER_ROOT : snapshotNullifierRoots[outcomeIndex]; + normalizedNullifierRoots[outcomeIndex] = normalizedNullifierRoot; + state.currentNullifierRoot = normalizedNullifierRoot; + state.snapshotLeafCount = snapshotLeafCountsInput[outcomeIndex]; + for (uint256 peakIndex = 0; peakIndex < MERKLE_MOUNTAIN_RANGE_MAX_PEAKS; peakIndex++) { + bytes32 peak = snapshotPeaksInput[outcomeIndex][peakIndex]; + state.snapshotPeaks[peakIndex] = peak; + } + state.balance = snapshotCarryTotals[outcomeIndex]; + state.inheritedUnresolvedTotal = snapshotCarryTotals[outcomeIndex]; + } - function balances(uint256 outcomeIndex) public view returns (uint256) { - return outcomeState[outcomeIndex].balance; + emit ForkCarrySnapshotInitialized(snapshotLeafCountsInput, snapshotCarryTotals, normalizedNullifierRoots); } - function deposits(uint8 outcomeIndex, uint256 depositIndex) public view returns (address depositor, uint256 amount, uint256 cumulativeAmount) { - Deposit storage deposit = outcomeState[outcomeIndex].deposits[depositIndex]; - return (deposit.depositor, deposit.amount, deposit.cumulativeAmount); + function resumeFromFork() external { + require(owner == msg.sender || address(securityPool) == msg.sender, 'only owner can resume'); + require(forkContinuation, 'not fork continuation'); + require(forkResumedAt == 0, 'already resumed'); + forkResumedAt = block.timestamp; + emit ForkContinuationResumed(block.timestamp); } - function importedDeposits(uint256 outcomeIndex, uint256 parentDepositIndex) public view returns (address depositor, uint256 amount, uint256 cumulativeAmount, bool settled) { - ImportedDeposit storage deposit = outcomeState[outcomeIndex].importedDeposits[parentDepositIndex]; - return (deposit.depositor, deposit.amount, deposit.cumulativeAmount, deposit.settled); + function forkContinuationResumed() public view returns (bool) { + return forkResumedAt != 0; } - function importedBalances(uint256 outcomeIndex) public view returns (uint256) { - return outcomeState[outcomeIndex].importedBalance; + // Snapshot initialization is contract-wide, and outcome 0 is used as the sentinel because + // initializeForkCarrySnapshot() sets every outcome's nullifier root in the same loop. + function forkCarrySnapshotInitialized() public view returns (bool) { + return outcomeState[0].currentNullifierRoot != bytes32(0); } - function getBalances() public view returns (uint256[3] memory) { - return [outcomeState[0].balance, outcomeState[1].balance, outcomeState[2].balance]; + function getOutcomeState(BinaryOutcomes.BinaryOutcome outcome) external view returns (OutcomeStateView memory stateView) { + if (outcome == BinaryOutcomes.BinaryOutcome.None) { + stateView.currentNullifierRoot = EMPTY_NULLIFIER_ROOT; + return stateView; + } + uint8 outcomeIndex = uint8(outcome); + OutcomeState storage state = outcomeState[outcomeIndex]; + (bytes32[MERKLE_MOUNTAIN_RANGE_MAX_PEAKS] memory currentPeaks, uint256 currentLeafCount, bytes32 currentCarryRoot, uint256 currentCarryTotal) = _getMaterializedCarrySnapshot(outcomeIndex); + stateView.balance = state.balance; + stateView.snapshotLeafCount = state.snapshotLeafCount; + stateView.snapshotPeaks = state.snapshotPeaks; + stateView.inheritedUnresolvedTotal = state.inheritedUnresolvedTotal; + stateView.currentNullifierRoot = _getCurrentNullifierRoot(outcomeIndex); + stateView.localHeadNodeId = state.localHeadNodeId; + stateView.currentLeafCount = currentLeafCount; + stateView.currentPeaks = currentPeaks; + stateView.localUnresolvedTotal = state.localUnresolvedTotal; + stateView.currentCarryRoot = currentCarryRoot; + stateView.currentCarryTotal = currentCarryTotal; + } + + function getForkCarrySnapshot() external view returns ( + bytes32[MERKLE_MOUNTAIN_RANGE_MAX_PEAKS][3] memory carryPeaks, + uint256[3] memory carryLeafCounts, + uint256[3] memory carryTotals, + bytes32[3] memory nullifierRoots + ) { + for (uint8 outcomeIndex = 0; outcomeIndex < 3; outcomeIndex++) { + (carryPeaks[outcomeIndex], carryLeafCounts[outcomeIndex]) = _materializeCurrentCarrySnapshot(outcomeIndex); + OutcomeState storage state = outcomeState[outcomeIndex]; + carryTotals[outcomeIndex] = state.inheritedUnresolvedTotal + state.localUnresolvedTotal; + nullifierRoots[outcomeIndex] = _getCurrentNullifierRoot(outcomeIndex); + } } // Attrition cost = startBond * exp( ln(ratio) * t / T ) where ratio = nonDecisionThreshold / startBond. @@ -149,82 +247,50 @@ contract EscalationGame { function computeIterativeAttritionCost(uint256 timeSinceStart) public view returns (uint256) { uint256 startBondLocal = startBond; uint256 nonDecisionThresholdLocal = nonDecisionThreshold; - require(timeSinceStart <= escalationTimeLength, 'Invalid time'); + require(timeSinceStart <= ESCALATION_TIME_LENGTH, 'Invalid time'); // Exact edge cases if (timeSinceStart == 0) return startBondLocal; - if (timeSinceStart == escalationTimeLength) return nonDecisionThresholdLocal; + if (timeSinceStart == ESCALATION_TIME_LENGTH) return nonDecisionThresholdLocal; // Exponent = lnRatio_scaled * t / T - uint256 exponent = lnRatioScaled * timeSinceStart / escalationTimeLength; + uint256 exponent = lnRatioScaled * timeSinceStart / ESCALATION_TIME_LENGTH; uint256 exponentPow2 = exponent / LN2_SCALED; uint256 exponentRemainder = exponent - exponentPow2 * LN2_SCALED; // Compute exp(exponentRemainder / SCALE) * SCALE using series: Σ_{k=0} exponent^k / (k! * SCALE^{k-1}) // Range reduction uses exp(x) = 2^k * exp(x - k * ln(2)). // Recurrence: term_k = term_{k-1} * exponent / (k * SCALE) - uint256 exp_scaled = SCALE; // k=0 - uint256 term = exponentRemainder; // k=1 - exp_scaled += term; + uint256 expScaled = SCALE; // k=0 + uint256 term = exponentRemainder; + expScaled += term; for (uint256 k = 2; k < MAX_EXP_ITERATIONS;) { term = term * exponentRemainder / (k * SCALE); if (term == 0) break; - exp_scaled += term; + expScaled += term; unchecked { ++k; } } - exp_scaled <<= exponentPow2; - uint256 cost = startBondLocal * exp_scaled / SCALE; + expScaled <<= exponentPow2; + uint256 cost = startBondLocal * expScaled / SCALE; // Clamp (should be ≤ nonDecisionThreshold, but rounding may cause slight overshoot) return cost > nonDecisionThresholdLocal ? nonDecisionThresholdLocal : cost; } function computeTimeSinceStartFromAttritionCost(uint256 attritionCost) public view returns (uint256) { if (attritionCost <= startBond) return 0; - if (attritionCost >= nonDecisionThreshold) return escalationTimeLength; + if (attritionCost >= nonDecisionThreshold) return ESCALATION_TIME_LENGTH; uint256 lnCostRatioScaled = _computeLnRatioScaled(startBond, attritionCost); - return lnCostRatioScaled * escalationTimeLength / lnRatioScaled; - } - - function _computeLnRatioScaled(uint256 lowValue, uint256 highValue) internal pure returns (uint256) { - uint256 normalizedLow = lowValue; - uint256 log2Count = 0; - while (highValue >= normalizedLow * 2) { - unchecked { - normalizedLow *= 2; - ++log2Count; - } - } - - uint256 diff = highValue - normalizedLow; - uint256 sum = highValue + normalizedLow; - uint256 z = diff * SCALE / sum; // z ∈ [0, SCALE / 3] after range reduction - if (z == 0) return 0; - return log2Count * LN2_SCALED + 2 * _computeAtanhScaled(z); // ln(highValue / lowValue) * SCALE - } - - function _computeAtanhScaled(uint256 z) internal pure returns (uint256 atanhScaled) { - uint256 z2 = z * z / SCALE; // = Z^2 * SCALE - uint256 term = z; // k=0: z / 1 - atanhScaled = term; - - for (uint256 k = 1; k < MAX_ATANH_ITERATIONS;) { - term = term * z2 * (2 * k - 1) / ((2 * k + 1) * SCALE); - if (term == 0) break; - atanhScaled += term; - unchecked { - ++k; - } - } + return lnCostRatioScaled * ESCALATION_TIME_LENGTH / lnRatioScaled; } function getEscalationGameEndDate() public view returns (uint256 endTime) { if (nonDecisionTimestamp > 0) return nonDecisionTimestamp; if (forkContinuation) { - if (!forkContinuationResumed) return type(uint256).max; + if (forkResumedAt == 0) return type(uint256).max; uint256 requiredElapsed = computeTimeSinceStartFromAttritionCost(getBindingCapital()); if (requiredElapsed <= forkElapsedAtStart) return forkResumedAt; return forkResumedAt + (requiredElapsed - forkElapsedAtStart); @@ -233,28 +299,27 @@ contract EscalationGame { } function totalCost() public view returns (uint256) { - if (forkContinuation && !forkContinuationResumed && forkElapsedAtStart == 0) return 0; - if (forkContinuation && !forkContinuationResumed) return computeIterativeAttritionCost(forkElapsedAtStart); + if (forkContinuation && forkResumedAt == 0 && forkElapsedAtStart == 0) return 0; + if (forkContinuation && forkResumedAt == 0) return computeIterativeAttritionCost(forkElapsedAtStart); if (forkContinuation) { uint256 forkElapsed = forkElapsedAtStart + (block.timestamp - forkResumedAt); if (forkElapsed == 0) return 0; - if (forkElapsed >= escalationTimeLength) return nonDecisionThreshold; + if (forkElapsed >= ESCALATION_TIME_LENGTH) return nonDecisionThreshold; return computeIterativeAttritionCost(forkElapsed); } if (activationTime >= block.timestamp) return 0; uint256 elapsedSinceActivation = block.timestamp - activationTime; - if (elapsedSinceActivation >= escalationTimeLength) return nonDecisionThreshold; + if (elapsedSinceActivation >= ESCALATION_TIME_LENGTH) return nonDecisionThreshold; return computeIterativeAttritionCost(elapsedSinceActivation); } - function getQuestionResolution() public view returns (BinaryOutcomes.BinaryOutcome outcome){ + function getQuestionResolution() public view returns (BinaryOutcomes.BinaryOutcome outcome) { uint256 currentTotalCost = totalCost(); uint8 invalidOver = outcomeState[0].balance >= currentTotalCost ? 1 : 0; uint8 yesOver = outcomeState[1].balance >= currentTotalCost ? 1 : 0; uint8 noOver = outcomeState[2].balance >= currentTotalCost ? 1 : 0; - if (invalidOver + yesOver + noOver >= 2) return BinaryOutcomes.BinaryOutcome.None; // if two or more outcomes are over the total cost, the game is still going + if (invalidOver + yesOver + noOver >= 2) return BinaryOutcomes.BinaryOutcome.None; if (outcomeState[0].balance == 0 && outcomeState[1].balance == 0 && outcomeState[2].balance == 0) return BinaryOutcomes.BinaryOutcome.Invalid; - // the game has ended due to timeout if (outcomeState[0].balance > outcomeState[1].balance && outcomeState[0].balance > outcomeState[2].balance) return BinaryOutcomes.BinaryOutcome.Invalid; if (outcomeState[1].balance > outcomeState[0].balance && outcomeState[1].balance > outcomeState[2].balance) return BinaryOutcomes.BinaryOutcome.Yes; return BinaryOutcomes.BinaryOutcome.No; @@ -273,7 +338,8 @@ contract EscalationGame { (outcomeState[0].balance >= outcomeState[2].balance && outcomeState[0].balance <= outcomeState[1].balance) ) { return outcomeState[0].balance; - } else if ( + } + if ( (outcomeState[1].balance >= outcomeState[0].balance && outcomeState[1].balance <= outcomeState[2].balance) || (outcomeState[1].balance >= outcomeState[2].balance && outcomeState[1].balance <= outcomeState[0].balance) ) { @@ -282,356 +348,467 @@ contract EscalationGame { return outcomeState[2].balance; } - // deposits on question outcome, returns how much user actually ended depositing - function depositOnOutcome(address depositor, BinaryOutcomes.BinaryOutcome outcome, uint256 amount) public returns (uint256 depositAmount) { + function depositOnOutcome(address depositor, BinaryOutcomes.BinaryOutcome outcome, uint256 amount) external returns (uint256 depositAmount) { require(nonDecisionTimestamp == 0, 'System has already reached a non-decision'); require(msg.sender == address(securityPool), 'Only Security Pool can deposit'); - require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid outcome: None'); + require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Outcome must not be None'); require(getQuestionResolution() == BinaryOutcomes.BinaryOutcome.None, 'System has already timed out'); - require(outcomeState[uint256(outcome)].balance < nonDecisionThreshold, 'Already full'); - require(amount >= startBond, 'all amounts need to be bigger or equal to start deposit'); // checks that we get start bond and spam protection - uint256 outcomeIdx = uint256(outcome); - OutcomeState storage selectedOutcomeState = outcomeState[outcomeIdx]; + require(outcomeState[uint8(outcome)].balance < nonDecisionThreshold, 'Already full'); + require(amount >= startBond, 'all amounts need to be bigger or equal to start deposit'); + uint256 outcomeIndex = uint256(outcome); + OutcomeState storage selectedOutcomeState = outcomeState[outcomeIndex]; uint256 currentBalance = selectedOutcomeState.balance; uint256 room = nonDecisionThreshold - currentBalance; - uint256 effectiveDeposit = amount > room ? room : amount; - uint256 newBalance = currentBalance + effectiveDeposit; - - // Snapshot all balances for tie detection - uint256 b0 = outcomeState[0].balance; - uint256 b1 = outcomeState[1].balance; - uint256 b2 = outcomeState[2].balance; - uint256 maxBal = b0 > b1 ? (b0 > b2 ? b0 : b2) : (b1 > b2 ? b1 : b2); - - // Check if new balance ties with existing maximum and another outcome has that maximum, and max is below threshold. - // Ties at/above threshold are allowed (to trigger nonDecision/fork). - bool otherHasMax = (outcomeIdx == 0) ? (b1 == maxBal || b2 == maxBal) : - (outcomeIdx == 1) ? (b0 == maxBal || b2 == maxBal) : - (b0 == maxBal || b1 == maxBal); - if (newBalance == maxBal && otherHasMax && maxBal < nonDecisionThreshold) { - effectiveDeposit -= 1; - require(effectiveDeposit >= startBond, 'tie adjustment would break min deposit'); - newBalance = currentBalance + effectiveDeposit; - } + (uint256 effectiveDeposit, uint256 newBalance) = _getAcceptedDepositAmount(outcomeIndex, amount, currentBalance, room); - // Update the balance - selectedOutcomeState.balance = newBalance; + selectedOutcomeState.balance += effectiveDeposit; depositAmount = effectiveDeposit; - // Record deposit Deposit memory deposit; deposit.depositor = depositor; deposit.amount = depositAmount; - deposit.cumulativeAmount = selectedOutcomeState.balance; + deposit.cumulativeAmount = newBalance; selectedOutcomeState.deposits.push(deposit); - emit DepositOnOutcome(depositor, outcome, deposit.amount, selectedOutcomeState.deposits.length - 1, deposit.cumulativeAmount); + uint256 depositIndex = selectedOutcomeState.deposits.length - 1; + uint256 stableParentDepositIndex = _getStableLocalParentDepositIndex(depositIndex); + uint256 nodeId = nextNodeId; + nextNodeId += 1; + Node storage node = nodes[nodeId]; + node.parentNodeId = selectedOutcomeState.localHeadNodeId; + node.depositor = depositor; + node.outcome = outcome; + node.amount = depositAmount; + node.parentDepositIndex = stableParentDepositIndex; + node.cumulativeAmount = deposit.cumulativeAmount; + selectedOutcomeState.localHeadNodeId = nodeId; + selectedOutcomeState.localUnresolvedTotal += depositAmount; + emit LocalDepositAppended(nodeId, outcome, depositor, depositAmount, stableParentDepositIndex, deposit.cumulativeAmount); + emit DepositOnOutcome(depositor, outcome, deposit.amount, depositIndex, deposit.cumulativeAmount); if (hasReachedNonDecision()) { nonDecisionTimestamp = block.timestamp; } } - function claimDepositForWinning(uint256 depositIndex, BinaryOutcomes.BinaryOutcome outcome) public returns (address depositor, uint256 amountToWithdraw, uint256 originalDepositAmount) { - require( - msg.sender == address(securityPool) || msg.sender == address(securityPool.securityPoolForker()), - 'Only Security Pool or designated forker can withdraw' - ); - require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid outcome: None'); - OutcomeState storage selectedOutcomeState = outcomeState[uint8(outcome)]; - Deposit memory deposit = selectedOutcomeState.deposits[depositIndex]; - require(deposit.amount > 0, 'deposit already settled'); - selectedOutcomeState.deposits[depositIndex].amount = 0; + function claimDepositForWinning(uint256 depositIndex, BinaryOutcomes.BinaryOutcome outcome) public onlySecurityPoolOrForker returns (address depositor, uint256 amountToWithdraw, uint256 originalDepositAmount) { + require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Outcome must not be None'); + Deposit memory deposit = _consumeLocalDeposit(uint8(outcome), depositIndex); depositor = deposit.depositor; originalDepositAmount = deposit.amount; - uint256 depositStart = deposit.cumulativeAmount - deposit.amount; - uint256 bindingCapitalAmount = getBindingCapital(); - uint256 rewardEligibleCapAmount = bindingCapitalAmount + bindingCapitalAmount / EXCESS_REWARD_WINDOW_DIVISOR; - uint256 winningOutcomeBalance = selectedOutcomeState.balance; - uint256 rewardEligiblePrincipalAmount = winningOutcomeBalance < rewardEligibleCapAmount ? winningOutcomeBalance : rewardEligibleCapAmount; - uint256 rewardBonusPoolAmount = (bindingCapitalAmount * 3) / 5; - uint256 totalHaircutAmount = (bindingCapitalAmount * 2) / 5; uint256 burnAmount; - if (rewardEligiblePrincipalAmount == 0) { - amountToWithdraw = deposit.amount; - burnAmount = 0; - } else { - uint256 eligibleEndAmount = deposit.cumulativeAmount < rewardEligibleCapAmount ? deposit.cumulativeAmount : rewardEligibleCapAmount; - uint256 rewardEligibleDepositAmount = eligibleEndAmount > depositStart ? eligibleEndAmount - depositStart : 0; - if (rewardEligibleDepositAmount > deposit.amount) rewardEligibleDepositAmount = deposit.amount; - uint256 bonusShare = rewardEligibleDepositAmount * rewardBonusPoolAmount / rewardEligiblePrincipalAmount; - burnAmount = rewardEligibleDepositAmount * totalHaircutAmount / rewardEligiblePrincipalAmount; - amountToWithdraw = deposit.amount + bonusShare; - } - - // Adjust based on actual fork threshold - uint256 actualForkThreshold = securityPool.zoltar().getForkThreshold(securityPool.universeId()); - if (actualForkThreshold < nonDecisionThreshold) { - amountToWithdraw = (amountToWithdraw * actualForkThreshold) / nonDecisionThreshold; - } + (amountToWithdraw, burnAmount) = _computeWinningWithdrawal(uint8(outcome), deposit.amount, deposit.cumulativeAmount); emit ClaimDeposit(amountToWithdraw, burnAmount); } - function exportUnresolvedForkDeposit(uint256 depositIndex, BinaryOutcomes.BinaryOutcome outcome) public returns (address depositor, uint256 amount, uint256 parentDepositIndex) { - require( - msg.sender == address(securityPool) || msg.sender == address(securityPool.securityPoolForker()), - 'Only Security Pool or designated forker can withdraw' - ); - require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid outcome: None'); - OutcomeState storage selectedOutcomeState = outcomeState[uint8(outcome)]; - if (depositIndex > type(uint128).max) { - uint256 importedDepositIndex = ~depositIndex; - ImportedDeposit storage importedDeposit = selectedOutcomeState.importedDeposits[importedDepositIndex]; - require(importedDeposit.depositor != address(0x0), 'unknown imported deposit'); - require(!importedDeposit.settled, 'deposit already settled'); - importedDeposit.settled = true; - depositor = importedDeposit.depositor; - amount = importedDeposit.amount; - parentDepositIndex = importedDepositIndex; - return (depositor, amount, parentDepositIndex); - } - Deposit memory deposit = selectedOutcomeState.deposits[depositIndex]; - require(deposit.amount > 0, 'deposit already settled'); - selectedOutcomeState.deposits[depositIndex].amount = 0; + function exportUnresolvedDeposit(uint256 depositIndex, BinaryOutcomes.BinaryOutcome outcome) public onlySecurityPoolOrForker returns (address depositor, uint256 amount, uint256 parentDepositIndex) { + require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Outcome must not be None'); + uint8 outcomeIndex = uint8(outcome); + Deposit memory deposit = _consumeLocalDeposit(outcomeIndex, depositIndex); depositor = deposit.depositor; amount = deposit.amount; - parentDepositIndex = forkContinuation ? FORK_CONTINUATION_LOCAL_DEPOSIT_INDEX_PREFIX | depositIndex : depositIndex; + parentDepositIndex = _getStableLocalParentDepositIndex(depositIndex); } - function importForkedDeposit(address depositor, BinaryOutcomes.BinaryOutcome outcome, uint256 parentDepositIndex, uint256 amount) public { - require( - msg.sender == address(securityPool) || msg.sender == address(securityPool.securityPoolForker()), - 'Only Security Pool or designated forker can withdraw' - ); - require(forkContinuation, 'not fork continuation'); - require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid outcome: None'); - require(amount > 0, 'amount must be positive'); + function withdrawDeposit(CarriedDepositProof calldata proof, BinaryOutcomes.BinaryOutcome outcome) public onlySecurityPoolOrForker returns (address depositor, uint256 amountToWithdraw, uint256 originalDepositAmount) { + require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Outcome must not be None'); + BinaryOutcomes.BinaryOutcome questionResolution = getQuestionResolution(); + require(questionResolution != BinaryOutcomes.BinaryOutcome.None, 'Question has not finalized!'); uint8 outcomeIndex = uint8(outcome); - OutcomeState storage selectedOutcomeState = outcomeState[outcomeIndex]; - ImportedDeposit storage deposit = selectedOutcomeState.importedDeposits[parentDepositIndex]; - require(deposit.depositor == address(0x0), 'deposit already imported'); - deposit.depositor = depositor; - deposit.amount = amount; - deposit.cumulativeAmount = _getImportedPrefixAmount(outcomeIndex, parentDepositIndex); - selectedOutcomeState.importedBalance += amount; - selectedOutcomeState.balance += amount; - _addImportedPrefixAmount(outcomeIndex, parentDepositIndex, amount); - selectedOutcomeState.unsettledImportedDepositIndexesByDepositor[depositor].push(parentDepositIndex); - selectedOutcomeState.importedDepositorIndexPosition[depositor][parentDepositIndex] = selectedOutcomeState.unsettledImportedDepositIndexesByDepositor[depositor].length; - emit ImportedForkDeposit(depositor, outcome, parentDepositIndex, amount); + depositor = proof.depositor; + originalDepositAmount = proof.amount; + _verifyAndConsumeCarriedDepositProof(outcomeIndex, proof); + if (outcome == questionResolution) { + uint256 burnAmount; + (amountToWithdraw, burnAmount) = _computeWinningWithdrawal(outcomeIndex, proof.amount, proof.cumulativeAmount); + emit ClaimDeposit(amountToWithdraw, burnAmount); + emit WithdrawDeposit(depositor, outcome, amountToWithdraw, proof.parentDepositIndex); + return (depositor, amountToWithdraw, originalDepositAmount); + } + emit WithdrawDeposit(depositor, outcome, 0, proof.parentDepositIndex); } - function withdrawImportedForkDeposit(uint256 parentDepositIndex, BinaryOutcomes.BinaryOutcome outcome) public returns (address depositor, uint256 amountToWithdraw, uint256 originalDepositAmount) { - require( - msg.sender == address(securityPool) || msg.sender == address(securityPool.securityPoolForker()), - 'Only Security Pool or designated forker can withdraw' - ); - require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid outcome: None'); + function exportUnresolvedDeposit(CarriedDepositProof calldata proof, BinaryOutcomes.BinaryOutcome outcome) public onlySecurityPoolOrForker returns (address depositor, uint256 amount, uint256 parentDepositIndex) { + require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Outcome must not be None'); uint8 outcomeIndex = uint8(outcome); - OutcomeState storage selectedOutcomeState = outcomeState[outcomeIndex]; - ImportedDeposit storage deposit = selectedOutcomeState.importedDeposits[parentDepositIndex]; - require(deposit.depositor != address(0x0), 'unknown imported deposit'); - require(!deposit.settled, 'deposit already settled'); - deposit.settled = true; - depositor = deposit.depositor; - originalDepositAmount = deposit.amount; - _removeUnsettledImportedDeposit(outcomeIndex, depositor, parentDepositIndex); - uint256 depositStart = deposit.cumulativeAmount; - uint256 bindingCapitalAmount = getBindingCapital(); - uint256 rewardEligibleCapAmount = bindingCapitalAmount + bindingCapitalAmount / EXCESS_REWARD_WINDOW_DIVISOR; - uint256 winningOutcomeBalance = selectedOutcomeState.balance; - uint256 rewardEligiblePrincipalAmount = winningOutcomeBalance < rewardEligibleCapAmount ? winningOutcomeBalance : rewardEligibleCapAmount; - uint256 burnAmount; - if (rewardEligiblePrincipalAmount == 0) { - amountToWithdraw = originalDepositAmount; - } else { - uint256 eligibleEndAmount = depositStart + originalDepositAmount < rewardEligibleCapAmount ? depositStart + originalDepositAmount : rewardEligibleCapAmount; - uint256 rewardEligibleDepositAmount = eligibleEndAmount > depositStart ? eligibleEndAmount - depositStart : 0; - if (rewardEligibleDepositAmount > originalDepositAmount) rewardEligibleDepositAmount = originalDepositAmount; - uint256 rewardBonusPoolAmount = (bindingCapitalAmount * 3) / 5; - uint256 totalHaircutAmount = (bindingCapitalAmount * 2) / 5; - uint256 bonusShare = rewardEligibleDepositAmount * rewardBonusPoolAmount / rewardEligiblePrincipalAmount; - burnAmount = rewardEligibleDepositAmount * totalHaircutAmount / rewardEligiblePrincipalAmount; - amountToWithdraw = originalDepositAmount + bonusShare; - } - - uint256 actualForkThreshold = securityPool.zoltar().getForkThreshold(securityPool.universeId()); - if (actualForkThreshold < nonDecisionThreshold) { - amountToWithdraw = (amountToWithdraw * actualForkThreshold) / nonDecisionThreshold; - } - - emit ClaimDeposit(amountToWithdraw, burnAmount); + _verifyAndConsumeCarriedDepositProof(outcomeIndex, proof); + depositor = proof.depositor; + amount = proof.amount; + parentDepositIndex = proof.parentDepositIndex; } - function forfeitImportedForkDeposit(uint256 parentDepositIndex, BinaryOutcomes.BinaryOutcome outcome) public returns (address depositor, uint256 originalDepositAmount) { - require( - msg.sender == address(securityPool) || msg.sender == address(securityPool.securityPoolForker()), - 'Only Security Pool or designated forker can withdraw' - ); - require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid outcome: None'); - BinaryOutcomes.BinaryOutcome questionResolution = getQuestionResolution(); - require(questionResolution != BinaryOutcomes.BinaryOutcome.None, 'Question has not finalized!'); - require(outcome != questionResolution, 'Winning deposits must withdraw'); + // Pages unresolved local carry leaves only, in newest-first local linked-list order. + // Inherited snapshot leaves are exposed through getForkCarrySnapshot(). + function getCarryLeafPageByOutcome(BinaryOutcomes.BinaryOutcome outcome, uint256 startNodeId, uint256 maxEntries) external view returns (CarryLeafView[] memory carryLeaves, uint256 nextPageNodeId) { + if (outcome == BinaryOutcomes.BinaryOutcome.None) return (new CarryLeafView[](0), 0); uint8 outcomeIndex = uint8(outcome); - OutcomeState storage selectedOutcomeState = outcomeState[outcomeIndex]; - ImportedDeposit storage deposit = selectedOutcomeState.importedDeposits[parentDepositIndex]; - require(deposit.depositor != address(0x0), 'unknown imported deposit'); - require(!deposit.settled, 'deposit already settled'); - deposit.settled = true; - depositor = deposit.depositor; - originalDepositAmount = deposit.amount; - _removeUnsettledImportedDeposit(outcomeIndex, depositor, parentDepositIndex); - emit WithdrawDeposit(depositor, outcome, 0, parentDepositIndex); - } + if (maxEntries == 0) return (new CarryLeafView[](0), startNodeId); - function refundCanceledDeposit(uint256 depositIndex, BinaryOutcomes.BinaryOutcome outcome) public returns (address depositor, uint256 amountToWithdraw) { - require(msg.sender == address(securityPool), 'Only Security Pool can withdraw'); - require(securityPool.zoltar().getForkTime(securityPool.universeId()) > 0, 'Zoltar has not forked'); - require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid outcome: None'); - OutcomeState storage selectedOutcomeState = outcomeState[uint8(outcome)]; - Deposit memory deposit = selectedOutcomeState.deposits[depositIndex]; - require(deposit.amount > 0, 'deposit already settled'); - selectedOutcomeState.deposits[depositIndex].amount = 0; - depositor = deposit.depositor; - amountToWithdraw = deposit.amount; - emit WithdrawDeposit(depositor, outcome, amountToWithdraw, depositIndex); - } - - function getUnsettledImportedDepositIndexesByOutcomeAndDepositor( - BinaryOutcomes.BinaryOutcome outcome, - address depositor, - uint256 startIndex, - uint256 scanCount - ) external view returns (uint256[] memory depositIndexes) { - require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid outcome: None'); - uint256[] storage depositorIndexes = outcomeState[uint8(outcome)].unsettledImportedDepositIndexesByDepositor[depositor]; - if (startIndex >= depositorIndexes.length || scanCount == 0) return new uint256[](0); - uint256 endIndex = startIndex + scanCount; - if (endIndex > depositorIndexes.length) { - endIndex = depositorIndexes.length; - } - depositIndexes = new uint256[](endIndex - startIndex); + OutcomeState storage state = outcomeState[outcomeIndex]; + uint256 nodeId = startNodeId == 0 ? state.localHeadNodeId : startNodeId; + if (nodeId != 0) { + require(nodes[nodeId].outcome == outcome, 'cursor outcome mismatch'); + } + carryLeaves = new CarryLeafView[](maxEntries); uint256 writeIndex = 0; - for (uint256 index = startIndex; index < endIndex; index++) { - uint256 depositIndex = depositorIndexes[index]; - depositIndexes[writeIndex] = depositIndex; - writeIndex += 1; + while (nodeId != 0 && writeIndex < maxEntries) { + Node storage currentNode = nodes[nodeId]; + uint256 parentNodeId = currentNode.parentNodeId; + require(currentNode.outcome == outcome, 'cursor outcome mismatch'); + if (!state.consumedParentDepositIndexes[currentNode.parentDepositIndex]) { + carryLeaves[writeIndex] = CarryLeafView({ + depositor: currentNode.depositor, + amount: currentNode.amount, + parentDepositIndex: currentNode.parentDepositIndex, + cumulativeAmount: currentNode.cumulativeAmount, + sourceNodeId: nodeId + }); + writeIndex += 1; + } + nodeId = parentNodeId; } + nextPageNodeId = nodeId; + if (writeIndex == maxEntries) return (carryLeaves, nextPageNodeId); + CarryLeafView[] memory trimmedCarryLeaves = new CarryLeafView[](writeIndex); + for (uint256 index = 0; index < writeIndex; index++) { + trimmedCarryLeaves[index] = carryLeaves[index]; + } + return (trimmedCarryLeaves, nextPageNodeId); } - function withdrawDeposit(uint256 depositIndex) public returns (address depositor, uint256 amountToWithdraw, uint256 originalDepositAmount) { - require(msg.sender == address(securityPool), 'Only Security Pool can withdraw'); - require(nonDecisionTimestamp == 0, 'System has reached non-decision'); - // if system hasnt forked, check outcome is winning - BinaryOutcomes.BinaryOutcome questionResolution = getQuestionResolution(); - (depositor, amountToWithdraw, originalDepositAmount) = claimDepositForWinning(depositIndex, questionResolution); - emit WithdrawDeposit(depositor, questionResolution, amountToWithdraw, depositIndex); + // Returns proof-consumed inherited indexes in proof-consumption order, not sorted parentDepositIndex order. + function getProofConsumedCarriedDepositIndexesByOutcome(BinaryOutcomes.BinaryOutcome outcome, uint256 startIndex, uint256 numberOfEntries) external view returns (uint256[] memory parentDepositIndexes) { + if (outcome == BinaryOutcomes.BinaryOutcome.None) return new uint256[](0); + uint256[] storage consumedIndexes = outcomeState[uint8(outcome)].proofConsumedDepositIndexes; + if (startIndex >= consumedIndexes.length || numberOfEntries == 0) return new uint256[](0); + uint256 endIndex = startIndex + numberOfEntries; + if (endIndex > consumedIndexes.length) endIndex = consumedIndexes.length; + parentDepositIndexes = new uint256[](endIndex - startIndex); + for (uint256 index = startIndex; index < endIndex; index++) { + parentDepositIndexes[index - startIndex] = consumedIndexes[index]; + } } - function forfeitLosingDeposit(uint256 depositIndex, BinaryOutcomes.BinaryOutcome outcome) public returns (address depositor, uint256 originalDepositAmount) { + function withdrawDeposit(uint256 depositIndex, BinaryOutcomes.BinaryOutcome outcome) public returns (address depositor, uint256 amountToWithdraw, uint256 originalDepositAmount) { require(msg.sender == address(securityPool), 'Only Security Pool can withdraw'); require(nonDecisionTimestamp == 0, 'System has reached non-decision'); - require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid outcome: None'); + require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Outcome must not be None'); BinaryOutcomes.BinaryOutcome questionResolution = getQuestionResolution(); require(questionResolution != BinaryOutcomes.BinaryOutcome.None, 'Question has not finalized!'); - require(outcome != questionResolution, 'Winning deposits must withdraw'); - OutcomeState storage selectedOutcomeState = outcomeState[uint8(outcome)]; - require(depositIndex < selectedOutcomeState.deposits.length, 'Invalid deposit index'); - Deposit memory deposit = selectedOutcomeState.deposits[depositIndex]; - require(deposit.amount > 0, 'deposit already settled'); - selectedOutcomeState.deposits[depositIndex].amount = 0; + if (outcome == questionResolution) { + (depositor, amountToWithdraw, originalDepositAmount) = claimDepositForWinning(depositIndex, questionResolution); + emit WithdrawDeposit(depositor, questionResolution, amountToWithdraw, depositIndex); + return (depositor, amountToWithdraw, originalDepositAmount); + } + Deposit memory deposit = _consumeLocalDeposit(uint8(outcome), depositIndex); depositor = deposit.depositor; originalDepositAmount = deposit.amount; emit WithdrawDeposit(depositor, outcome, 0, depositIndex); } - function getUnsettledDepositIndexesByOutcomeAndDepositor( - BinaryOutcomes.BinaryOutcome outcome, - address depositor, - uint256 startIndex, - uint256 scanCount - ) external view returns (uint256[] memory depositIndexes) { - require(outcome != BinaryOutcomes.BinaryOutcome.None, 'Invalid outcome: None'); + function getDepositsByOutcome(BinaryOutcomes.BinaryOutcome outcome, uint256 startIndex, uint256 numberOfEntries) external view returns (Deposit[] memory returnDeposits) { + if (outcome == BinaryOutcomes.BinaryOutcome.None) return new Deposit[](0); Deposit[] storage outcomeDeposits = outcomeState[uint8(outcome)].deposits; - if (startIndex >= outcomeDeposits.length || scanCount == 0) return new uint256[](0); - uint256 endIndex = startIndex + scanCount; - if (endIndex > outcomeDeposits.length) { - endIndex = outcomeDeposits.length; + uint256 iterateUntil = startIndex + numberOfEntries > outcomeDeposits.length ? outcomeDeposits.length : startIndex + numberOfEntries; + if (iterateUntil <= startIndex) return new Deposit[](0); + returnDeposits = new Deposit[](iterateUntil - startIndex); + for (uint256 index = startIndex; index < iterateUntil; index++) { + returnDeposits[index - startIndex] = outcomeDeposits[index]; } + } - uint256 matchCount = 0; - for (uint256 index = startIndex; index < endIndex; index++) { - Deposit storage deposit = outcomeDeposits[index]; - if (deposit.depositor == depositor && deposit.amount > 0) { - matchCount++; + function _computeLnRatioScaled(uint256 lowValue, uint256 highValue) internal pure returns (uint256) { + uint256 normalizedLow = lowValue; + uint256 log2Count = 0; + while (highValue >= normalizedLow * 2) { + unchecked { + normalizedLow *= 2; + ++log2Count; + } + } + + uint256 diff = highValue - normalizedLow; + uint256 sum = highValue + normalizedLow; + uint256 z = diff * SCALE / sum; // z ∈ [0, SCALE / 3] after range reduction + if (z == 0) return 0; + return log2Count * LN2_SCALED + 2 * _computeAtanhScaled(z); // ln(highValue / lowValue) * SCALE + } + + function _computeAtanhScaled(uint256 z) internal pure returns (uint256 atanhScaled) { + uint256 z2 = z * z / SCALE; // = Z^2 * SCALE + uint256 term = z; // k=0: z / 1 + atanhScaled = term; + + for (uint256 k = 1; k < MAX_ATANH_ITERATIONS;) { + term = term * z2 * (2 * k - 1) / ((2 * k + 1) * SCALE); + if (term == 0) break; + atanhScaled += term; + unchecked { + ++k; + } + } + } + + function _appendCarriedLeafToMerkleMountainRange(bytes32[MERKLE_MOUNTAIN_RANGE_MAX_PEAKS] memory currentPeaks, uint256 currentLeafCount, bytes32 leafHash) private pure returns (bytes32[MERKLE_MOUNTAIN_RANGE_MAX_PEAKS] memory updatedPeaks, uint256 updatedLeafCount) { + updatedPeaks = currentPeaks; + uint256 leafCount = currentLeafCount; + uint256 peakIndex = 0; + bytes32 carryHash = leafHash; + + while (((leafCount >> peakIndex) & 1) == 1) { + carryHash = MerkleMountainRange.hashParent(updatedPeaks[peakIndex], carryHash); + delete updatedPeaks[peakIndex]; + peakIndex += 1; + } + + require(peakIndex < MERKLE_MOUNTAIN_RANGE_MAX_PEAKS, 'Merkle Mountain Range peak overflow'); + updatedPeaks[peakIndex] = carryHash; + updatedLeafCount = leafCount + 1; + } + + function _bagCarryPeaks(bytes32[MERKLE_MOUNTAIN_RANGE_MAX_PEAKS] memory peakHashes, uint256 leafCount) private pure returns (bytes32) { + if (leafCount == 0) return bytes32(0); + + uint256 peakCount = 0; + for (uint256 peakIndex = 0; peakIndex < MERKLE_MOUNTAIN_RANGE_MAX_PEAKS; peakIndex++) { + if (((leafCount >> peakIndex) & 1) == 1) { + peakCount += 1; } } - depositIndexes = new uint256[](matchCount); + bytes32[] memory peaks = new bytes32[](peakCount); uint256 writeIndex = 0; - for (uint256 index = startIndex; index < endIndex; index++) { - Deposit storage deposit = outcomeDeposits[index]; - if (deposit.depositor == depositor && deposit.amount > 0) { - depositIndexes[writeIndex] = index; - writeIndex++; + for (uint256 peakIndex = 0; peakIndex < MERKLE_MOUNTAIN_RANGE_MAX_PEAKS; peakIndex++) { + if (((leafCount >> peakIndex) & 1) == 1) { + peaks[writeIndex] = peakHashes[peakIndex]; + writeIndex += 1; } } + + return MerkleMountainRange.bagPeaks(peaks, peakCount); } - // TODO, for the UI, we probably want to retrieve multiple outcomes at once - function getDepositsByOutcome(BinaryOutcomes.BinaryOutcome outcome, uint256 startIndex, uint256 numberOfEntries) external view returns (Deposit[] memory returnDeposits) { - Deposit[] storage outcomeDeposits = outcomeState[uint8(outcome)].deposits; - uint256 iterateUntil = startIndex + numberOfEntries > outcomeDeposits.length ? outcomeDeposits.length : startIndex + numberOfEntries; - if (iterateUntil <= startIndex) return new Deposit[](0); - returnDeposits = new Deposit[](iterateUntil - startIndex); - for (uint256 i = startIndex; i < iterateUntil; i++) { - returnDeposits[i - startIndex] = outcomeDeposits[i]; + function _getCurrentNullifierRoot(uint8 outcomeIndex) private view returns (bytes32) { + bytes32 root = outcomeState[outcomeIndex].currentNullifierRoot; + if (root != bytes32(0)) return root; + return EMPTY_NULLIFIER_ROOT; + } + + function _computeEmptyNullifierRoot() private pure returns (bytes32 root) { + root = bytes32(0); + for (uint256 depth = 0; depth < NULLIFIER_DEPTH; depth++) { + root = MerkleMountainRange.hashParent(root, root); } } - function _removeUnsettledImportedDeposit(uint8 outcomeIndex, address depositor, uint256 parentDepositIndex) private { - OutcomeState storage selectedOutcomeState = outcomeState[outcomeIndex]; - uint256[] storage depositorIndexes = selectedOutcomeState.unsettledImportedDepositIndexesByDepositor[depositor]; - uint256 positionPlusOne = selectedOutcomeState.importedDepositorIndexPosition[depositor][parentDepositIndex]; - require(positionPlusOne != 0, 'deposit not unsettled'); - uint256 position = positionPlusOne - 1; - uint256 lastIndex = depositorIndexes.length - 1; - if (position != lastIndex) { - uint256 movedDepositIndex = depositorIndexes[lastIndex]; - depositorIndexes[position] = movedDepositIndex; - selectedOutcomeState.importedDepositorIndexPosition[depositor][movedDepositIndex] = positionPlusOne; + function _verifyAndConsumeCarriedDepositProof(uint8 outcomeIndex, CarriedDepositProof calldata proof) private { + bytes32 leafHash = _verifyCarriedDepositMerkleMountainRangeProof(outcomeIndex, proof); + _verifyAndAdvanceNullifier(outcomeIndex, proof.parentDepositIndex, proof.nullifierSiblings); + _consumeCarriedDeposit(outcomeIndex, proof.parentDepositIndex, proof.amount); + emit CarriedDepositClaimed(BinaryOutcomes.BinaryOutcome(outcomeIndex), proof.depositor, proof.amount, proof.parentDepositIndex, proof.sourceNodeId, leafHash); + } + + function _verifyCarriedDepositMerkleMountainRangeProof(uint8 outcomeIndex, CarriedDepositProof calldata proof) private view returns (bytes32 leafHash) { + OutcomeState storage state = outcomeState[outcomeIndex]; + uint256 leafCount = state.snapshotLeafCount; + require(leafCount > 0, 'no inherited carry snapshot'); + require(proof.amount > 0, 'amount must be positive'); + leafHash = MerkleMountainRange.hashLeaf(proof.depositor, BinaryOutcomes.BinaryOutcome(outcomeIndex), proof.amount, proof.parentDepositIndex, proof.cumulativeAmount, proof.sourceNodeId); + bytes32 computedRoot = _computeMerkleMountainRangeRootFromProof(leafHash, leafCount, proof.leafIndex, proof.merkleMountainRangePeakIndex, proof.merkleMountainRangeSiblings); + require(computedRoot == _bagCarryPeaks(state.snapshotPeaks, state.snapshotLeafCount), 'invalid carry inclusion proof'); + } + + function _computeMerkleMountainRangeRootFromProof(bytes32 leafHash, uint256 leafCount, uint256 leafIndex, uint256 peakHeight, bytes32[] calldata siblings) private pure returns (bytes32) { + require(((leafCount >> peakHeight) & 1) == 1, 'peak absent'); + require(peakHeight < MERKLE_MOUNTAIN_RANGE_MAX_PEAKS, 'invalid peak height'); + require(leafIndex < (uint256(1) << peakHeight), 'leaf index out of range'); + + bytes32 peakRoot = leafHash; + for (uint256 level = 0; level < peakHeight; level++) { + bytes32 siblingHash = siblings[level]; + if (((leafIndex >> level) & 1) == 0) { + peakRoot = MerkleMountainRange.hashParent(peakRoot, siblingHash); + } else { + peakRoot = MerkleMountainRange.hashParent(siblingHash, peakRoot); + } + } + + uint256 peakCount = 0; + for (uint256 index = 0; index < MERKLE_MOUNTAIN_RANGE_MAX_PEAKS; index++) { + if (((leafCount >> index) & 1) == 1) { + peakCount += 1; + } + } + require(siblings.length == peakHeight + peakCount - 1, 'invalid Merkle Mountain Range proof length'); + bytes32[] memory peaks = new bytes32[](peakCount); + uint256 writeIndex = 0; + uint256 siblingIndex = peakHeight; + for (uint256 index = 0; index < MERKLE_MOUNTAIN_RANGE_MAX_PEAKS; index++) { + if (((leafCount >> index) & 1) != 1) continue; + if (index == peakHeight) { + peaks[writeIndex] = peakRoot; + } else { + peaks[writeIndex] = siblings[siblingIndex]; + siblingIndex += 1; + } + writeIndex += 1; + } + return MerkleMountainRange.bagPeaks(peaks, peakCount); + } + + function _verifyAndAdvanceNullifier(uint8 outcomeIndex, uint256 parentDepositIndex, bytes32[] calldata siblings) private { + require(siblings.length == NULLIFIER_DEPTH, 'invalid nullifier proof length'); + bytes32 currentRoot = _getCurrentNullifierRoot(outcomeIndex); + bytes32 emptyRoot = _computeNullifierRoot(parentDepositIndex, siblings, bytes32(0)); + require(emptyRoot == currentRoot, 'invalid nullifier proof'); + OutcomeState storage state = outcomeState[outcomeIndex]; + state.currentNullifierRoot = _computeNullifierRoot(parentDepositIndex, siblings, bytes32(uint256(1))); + state.proofConsumedDepositIndexes.push(parentDepositIndex); + } + + function _computeNullifierRoot(uint256 parentDepositIndex, bytes32[] calldata siblings, bytes32 leafValue) private pure returns (bytes32 root) { + root = leafValue; + uint256 path = uint256(keccak256(abi.encode(parentDepositIndex))); + for (uint256 depth = 0; depth < NULLIFIER_DEPTH; depth++) { + bytes32 siblingHash = siblings[depth]; + if (((path >> depth) & 1) == 0) { + root = MerkleMountainRange.hashParent(root, siblingHash); + } else { + root = MerkleMountainRange.hashParent(siblingHash, root); + } } - depositorIndexes.pop(); - delete selectedOutcomeState.importedDepositorIndexPosition[depositor][parentDepositIndex]; } - function _getImportedPrefixAmount(uint8 outcomeIndex, uint256 parentDepositIndex) internal view returns (uint256 prefixAmount) { - OutcomeState storage selectedOutcomeState = outcomeState[outcomeIndex]; - if (parentDepositIndex == 0) { - return 0; + function _getAcceptedDepositAmount(uint256 outcomeIndex, uint256 requestedAmount, uint256 currentBalance, uint256 room) private view returns (uint256 acceptedAmount, uint256 newBalance) { + acceptedAmount = requestedAmount > room ? room : requestedAmount; + newBalance = currentBalance + acceptedAmount; + + uint256 invalidBalance = outcomeState[0].balance; + uint256 yesBalance = outcomeState[1].balance; + uint256 noBalance = outcomeState[2].balance; + uint256 maxBalance = invalidBalance > yesBalance ? (invalidBalance > noBalance ? invalidBalance : noBalance) : (yesBalance > noBalance ? yesBalance : noBalance); + bool otherHasMax = outcomeIndex == 0 ? (yesBalance == maxBalance || noBalance == maxBalance) : + outcomeIndex == 1 ? (invalidBalance == maxBalance || noBalance == maxBalance) : + (invalidBalance == maxBalance || yesBalance == maxBalance); + + if (newBalance == maxBalance && otherHasMax && maxBalance < nonDecisionThreshold) { + acceptedAmount -= 1; + require(acceptedAmount >= startBond, 'tie adjustment would break min deposit'); + newBalance = currentBalance + acceptedAmount; } - if (parentDepositIndex == type(uint256).max) { - return selectedOutcomeState.importedTotalAmount - selectedOutcomeState.importedMaxKeyAmount; + } + + function _getStableLocalParentDepositIndex(uint256 depositIndex) private view returns (uint256) { + return forkContinuation ? FORK_CONTINUATION_LOCAL_DEPOSIT_INDEX_PREFIX | depositIndex : depositIndex; + } + + function _markLocalDepositConsumed(uint8 outcomeIndex, uint256 depositIndex, uint256 amount) private { + OutcomeState storage state = outcomeState[outcomeIndex]; + uint256 stableParentDepositIndex = _getStableLocalParentDepositIndex(depositIndex); + if (state.consumedParentDepositIndexes[stableParentDepositIndex]) return; + state.consumedParentDepositIndexes[stableParentDepositIndex] = true; + state.localUnresolvedTotal -= amount; + } + + function _consumeCarriedDeposit(uint8 outcomeIndex, uint256 parentDepositIndex, uint256 amount) private { + require(!_isCarriedDepositConsumed(outcomeIndex, parentDepositIndex), 'deposit already settled'); + OutcomeState storage state = outcomeState[outcomeIndex]; + state.consumedParentDepositIndexes[parentDepositIndex] = true; + state.inheritedUnresolvedTotal -= amount; + } + + function _isCarriedDepositConsumed(uint8 outcomeIndex, uint256 parentDepositIndex) private view returns (bool) { + return outcomeState[outcomeIndex].consumedParentDepositIndexes[parentDepositIndex]; + } + + function _getMaterializedCarrySnapshot(uint8 outcomeIndex) private view returns (bytes32[MERKLE_MOUNTAIN_RANGE_MAX_PEAKS] memory currentPeaks, uint256 currentLeafCount, bytes32 currentCarryRoot, uint256 currentCarryTotal) { + (currentPeaks, currentLeafCount) = _materializeCurrentCarrySnapshot(outcomeIndex); + currentCarryRoot = _bagCarryPeaks(currentPeaks, currentLeafCount); + OutcomeState storage state = outcomeState[outcomeIndex]; + currentCarryTotal = state.inheritedUnresolvedTotal + state.localUnresolvedTotal; + } + + function _computeWinningWithdrawal(uint8 outcomeIndex, uint256 depositAmount, uint256 cumulativeAmount) private view returns (uint256 amountToWithdraw, uint256 burnAmount) { + uint256 depositStart = cumulativeAmount - depositAmount; + uint256 bindingCapitalAmount = getBindingCapital(); + uint256 rewardEligibleCapAmount = bindingCapitalAmount + bindingCapitalAmount / EXCESS_REWARD_WINDOW_DIVISOR; + uint256 winningOutcomeBalance = outcomeState[outcomeIndex].balance; + uint256 rewardEligiblePrincipalAmount = winningOutcomeBalance < rewardEligibleCapAmount ? winningOutcomeBalance : rewardEligibleCapAmount; + if (rewardEligiblePrincipalAmount == 0) { + amountToWithdraw = depositAmount; + } else { + uint256 eligibleEndAmount = cumulativeAmount < rewardEligibleCapAmount ? cumulativeAmount : rewardEligibleCapAmount; + uint256 rewardEligibleDepositAmount = eligibleEndAmount > depositStart ? eligibleEndAmount - depositStart : 0; + if (rewardEligibleDepositAmount > depositAmount) rewardEligibleDepositAmount = depositAmount; + uint256 rewardBonusPoolAmount = (bindingCapitalAmount * 3) / 5; + uint256 totalHaircutAmount = (bindingCapitalAmount * 2) / 5; + uint256 bonusShare = rewardEligibleDepositAmount * rewardBonusPoolAmount / rewardEligiblePrincipalAmount; + burnAmount = rewardEligibleDepositAmount * totalHaircutAmount / rewardEligiblePrincipalAmount; + amountToWithdraw = depositAmount + bonusShare; } - for (uint256 index = parentDepositIndex; index > 0;) { - prefixAmount += selectedOutcomeState.importedPrefixTree[index]; - uint256 lowBit = index & (~index + 1); - index -= lowBit; + + uint256 actualForkThreshold = securityPool.zoltar().getForkThreshold(securityPool.universeId()); + if (actualForkThreshold < nonDecisionThreshold) { + amountToWithdraw = (amountToWithdraw * actualForkThreshold) / nonDecisionThreshold; } } - function _addImportedPrefixAmount(uint8 outcomeIndex, uint256 parentDepositIndex, uint256 amount) internal { + function _consumeLocalDeposit(uint8 outcomeIndex, uint256 depositIndex) private returns (Deposit memory deposit) { OutcomeState storage selectedOutcomeState = outcomeState[outcomeIndex]; - selectedOutcomeState.importedTotalAmount += amount; - if (parentDepositIndex == type(uint256).max) { - selectedOutcomeState.importedMaxKeyAmount += amount; - return; - } - for (uint256 index = parentDepositIndex + 1; index > 0;) { - selectedOutcomeState.importedPrefixTree[index] += amount; - uint256 lowBit = index & (~index + 1); - unchecked { - index += lowBit; + require(depositIndex < selectedOutcomeState.deposits.length, 'Invalid deposit index'); + deposit = selectedOutcomeState.deposits[depositIndex]; + require(deposit.amount > 0, 'deposit already settled'); + selectedOutcomeState.deposits[depositIndex].amount = 0; + _markLocalDepositConsumed(outcomeIndex, depositIndex, deposit.amount); + } + + function _materializeCurrentCarrySnapshot(uint8 outcomeIndex) private view returns (bytes32[MERKLE_MOUNTAIN_RANGE_MAX_PEAKS] memory currentPeaks, uint256 currentLeafCount) { + OutcomeState storage state = outcomeState[outcomeIndex]; + currentLeafCount = state.snapshotLeafCount; + for (uint256 peakIndex = 0; peakIndex < MERKLE_MOUNTAIN_RANGE_MAX_PEAKS; peakIndex++) { + currentPeaks[peakIndex] = state.snapshotPeaks[peakIndex]; + } + + uint256[] memory unresolvedNodeIds = _getUnresolvedLocalNodeIds(state); + uint256 unresolvedLeafCount = unresolvedNodeIds.length; + for (uint256 unresolvedIndex = 0; unresolvedIndex < unresolvedLeafCount; unresolvedIndex++) { + uint256 unresolvedNodeId = unresolvedNodeIds[unresolvedIndex]; + Node storage unresolvedNode = nodes[unresolvedNodeId]; + (currentPeaks, currentLeafCount) = _appendCarriedLeafToMerkleMountainRange( + currentPeaks, + currentLeafCount, + MerkleMountainRange.hashLeaf( + unresolvedNode.depositor, + unresolvedNode.outcome, + unresolvedNode.amount, + unresolvedNode.parentDepositIndex, + unresolvedNode.cumulativeAmount, + unresolvedNodeId + ) + ); + } + } + + function _getUnresolvedLocalNodeIds(OutcomeState storage state) private view returns (uint256[] memory unresolvedNodeIds) { + uint256 nodeId = state.localHeadNodeId; + uint256 unresolvedLeafCount = 0; + while (nodeId != 0) { + Node storage currentNode = nodes[nodeId]; + if (!state.consumedParentDepositIndexes[currentNode.parentDepositIndex]) { + unresolvedLeafCount += 1; + } + nodeId = currentNode.parentNodeId; + } + + unresolvedNodeIds = new uint256[](unresolvedLeafCount); + nodeId = state.localHeadNodeId; + uint256 writeIndex = unresolvedLeafCount; + while (nodeId != 0) { + Node storage currentNode = nodes[nodeId]; + if (!state.consumedParentDepositIndexes[currentNode.parentDepositIndex]) { + writeIndex -= 1; + unresolvedNodeIds[writeIndex] = nodeId; } + nodeId = currentNode.parentNodeId; } } } diff --git a/solidity/contracts/peripherals/MerkleMountainRange.sol b/solidity/contracts/peripherals/MerkleMountainRange.sol new file mode 100644 index 00000000..ac5a08d8 --- /dev/null +++ b/solidity/contracts/peripherals/MerkleMountainRange.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity 0.8.35; + +import { BinaryOutcomes } from './BinaryOutcomes.sol'; + +library MerkleMountainRange { + function hashLeaf(address depositor, BinaryOutcomes.BinaryOutcome outcome, uint256 amount, uint256 parentDepositIndex, uint256 cumulativeAmount, uint256 sourceNodeId) internal pure returns (bytes32) { + return keccak256(abi.encode(depositor, outcome, amount, parentDepositIndex, cumulativeAmount, sourceNodeId)); + } + + function hashParent(bytes32 left, bytes32 right) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(left, right)); + } + + function bagPeaks(bytes32[] memory peaks, uint256 peakCount) internal pure returns (bytes32 root) { + if (peakCount == 0) return bytes32(0); + root = peaks[peakCount - 1]; + for (uint256 peakIndex = peakCount - 1; peakIndex > 0; peakIndex--) { + root = hashParent(peaks[peakIndex - 1], root); + } + } +} diff --git a/solidity/contracts/peripherals/SecurityPool.sol b/solidity/contracts/peripherals/SecurityPool.sol index 3fe1f3f6..5ee5371b 100644 --- a/solidity/contracts/peripherals/SecurityPool.sol +++ b/solidity/contracts/peripherals/SecurityPool.sol @@ -9,7 +9,7 @@ import { ISecurityPool, SecurityVault, SystemState, QuestionOutcome, ISecurityPo import { OpenOracle } from './openOracle/OpenOracle.sol'; import { SecurityPoolUtils } from './SecurityPoolUtils.sol'; import { EscalationGameFactory } from './factories/EscalationGameFactory.sol'; -import { EscalationGame } from './EscalationGame.sol'; +import { EscalationGame, CarriedDepositProof } from './EscalationGame.sol'; import { ZoltarQuestionData } from '../ZoltarQuestionData.sol'; import { SecurityPoolForker } from './SecurityPoolForker.sol'; import { ISecurityPoolForker } from './interfaces/ISecurityPoolForker.sol'; @@ -410,7 +410,7 @@ contract SecurityPool is ISecurityPool { emit RedeemRep(msg.sender, vault, repAmount); } - function withdrawForkedEscalationDeposits(QuestionOutcome outcome, uint256[] memory parentDepositIndexes) external { + function withdrawForkedEscalationDeposits(QuestionOutcome outcome, CarriedDepositProof[] memory proofs) external { require(address(escalationGame) != address(0x0), 'missing escalation'); require(systemState == SystemState.Operational, 'not operational'); BinaryOutcomes.BinaryOutcome questionOutcome = ISecurityPoolForker(securityPoolForker).getQuestionOutcome(this); @@ -418,18 +418,15 @@ contract SecurityPool is ISecurityPool { BinaryOutcomes.BinaryOutcome withdrawalOutcome = BinaryOutcomes.BinaryOutcome(uint8(outcome)); require(withdrawalOutcome != BinaryOutcomes.BinaryOutcome.None, 'invalid none'); + EscalationGame escalationGameContract = EscalationGame(payable(address(escalationGame))); address beneficiaryVault = address(0x0); uint256 totalAmountToWithdraw = 0; uint256 totalOriginalDepositAmount = 0; - for (uint256 index = 0; index < parentDepositIndexes.length; index++) { + for (uint256 index = 0; index < proofs.length; index++) { address depositor; uint256 amountToWithdraw; uint256 originalDepositAmount; - if (withdrawalOutcome == questionOutcome) { - (depositor, amountToWithdraw, originalDepositAmount) = escalationGame.withdrawImportedForkDeposit(parentDepositIndexes[index], withdrawalOutcome); - } else { - (depositor, originalDepositAmount) = escalationGame.forfeitImportedForkDeposit(parentDepositIndexes[index], withdrawalOutcome); - } + (depositor, amountToWithdraw, originalDepositAmount) = escalationGameContract.withdrawDeposit(proofs[index], withdrawalOutcome); if (beneficiaryVault == address(0x0)) { beneficiaryVault = depositor; } @@ -439,11 +436,7 @@ contract SecurityPool is ISecurityPool { totalAmountToWithdraw += amountToWithdraw; totalOriginalDepositAmount += originalDepositAmount; } - if (totalAmountToWithdraw > totalOriginalDepositAmount) { - securityVaults[beneficiaryVault].poolOwnership += repToPoolOwnership(totalAmountToWithdraw - totalOriginalDepositAmount); - } else if (totalAmountToWithdraw < totalOriginalDepositAmount) { - securityVaults[beneficiaryVault].poolOwnership -= repToPoolOwnership(totalOriginalDepositAmount - totalAmountToWithdraw); - } + _applyForkedEscalationSettlement(beneficiaryVault, totalAmountToWithdraw, totalOriginalDepositAmount); } //////////////////////////////////////// @@ -482,11 +475,7 @@ contract SecurityPool is ISecurityPool { address depositor; uint256 amountToWithdraw; uint256 originalDepositAmount; - if (outcome == questionOutcome) { - (depositor, amountToWithdraw, originalDepositAmount) = escalationGame.withdrawDeposit(depositIndexes[index]); - } else { - (depositor, originalDepositAmount) = escalationGame.forfeitLosingDeposit(depositIndexes[index], outcome); - } + (depositor, amountToWithdraw, originalDepositAmount) = escalationGame.withdrawDeposit(depositIndexes[index], outcome); if (beneficiaryVault == address(0x0)) { beneficiaryVault = depositor; } @@ -515,6 +504,21 @@ contract SecurityPool is ISecurityPool { escalationGame = escalationGameFactory.deployEscalationGameFromFork(startBond, nonDecisionThreshold, elapsedAtFork); } + function initializeForkCarrySnapshot( + bytes32[64][3] memory inheritedCarryPeaks, + uint256[3] memory inheritedCarryLeafCounts, + uint256[3] memory inheritedCarryTotals, + bytes32[3] memory inheritedNullifierRoots + ) external onlyForker { + require(address(escalationGame) != address(0x0), 'missing escalation'); + EscalationGame(payable(address(escalationGame))).initializeForkCarrySnapshot( + inheritedCarryPeaks, + inheritedCarryLeafCounts, + inheritedCarryTotals, + inheritedNullifierRoots + ); + } + function resumeForkedEscalationGame() external onlyForker { require(address(escalationGame) != address(0x0), 'missing escalation'); escalationGame.resumeFromFork(); @@ -551,6 +555,15 @@ contract SecurityPool is ISecurityPool { totalLockedRepInEscalationGame -= repAmount; } + function _applyForkedEscalationSettlement(address beneficiaryVault, uint256 totalAmountToWithdraw, uint256 totalOriginalDepositAmount) private { + if (beneficiaryVault == address(0x0)) return; + if (totalAmountToWithdraw > totalOriginalDepositAmount) { + securityVaults[beneficiaryVault].poolOwnership += repToPoolOwnership(totalAmountToWithdraw - totalOriginalDepositAmount); + } else if (totalAmountToWithdraw < totalOriginalDepositAmount) { + securityVaults[beneficiaryVault].poolOwnership -= repToPoolOwnership(totalOriginalDepositAmount - totalAmountToWithdraw); + } + } + function _trackVault(address vault) private { require(vault != address(0x0), 'invalid vault'); if (vaultIndexesPlusOne[vault] != 0) return; diff --git a/solidity/contracts/peripherals/SecurityPoolForker.sol b/solidity/contracts/peripherals/SecurityPoolForker.sol index 9782e4ff..f7cfd7e5 100644 --- a/solidity/contracts/peripherals/SecurityPoolForker.sol +++ b/solidity/contracts/peripherals/SecurityPoolForker.sol @@ -209,17 +209,30 @@ contract SecurityPoolForker is ISecurityPoolForker { function _initializeChildForkedEscalationGameIfNeeded(ISecurityPool parent, ISecurityPool child) private { ForkData storage parentForkData = forkDataByPool[parent]; if (!parentForkData.unresolvedEscalationAtFork) return; - if (address(child.escalationGame()) != address(0x0)) return; - child.initializeForkedEscalationGame( - parentForkData.escalationStartBondAtFork, - parentForkData.escalationNonDecisionThresholdAtFork, - parentForkData.escalationElapsedAtFork - ); + if (address(child.escalationGame()) == address(0x0)) { + child.initializeForkedEscalationGame( + parentForkData.escalationStartBondAtFork, + parentForkData.escalationNonDecisionThresholdAtFork, + parentForkData.escalationElapsedAtFork + ); + } + EscalationGame childEscalationGame = EscalationGame(payable(address(child.escalationGame()))); + if (!childEscalationGame.forkCarrySnapshotInitialized()) { + EscalationGame parentEscalationGame = EscalationGame(payable(address(parent.escalationGame()))); + (bytes32[64][3] memory inheritedCarryPeaks, uint256[3] memory inheritedCarryLeafCounts, uint256[3] memory inheritedCarryTotals, bytes32[3] memory inheritedNullifierRoots) = + parentEscalationGame.getForkCarrySnapshot(); + child.initializeForkCarrySnapshot(inheritedCarryPeaks, inheritedCarryLeafCounts, inheritedCarryTotals, inheritedNullifierRoots); + } if (child.systemState() == SystemState.Operational) { child.resumeForkedEscalationGame(); } } + function initializeChildForkedEscalationGameIfNeeded(ISecurityPool parent, ISecurityPool child) external { + require(msg.sender == address(this), 'only self'); + _initializeChildForkedEscalationGameIfNeeded(parent, child); + } + function _creditMigratedEscalationPrincipal(ISecurityPool parent, ISecurityPool child, uint256 migratedPrincipal) private { if (migratedPrincipal == 0) return; uint256 parentRepAtFork = forkDataByPool[parent].repAtFork; @@ -229,27 +242,6 @@ contract SecurityPoolForker is ISecurityPoolForker { } } - function _migrateEscalationDeposits( - ISecurityPool parent, - ISecurityPool child, - address vault, - BinaryOutcomes.BinaryOutcome sourceOutcome, - uint256[] memory depositIndexes - ) private returns (uint256 migratedPrincipal) { - if (depositIndexes.length == 0) return 0; - EscalationGame escalationGame = parent.escalationGame(); - require(address(escalationGame) != address(0x0), 'e4'); - for (uint256 index = 0; index < depositIndexes.length; index++) { - (address depositor, uint256 amount, uint256 parentDepositIndex) = escalationGame.exportUnresolvedForkDeposit(depositIndexes[index], sourceOutcome); - require(depositor == vault, 'e5'); - parent.clearEscalationLockForForkMigration(vault, amount); - child.addEscalationLockForForkMigration(vault, amount); - child.escalationGame().importForkedDeposit(vault, sourceOutcome, parentDepositIndex, amount); - migratedPrincipal += amount; - } - _creditMigratedEscalationPrincipal(parent, child, migratedPrincipal); - } - function _migrateVaultUnlockedState(ISecurityPool parent, ISecurityPool child, address vault, uint256 lockedRepAlreadyMigrated) private { uint256 parentRepAtFork = forkDataByPool[parent].repAtFork; ForkData storage parentForkData = forkDataByPool[parent]; @@ -352,13 +344,7 @@ contract SecurityPoolForker is ISecurityPoolForker { _delegateVaultMigrationCall(); } - function migrateVaultWithUnresolvedEscalation( - ISecurityPool, - uint8, - uint256[] memory, - uint256[] memory, - uint256[] memory - ) public { + function migrateVaultWithUnresolvedEscalation(ISecurityPool, uint8) public { _delegateVaultMigrationCall(); } diff --git a/solidity/contracts/peripherals/SecurityPoolForkerVaultMigrationDelegate.sol b/solidity/contracts/peripherals/SecurityPoolForkerVaultMigrationDelegate.sol index 7e9234b6..da1597be 100644 --- a/solidity/contracts/peripherals/SecurityPoolForkerVaultMigrationDelegate.sol +++ b/solidity/contracts/peripherals/SecurityPoolForkerVaultMigrationDelegate.sol @@ -10,6 +10,10 @@ import { BinaryOutcomes } from './BinaryOutcomes.sol'; import { SecurityPoolUtils } from './SecurityPoolUtils.sol'; import { SecurityPoolMigrationProxy } from './SecurityPoolMigrationProxy.sol'; +interface ISecurityPoolForkerChildEscalationInitializer { + function initializeChildForkedEscalationGameIfNeeded(ISecurityPool parent, ISecurityPool child) external; +} + struct VaultMigrationForkData { uint256 repAtFork; UniformPriceDualCapBatchAuction truthAuction; @@ -99,17 +103,7 @@ contract SecurityPoolForkerVaultMigrationDelegate { } function _initializeChildForkedEscalationGameIfNeeded(ISecurityPool parent, ISecurityPool child) private { - VaultMigrationForkData storage parentForkData = forkDataByPool[parent]; - if (!parentForkData.unresolvedEscalationAtFork) return; - if (address(child.escalationGame()) != address(0x0)) return; - child.initializeForkedEscalationGame( - parentForkData.escalationStartBondAtFork, - parentForkData.escalationNonDecisionThresholdAtFork, - parentForkData.escalationElapsedAtFork - ); - if (child.systemState() == SystemState.Operational) { - child.resumeForkedEscalationGame(); - } + ISecurityPoolForkerChildEscalationInitializer(address(this)).initializeChildForkedEscalationGameIfNeeded(parent, child); } function _creditMigratedEscalationPrincipal(ISecurityPool parent, ISecurityPool child, uint256 migratedPrincipal) private { @@ -121,27 +115,6 @@ contract SecurityPoolForkerVaultMigrationDelegate { } } - function _migrateEscalationDeposits( - ISecurityPool parent, - ISecurityPool child, - address vault, - BinaryOutcomes.BinaryOutcome sourceOutcome, - uint256[] memory depositIndexes - ) private returns (uint256 migratedPrincipal) { - if (depositIndexes.length == 0) return 0; - EscalationGame escalationGame = parent.escalationGame(); - require(address(escalationGame) != address(0x0), 'e4'); - for (uint256 index = 0; index < depositIndexes.length; index++) { - (address depositor, uint256 amount, uint256 parentDepositIndex) = escalationGame.exportUnresolvedForkDeposit(depositIndexes[index], sourceOutcome); - require(depositor == vault, 'e5'); - parent.clearEscalationLockForForkMigration(vault, amount); - child.addEscalationLockForForkMigration(vault, amount); - child.escalationGame().importForkedDeposit(vault, sourceOutcome, parentDepositIndex, amount); - migratedPrincipal += amount; - } - _creditMigratedEscalationPrincipal(parent, child, migratedPrincipal); - } - function _migrateVaultUnlockedState(ISecurityPool parent, ISecurityPool child, address vault, uint256 lockedRepAlreadyMigrated) private { uint256 parentRepAtFork = forkDataByPool[parent].repAtFork; VaultMigrationForkData storage parentForkData = forkDataByPool[parent]; @@ -221,13 +194,7 @@ contract SecurityPoolForkerVaultMigrationDelegate { _migrateVaultUnlockedState(parent, child, msg.sender, 0); } - function migrateVaultWithUnresolvedEscalation( - ISecurityPool parent, - uint8 childOutcomeIndex, - uint256[] memory invalidDepositIndexes, - uint256[] memory yesDepositIndexes, - uint256[] memory noDepositIndexes - ) public { + function migrateVaultWithUnresolvedEscalation(ISecurityPool parent, uint8 childOutcomeIndex) public { VaultMigrationForkData storage parentForkData = forkDataByPool[parent]; require(parentForkData.unresolvedEscalationAtFork, 'ee'); require(block.timestamp <= zoltar.getForkTime(parent.universeId()) + SecurityPoolUtils.MIGRATION_TIME, 'migration window closed'); @@ -235,13 +202,9 @@ contract SecurityPoolForkerVaultMigrationDelegate { (, , , , uint256 parentLockedRepInEscalationGame) = parent.securityVaults(msg.sender); require(parentLockedRepInEscalationGame > 0, 'ef'); ISecurityPool child = _getOrDeployChildPool(parent, childOutcomeIndex); - uint256 migratedPrincipal = 0; - migratedPrincipal += _migrateEscalationDeposits(parent, child, msg.sender, BinaryOutcomes.BinaryOutcome.Invalid, invalidDepositIndexes); - migratedPrincipal += _migrateEscalationDeposits(parent, child, msg.sender, BinaryOutcomes.BinaryOutcome.Yes, yesDepositIndexes); - migratedPrincipal += _migrateEscalationDeposits(parent, child, msg.sender, BinaryOutcomes.BinaryOutcome.No, noDepositIndexes); - require(migratedPrincipal > 0, 'f0'); - (, , , , uint256 remainingLockedRep) = parent.securityVaults(msg.sender); - require(remainingLockedRep == 0, 'f1'); - _migrateVaultUnlockedState(parent, child, msg.sender, migratedPrincipal); + parent.clearEscalationLockForForkMigration(msg.sender, parentLockedRepInEscalationGame); + child.addEscalationLockForForkMigration(msg.sender, parentLockedRepInEscalationGame); + _creditMigratedEscalationPrincipal(parent, child, parentLockedRepInEscalationGame); + _migrateVaultUnlockedState(parent, child, msg.sender, parentLockedRepInEscalationGame); } } diff --git a/solidity/contracts/peripherals/factories/EscalationGameFactory.sol b/solidity/contracts/peripherals/factories/EscalationGameFactory.sol index 7f6a7710..35e8ce89 100644 --- a/solidity/contracts/peripherals/factories/EscalationGameFactory.sol +++ b/solidity/contracts/peripherals/factories/EscalationGameFactory.sol @@ -7,15 +7,15 @@ import { EscalationGame } from '../EscalationGame.sol'; contract EscalationGameFactory { function deployEscalationGame(uint256 startBond, uint256 _nonDecisionThreshold) external returns (EscalationGame) { ISecurityPool securityPool = ISecurityPool(payable(msg.sender)); - EscalationGame game = new EscalationGame{ salt: bytes32(uint256(0x0)) }(securityPool); - game.start(startBond, _nonDecisionThreshold); - return game; + EscalationGame gameImplementation = new EscalationGame{ salt: bytes32(uint256(0x0)) }(securityPool); + gameImplementation.start(startBond, _nonDecisionThreshold); + return EscalationGame(payable(address(gameImplementation))); } function deployEscalationGameFromFork(uint256 startBond, uint256 nonDecisionThreshold, uint256 elapsedAtFork) external returns (EscalationGame) { ISecurityPool securityPool = ISecurityPool(payable(msg.sender)); - EscalationGame game = new EscalationGame{ salt: bytes32(uint256(0x0)) }(securityPool); - game.startFromFork(startBond, nonDecisionThreshold, elapsedAtFork); - return game; + EscalationGame gameImplementation = new EscalationGame{ salt: bytes32(uint256(0x0)) }(securityPool); + gameImplementation.startFromFork(startBond, nonDecisionThreshold, elapsedAtFork); + return EscalationGame(payable(address(gameImplementation))); } } diff --git a/solidity/contracts/peripherals/interfaces/ISecurityPool.sol b/solidity/contracts/peripherals/interfaces/ISecurityPool.sol index 7126c12b..72689554 100644 --- a/solidity/contracts/peripherals/interfaces/ISecurityPool.sol +++ b/solidity/contracts/peripherals/interfaces/ISecurityPool.sol @@ -8,6 +8,7 @@ import { IShareToken } from './IShareToken.sol'; import { ReputationToken } from '../../ReputationToken.sol'; import { SecurityPoolOracleCoordinator } from '../SecurityPoolOracleCoordinator.sol'; import { EscalationGame } from '../EscalationGame.sol'; +import { CarriedDepositProof } from '../EscalationGame.sol'; import { ZoltarQuestionData } from '../../ZoltarQuestionData.sol'; struct SecurityVault { @@ -77,7 +78,7 @@ interface ISecurityPool { function performWithdrawRep(address vault, uint256 repAmount) external; function depositRep(uint256 repAmount) external; function redeemRep(address vault) external; - function withdrawForkedEscalationDeposits(QuestionOutcome outcome, uint256[] memory parentDepositIndexes) external; + function withdrawForkedEscalationDeposits(QuestionOutcome outcome, CarriedDepositProof[] memory proofs) external; function performLiquidation(address callerVault, address targetVaultAddress, uint256 debtAmount, uint256 snapshotTargetOwnership, uint256 snapshotTargetAllowance, uint256 snapshotTotalRep, uint256 snapshotDenominator) external; function performSetSecurityBondsAllowance(address callerVault, uint256 amount) external; @@ -86,6 +87,7 @@ interface ISecurityPool { function escalationGame() external view returns (EscalationGame); function initializeForkedEscalationGame(uint256 startBond, uint256 nonDecisionThreshold, uint256 elapsedAtFork) external; + function initializeForkCarrySnapshot(bytes32[64][3] memory inheritedCarryPeaks, uint256[3] memory inheritedCarryLeafCounts, uint256[3] memory inheritedCarryTotals, bytes32[3] memory inheritedNullifierRoots) external; function resumeForkedEscalationGame() external; function setAwaitingForkContinuation(bool shouldAwait) external; function activateForkMode() external; diff --git a/solidity/contracts/peripherals/interfaces/ISecurityPoolForker.sol b/solidity/contracts/peripherals/interfaces/ISecurityPoolForker.sol index 751a73b4..a32199a3 100644 --- a/solidity/contracts/peripherals/interfaces/ISecurityPoolForker.sol +++ b/solidity/contracts/peripherals/interfaces/ISecurityPoolForker.sol @@ -10,13 +10,7 @@ interface ISecurityPoolForker { function migrateRepToZoltar(ISecurityPool securityPool, uint256[] memory outcomeIndices) external; function createChildUniverse(ISecurityPool securityPool, uint8 outcomeIndex) external; function migrateVault(ISecurityPool securityPool, uint8 outcomeIndex) external; - function migrateVaultWithUnresolvedEscalation( - ISecurityPool securityPool, - uint8 childOutcomeIndex, - uint256[] memory invalidDepositIndexes, - uint256[] memory yesDepositIndexes, - uint256[] memory noDepositIndexes - ) external; + function migrateVaultWithUnresolvedEscalation(ISecurityPool securityPool, uint8 childOutcomeIndex) external; function migrateFromEscalationGame(ISecurityPool securityPool, address vault, BinaryOutcomes.BinaryOutcome outcomeIndex, uint256[] memory depositIndexes) external; function startTruthAuction(ISecurityPool securityPool) external; function finalizeTruthAuction(ISecurityPool securityPool) external; diff --git a/solidity/contracts/peripherals/test/EscalationGameProofTestSecurityPool.sol b/solidity/contracts/peripherals/test/EscalationGameProofTestSecurityPool.sol new file mode 100644 index 00000000..3e44af51 --- /dev/null +++ b/solidity/contracts/peripherals/test/EscalationGameProofTestSecurityPool.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Unlicense +pragma solidity 0.8.35; + +import { Zoltar } from '../../Zoltar.sol'; +import { BinaryOutcomes } from '../BinaryOutcomes.sol'; +import { EscalationGame, CarriedDepositProof } from '../EscalationGame.sol'; + +contract EscalationGameProofTestSecurityPool { + Zoltar public immutable zoltar; + uint248 public immutable universeId; + address public immutable securityPoolForker; + EscalationGame public escalationGame; + + constructor(Zoltar zoltarAddress, uint248 configuredUniverseId, address configuredSecurityPoolForker) { + zoltar = zoltarAddress; + universeId = configuredUniverseId; + securityPoolForker = configuredSecurityPoolForker; + } + + function setEscalationGame(EscalationGame game) external { + require(address(escalationGame) == address(0), 'escalation game already configured'); + escalationGame = game; + } + + function depositOnOutcome(address depositor, BinaryOutcomes.BinaryOutcome outcome, uint256 amount) external returns (uint256) { + return escalationGame.depositOnOutcome(depositor, outcome, amount); + } + + function initializeForkCarrySnapshot( + bytes32[64][3] memory inheritedCarryPeaks, + uint256[3] memory inheritedCarryLeafCounts, + uint256[3] memory inheritedCarryTotals, + bytes32[3] memory inheritedNullifierRoots + ) external { + escalationGame.initializeForkCarrySnapshot( + inheritedCarryPeaks, inheritedCarryLeafCounts, inheritedCarryTotals, inheritedNullifierRoots + ); + } + + function withdrawDeposit(BinaryOutcomes.BinaryOutcome outcome, CarriedDepositProof calldata proof) + external + returns (address depositor, uint256 amountToWithdraw, uint256 originalDepositAmount) + { + return escalationGame.withdrawDeposit(proof, outcome); + } + + function claimDepositForWinning(uint256 depositIndex, BinaryOutcomes.BinaryOutcome outcome) + external + returns (address depositor, uint256 amountToWithdraw, uint256 originalDepositAmount) + { + return escalationGame.claimDepositForWinning(depositIndex, outcome); + } +} diff --git a/solidity/ts/tests/escalationGame.test.ts b/solidity/ts/tests/escalationGame.test.ts index f85695b6..f03ba50c 100644 --- a/solidity/ts/tests/escalationGame.test.ts +++ b/solidity/ts/tests/escalationGame.test.ts @@ -1,5 +1,5 @@ import { beforeAll, beforeEach, describe, setDefaultTimeout, test } from 'bun:test' -import { decodeEventLog, encodeDeployData, type Address } from 'viem' +import { concatHex, decodeEventLog, encodeAbiParameters, encodeDeployData, keccak256, type Address, type Hex } from 'viem' import { AnvilWindowEthereum } from '../testsuite/simulator/AnvilWindowEthereum' import { TEST_TIMEOUT_MS, useIsolatedAnvilNode } from '../testsuite/simulator/useIsolatedAnvilNode' import { createWriteClient, WriteClient, writeContractAndWait } from '../testsuite/simulator/utils/viem' @@ -10,10 +10,11 @@ import assert from 'node:assert/strict' import { deployEscalationGame, depositOnOutcome, getActivationTime, getBalances, getEscalationGameDeposits, getQuestionResolution } from '../testsuite/simulator/utils/contracts/escalationGame' import { ensureZoltarDeployed, getZoltarAddress } from '../testsuite/simulator/utils/contracts/zoltar' import { ensureInfraDeployed } from '../testsuite/simulator/utils/contracts/deployPeripherals' -import { peripherals_EscalationGame_EscalationGame, peripherals_test_EscalationGameTestSecurityPool_EscalationGameTestSecurityPool } from '../types/contractArtifact' +import { peripherals_EscalationGame_EscalationGame, peripherals_test_EscalationGameProofTestSecurityPool_EscalationGameProofTestSecurityPool as escalationGameProofTestPoolArtifact, peripherals_test_EscalationGameTestSecurityPool_EscalationGameTestSecurityPool } from '../types/contractArtifact' import { isIgnorableLogDecodeError } from './logDecodeErrors' const ESCALATION_TIME_LENGTH = 4233600n +const NULLIFIER_DEPTH = 64 setDefaultTimeout(TEST_TIMEOUT_MS) @@ -23,6 +24,7 @@ describe('Escalation Game Test Suite', () => { let client: WriteClient const reportBond = 1n * 10n ** 18n const nonDecisionThreshold = 1000n * 10n ** 18n + const recursiveResolutionTargetCost = (25n * reportBond) / 10n const readIterativeAttritionCost = async (escalationGame: Address, timeSinceStart: bigint) => await client.readContract({ @@ -87,6 +89,273 @@ describe('Escalation Game Test Suite', () => { return { escalationGameAddress, testSecurityPoolAddress } } + const deployEscalationGameWithProofPool = async () => { + const zoltarAddress = getZoltarAddress() + const testSecurityPoolDeploymentHash = await client.sendTransaction({ + data: encodeDeployData({ + abi: escalationGameProofTestPoolArtifact.abi, + bytecode: `0x${escalationGameProofTestPoolArtifact.evm.bytecode.object}`, + args: [zoltarAddress, 0n, client.account.address], + }), + }) + const testSecurityPoolDeploymentReceipt = await client.waitForTransactionReceipt({ hash: testSecurityPoolDeploymentHash }) + const testSecurityPoolAddress = testSecurityPoolDeploymentReceipt.contractAddress + if (testSecurityPoolAddress === undefined || testSecurityPoolAddress === null) throw new Error('proof test security pool deployment address missing') + const escalationGameDeploymentHash = await client.sendTransaction({ + data: encodeDeployData({ + abi: peripherals_EscalationGame_EscalationGame.abi, + bytecode: `0x${peripherals_EscalationGame_EscalationGame.evm.bytecode.object}`, + args: [testSecurityPoolAddress], + }), + }) + const escalationGameDeploymentReceipt = await client.waitForTransactionReceipt({ hash: escalationGameDeploymentHash }) + const escalationGameAddress = escalationGameDeploymentReceipt.contractAddress + if (escalationGameAddress === undefined || escalationGameAddress === null) throw new Error('escalation game deployment address missing') + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: escalationGameProofTestPoolArtifact.abi, + address: testSecurityPoolAddress, + functionName: 'setEscalationGame', + args: [escalationGameAddress], + }), + ) + return { escalationGameAddress, testSecurityPoolAddress } + } + + const startEscalation = async (escalationGameAddress: Address, startBond: bigint, nonDecisionThreshold: bigint) => + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'start', + args: [startBond, nonDecisionThreshold], + }), + ) + + const startEscalationFromFork = async (escalationGameAddress: Address, startBond: bigint, nonDecisionThreshold: bigint, elapsedAtFork: bigint) => + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'startFromFork', + args: [startBond, nonDecisionThreshold, elapsedAtFork], + }), + ) + + const resumeEscalationFromFork = async (escalationGameAddress: Address) => + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'resumeFromFork', + args: [], + }), + ) + + const advanceForkContinuationPastStart = async (escalationGameAddress: Address, targetAttritionCost = reportBond + 1n) => { + await resumeEscalationFromFork(escalationGameAddress) + const forkResumedAt = await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'forkResumedAt', + args: [], + }) + const elapsedAtTargetCost = await readTimeSinceStartFromAttritionCost(escalationGameAddress, targetAttritionCost) + await mockWindow.setTime(forkResumedAt + (elapsedAtTargetCost > 0n ? elapsedAtTargetCost : 1n)) + } + + const depositOnOutcomeViaProofTestSecurityPool = async (testSecurityPoolAddress: Address, depositor: Address, outcome: QuestionOutcome, amount: bigint) => + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: escalationGameProofTestPoolArtifact.abi, + address: testSecurityPoolAddress, + functionName: 'depositOnOutcome', + args: [depositor, outcome, amount], + }), + ) + + const readOutcomeState = async (escalationGameAddress: Address, outcome: QuestionOutcome) => + await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'getOutcomeState', + args: [outcome], + }) + + const readCarryPeaks = async (escalationGameAddress: Address, outcome: QuestionOutcome) => (await readOutcomeState(escalationGameAddress, outcome)).currentPeaks + const readCarryRoot = async (escalationGameAddress: Address, outcome: QuestionOutcome) => (await readOutcomeState(escalationGameAddress, outcome)).currentCarryRoot + const readCarryLeafCount = async (escalationGameAddress: Address, outcome: QuestionOutcome) => (await readOutcomeState(escalationGameAddress, outcome)).currentLeafCount + const readCarryTotal = async (escalationGameAddress: Address, outcome: QuestionOutcome) => (await readOutcomeState(escalationGameAddress, outcome)).currentCarryTotal + const readNullifierRoot = async (escalationGameAddress: Address, outcome: QuestionOutcome) => (await readOutcomeState(escalationGameAddress, outcome)).currentNullifierRoot + const readForkCarrySnapshotInitialized = async (escalationGameAddress: Address) => + await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'forkCarrySnapshotInitialized', + args: [], + }) + + const readCarryLeafPage = async (escalationGameAddress: Address, outcome: QuestionOutcome, startNodeId: bigint, maxEntries: bigint) => + await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'getCarryLeafPageByOutcome', + args: [outcome, startNodeId, maxEntries], + }) + + type PeakArray = Awaited> + + const toPeakArray = (peaks: readonly Hex[]): PeakArray => { + if (peaks.length !== 64) { + throw new Error(`expected 64 carry peaks, got ${peaks.length}`) + } + return peaks as PeakArray + } + + const zeroPeakArray = () => toPeakArray(Array.from({ length: 64 }, () => zeroHash())) + + const initializeSnapshotViaTestSecurityPool = async ( + testSecurityPoolAddress: Address, + inheritedCarryPeaks: readonly [PeakArray, PeakArray, PeakArray], + inheritedCarryLeafCounts: readonly [bigint, bigint, bigint], + inheritedCarryTotals: readonly [bigint, bigint, bigint], + inheritedNullifierRoots: readonly [Hex, Hex, Hex], + ) => + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: escalationGameProofTestPoolArtifact.abi, + address: testSecurityPoolAddress, + functionName: 'initializeForkCarrySnapshot', + args: [inheritedCarryPeaks, inheritedCarryLeafCounts, inheritedCarryTotals, inheritedNullifierRoots], + }), + ) + + const withdrawDepositViaProofTestSecurityPool = async ( + testSecurityPoolAddress: Address, + outcome: QuestionOutcome, + proof: { + depositor: Address + amount: bigint + parentDepositIndex: bigint + cumulativeAmount: bigint + sourceNodeId: bigint + leafIndex: bigint + merkleMountainRangeSiblings: readonly Hex[] + merkleMountainRangePeakIndex: bigint + nullifierSiblings: readonly Hex[] + }, + ) => + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: escalationGameProofTestPoolArtifact.abi, + address: testSecurityPoolAddress, + functionName: 'withdrawDeposit', + args: [outcome, proof], + }), + ) + + const claimDepositForWinningViaTestSecurityPool = async (testSecurityPoolAddress: Address, depositIndex: bigint, outcome: QuestionOutcome) => + await writeContractAndWait( + client, + async () => + await client.writeContract({ + abi: escalationGameProofTestPoolArtifact.abi, + address: testSecurityPoolAddress, + functionName: 'claimDepositForWinning', + args: [depositIndex, outcome], + }), + ) + + const zeroHash = () => `0x${'0'.repeat(64)}` as Hex + + const hashCarryLeaf = (depositor: Address, outcome: QuestionOutcome, amount: bigint, parentDepositIndex: bigint, cumulativeAmount: bigint, sourceNodeId: bigint) => + keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint8' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }], [depositor, outcome, amount, parentDepositIndex, cumulativeAmount, sourceNodeId])) + + const hashParent = (left: Hex, right: Hex) => keccak256(concatHex([left, right])) + + const buildZeroHashes = () => { + const zeroHashes: Hex[] = [zeroHash()] + for (let depth = 0; depth < NULLIFIER_DEPTH; depth += 1) { + zeroHashes.push(hashParent(zeroHashes[depth], zeroHashes[depth])) + } + return zeroHashes + } + + class SparseNullifierTree { + private readonly zeroHashes = buildZeroHashes() + private readonly nodes = new Map() + private readonly pathMask = (1n << BigInt(NULLIFIER_DEPTH)) - 1n + root: Hex = this.zeroHashes[NULLIFIER_DEPTH] + + private getPath(parentDepositIndex: bigint) { + return BigInt(keccak256(encodeAbiParameters([{ type: 'uint256' }], [parentDepositIndex]))) & this.pathMask + } + + getProof(parentDepositIndex: bigint) { + const path = this.getPath(parentDepositIndex) + const siblings: Hex[] = [] + let nodeIndex = path + for (let depth = 0; depth < NULLIFIER_DEPTH; depth += 1) { + const siblingIndex = nodeIndex ^ 1n + const siblingHash = this.nodes.get(`${depth}:${siblingIndex}`) ?? this.zeroHashes[depth] + siblings.push(siblingHash) + nodeIndex >>= 1n + } + return siblings + } + + consume(parentDepositIndex: bigint) { + const path = this.getPath(parentDepositIndex) + let nodeIndex = path + let nodeHash = `0x${'0'.repeat(63)}1` as Hex + this.nodes.set(`0:${nodeIndex}`, nodeHash) + for (let depth = 0; depth < NULLIFIER_DEPTH; depth += 1) { + const isRightNode = (nodeIndex & 1n) === 1n + const siblingIndex = nodeIndex ^ 1n + const siblingHash = this.nodes.get(`${depth}:${siblingIndex}`) ?? this.zeroHashes[depth] + const parentHash = isRightNode ? hashParent(siblingHash, nodeHash) : hashParent(nodeHash, siblingHash) + nodeIndex >>= 1n + nodeHash = parentHash + this.nodes.set(`${depth + 1}:${nodeIndex}`, nodeHash) + } + this.root = nodeHash + } + } + + const createCarryProof = async (escalationGameAddress: Address, parentDepositIndex: bigint, leafIndex: bigint, merkleMountainRangePeakIndex: bigint, merkleMountainRangeSiblings: readonly Hex[], nullifierSiblings: readonly Hex[]) => { + const node = await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'nodes', + args: [leafIndex + 1n], + }) + return { + depositor: node[1], + amount: node[3], + parentDepositIndex, + cumulativeAmount: node[5], + sourceNodeId: leafIndex + 1n, + leafIndex, + merkleMountainRangeSiblings, + merkleMountainRangePeakIndex, + nullifierSiblings, + } + } + const depositOnOutcomeViaTestSecurityPool = async (testSecurityPoolAddress: Address, depositor: Address, outcome: QuestionOutcome, amount: bigint) => await writeContractAndWait( client, @@ -248,6 +517,287 @@ describe('Escalation Game Test Suite', () => { ) }) + test('fork carry maintains an append-only Merkle Mountain Range root for inherited carryover deposits', async () => { + const { escalationGameAddress, testSecurityPoolAddress } = await deployEscalationGameWithProofPool() + await startEscalation(escalationGameAddress, reportBond, nonDecisionThreshold) + + await depositOnOutcomeViaProofTestSecurityPool(testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, reportBond) + + const firstLeafHash = hashCarryLeaf(client.account.address, QuestionOutcome.Yes, reportBond, 0n, reportBond, 1n) + const rootAfterFirstDeposit = await readCarryRoot(escalationGameAddress, QuestionOutcome.Yes) + assert.strictEqual(rootAfterFirstDeposit, firstLeafHash, 'single appended leaf should be its own Merkle Mountain Range root') + + await depositOnOutcomeViaProofTestSecurityPool(testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, reportBond) + const secondLeafHash = hashCarryLeaf(client.account.address, QuestionOutcome.Yes, reportBond, 1n, 2n * reportBond, 2n) + const expectedTwoLeafRoot = keccak256(concatHex([firstLeafHash, secondLeafHash])) + const rootAfterSecondDeposit = await readCarryRoot(escalationGameAddress, QuestionOutcome.Yes) + assert.strictEqual(rootAfterSecondDeposit, expectedTwoLeafRoot, 'two appended leaves should bag into the expected Merkle Mountain Range root') + }) + + test('fork carry leaf paging uses node cursors and skips consumed local leaves', async () => { + const { escalationGameAddress, testSecurityPoolAddress } = await deployEscalationGameWithProofPool() + await startEscalation(escalationGameAddress, reportBond, nonDecisionThreshold) + await depositOnOutcomeViaProofTestSecurityPool(testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, reportBond) + await depositOnOutcomeViaProofTestSecurityPool(testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, 2n * reportBond) + await depositOnOutcomeViaProofTestSecurityPool(testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, 3n * reportBond) + + const activationTime = await getActivationTime(client, escalationGameAddress) + await mockWindow.setTime(activationTime + ESCALATION_TIME_LENGTH + 1n) + await claimDepositForWinningViaTestSecurityPool(testSecurityPoolAddress, 1n, QuestionOutcome.Yes) + + const [firstPage, firstNextNodeId] = await readCarryLeafPage(escalationGameAddress, QuestionOutcome.Yes, 0n, 1n) + assert.strictEqual(firstPage.length, 1, 'first page should include one unresolved leaf') + assert.strictEqual(firstPage[0]?.parentDepositIndex, 2n, 'first page should start from the newest unresolved leaf') + assert.strictEqual(firstPage[0]?.amount, 3n * reportBond, 'first page should preserve the newest unresolved leaf amount') + assert.strictEqual(firstNextNodeId, 2n, 'first page should return the next raw node cursor') + + const [secondPage, secondNextNodeId] = await readCarryLeafPage(escalationGameAddress, QuestionOutcome.Yes, firstNextNodeId, 2n) + assert.strictEqual(secondPage.length, 1, 'second page should skip the consumed middle leaf and include the oldest unresolved leaf') + assert.strictEqual(secondPage[0]?.parentDepositIndex, 0n, 'second page should return the remaining unresolved oldest leaf') + assert.strictEqual(secondPage[0]?.amount, reportBond, 'second page should preserve the oldest unresolved leaf amount') + assert.strictEqual(secondNextNodeId, 0n, 'second page should finish the cursor traversal') + }) + + test('fork carry leaf paging rejects cursors from another outcome chain', async () => { + const { escalationGameAddress, testSecurityPoolAddress } = await deployEscalationGameWithProofPool() + await startEscalation(escalationGameAddress, reportBond, nonDecisionThreshold) + await depositOnOutcomeViaProofTestSecurityPool(testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, reportBond) + await depositOnOutcomeViaProofTestSecurityPool(testSecurityPoolAddress, client.account.address, QuestionOutcome.No, 2n * reportBond) + + const [yesPage] = await readCarryLeafPage(escalationGameAddress, QuestionOutcome.Yes, 0n, 1n) + const yesNodeId = yesPage[0]?.sourceNodeId + assert.notStrictEqual(yesNodeId, undefined) + await assert.rejects(readCarryLeafPage(escalationGameAddress, QuestionOutcome.No, yesNodeId ?? 0n, 1n), /cursor outcome mismatch/i) + }) + + test('fork carry snapshot initialization normalizes zero nullifier roots to the empty sparse-tree root', async () => { + const child = await deployEscalationGameWithProofPool() + await startEscalationFromFork(child.escalationGameAddress, reportBond, nonDecisionThreshold, 0n) + + const initializeSnapshotHash = await initializeSnapshotViaTestSecurityPool(child.testSecurityPoolAddress, [zeroPeakArray(), zeroPeakArray(), zeroPeakArray()], [0n, 0n, 0n], [0n, 0n, 0n], [zeroHash(), zeroHash(), zeroHash()]) + + const emptyNullifierRoot = new SparseNullifierTree().root + const snapshotInitialized = await readForkCarrySnapshotInitialized(child.escalationGameAddress) + const yesNullifierRoot = await readNullifierRoot(child.escalationGameAddress, QuestionOutcome.Yes) + const forkCarrySnapshot = await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: child.escalationGameAddress, + functionName: 'getForkCarrySnapshot', + args: [], + }) + const initializeSnapshotReceipt = await client.waitForTransactionReceipt({ hash: initializeSnapshotHash }) + const snapshotInitializedLog = initializeSnapshotReceipt.logs + .map(log => { + try { + return decodeEventLog({ + abi: peripherals_EscalationGame_EscalationGame.abi, + data: log.data, + topics: log.topics, + }) + } catch (error) { + if (!isIgnorableLogDecodeError(error)) throw error + return undefined + } + }) + .find(log => log?.eventName === 'ForkCarrySnapshotInitialized') + + if (snapshotInitializedLog === undefined) { + throw new Error('missing ForkCarrySnapshotInitialized log') + } + + assert.strictEqual(snapshotInitialized, true, 'initialized snapshots with empty nullifier roots should not look uninitialized') + assert.strictEqual(yesNullifierRoot, emptyNullifierRoot, 'outcome state should expose the normalized empty nullifier root') + assert.strictEqual(forkCarrySnapshot[3][1], emptyNullifierRoot, 'fork carry snapshots should export normalized empty nullifier roots') + assert.deepStrictEqual(snapshotInitializedLog.args.inheritedNullifierRoots, [emptyNullifierRoot, emptyNullifierRoot, emptyNullifierRoot], 'snapshot initialization logs should emit normalized empty nullifier roots') + }) + + test('fork carry child instances can settle multiple inherited carried deposits from proofs only', async () => { + const parent = await deployEscalationGameWithProofPool() + await startEscalation(parent.escalationGameAddress, reportBond, nonDecisionThreshold) + await depositOnOutcomeViaProofTestSecurityPool(parent.testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, reportBond) + await depositOnOutcomeViaProofTestSecurityPool(parent.testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, 2n * reportBond) + + const parentLeafCount = await readCarryLeafCount(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentCarryTotal = await readCarryTotal(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentNullifierRoot = await readNullifierRoot(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentYesPeaks = await readCarryPeaks(parent.escalationGameAddress, QuestionOutcome.Yes) + + const firstLeafHash = hashCarryLeaf(client.account.address, QuestionOutcome.Yes, reportBond, 0n, reportBond, 1n) + const secondLeafHash = hashCarryLeaf(client.account.address, QuestionOutcome.Yes, 2n * reportBond, 1n, 3n * reportBond, 2n) + + const child = await deployEscalationGameWithProofPool() + await startEscalationFromFork(child.escalationGameAddress, reportBond, nonDecisionThreshold, 0n) + await initializeSnapshotViaTestSecurityPool(child.testSecurityPoolAddress, [zeroPeakArray(), parentYesPeaks, zeroPeakArray()], [0n, parentLeafCount, 0n], [0n, parentCarryTotal, 0n], [zeroHash(), parentNullifierRoot, zeroHash()]) + await advanceForkContinuationPastStart(child.escalationGameAddress, recursiveResolutionTargetCost) + + const nullifierTree = new SparseNullifierTree() + const firstProof = await createCarryProof(parent.escalationGameAddress, 0n, 0n, 1n, [secondLeafHash], nullifierTree.getProof(0n)) + await withdrawDepositViaProofTestSecurityPool(child.testSecurityPoolAddress, QuestionOutcome.Yes, firstProof) + nullifierTree.consume(0n) + + const secondProof = await createCarryProof(parent.escalationGameAddress, 1n, 1n, 1n, [firstLeafHash], nullifierTree.getProof(1n)) + await withdrawDepositViaProofTestSecurityPool(child.testSecurityPoolAddress, QuestionOutcome.Yes, secondProof) + + const remainingCarryTotal = await readCarryTotal(child.escalationGameAddress, QuestionOutcome.Yes) + assert.strictEqual(remainingCarryTotal, 0n) + }) + + test('fork carry proof settlement rejects reusing the same carried proof twice', async () => { + const parent = await deployEscalationGameWithProofPool() + await startEscalation(parent.escalationGameAddress, reportBond, nonDecisionThreshold) + await depositOnOutcomeViaProofTestSecurityPool(parent.testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, reportBond) + + const parentLeafCount = await readCarryLeafCount(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentCarryTotal = await readCarryTotal(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentNullifierRoot = await readNullifierRoot(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentYesPeaks = await readCarryPeaks(parent.escalationGameAddress, QuestionOutcome.Yes) + + const child = await deployEscalationGameWithProofPool() + await startEscalationFromFork(child.escalationGameAddress, reportBond, nonDecisionThreshold, 0n) + await initializeSnapshotViaTestSecurityPool(child.testSecurityPoolAddress, [zeroPeakArray(), parentYesPeaks, zeroPeakArray()], [0n, parentLeafCount, 0n], [0n, parentCarryTotal, 0n], [zeroHash(), parentNullifierRoot, zeroHash()]) + await advanceForkContinuationPastStart(child.escalationGameAddress) + + const nullifierTree = new SparseNullifierTree() + const proof = await createCarryProof(parent.escalationGameAddress, 0n, 0n, 0n, [], nullifierTree.getProof(0n)) + await withdrawDepositViaProofTestSecurityPool(child.testSecurityPoolAddress, QuestionOutcome.Yes, proof) + await assert.rejects(withdrawDepositViaProofTestSecurityPool(child.testSecurityPoolAddress, QuestionOutcome.Yes, proof), /invalid nullifier proof|deposit already settled/) + }) + + test('fork carry grandchild instances can settle inherited parent carry from a recursive child snapshot', async () => { + const parent = await deployEscalationGameWithProofPool() + await startEscalation(parent.escalationGameAddress, reportBond, nonDecisionThreshold) + await depositOnOutcomeViaProofTestSecurityPool(parent.testSecurityPoolAddress, client.account.address, QuestionOutcome.Invalid, 2n * reportBond) + await depositOnOutcomeViaProofTestSecurityPool(parent.testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, 3n * reportBond) + + const parentInvalidPeaks = await readCarryPeaks(parent.escalationGameAddress, QuestionOutcome.Invalid) + const parentYesPeaks = await readCarryPeaks(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentInvalidLeafCount = await readCarryLeafCount(parent.escalationGameAddress, QuestionOutcome.Invalid) + const parentLeafCount = await readCarryLeafCount(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentInvalidCarryTotal = await readCarryTotal(parent.escalationGameAddress, QuestionOutcome.Invalid) + const parentCarryTotal = await readCarryTotal(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentInvalidNullifierRoot = await readNullifierRoot(parent.escalationGameAddress, QuestionOutcome.Invalid) + const parentNullifierRoot = await readNullifierRoot(parent.escalationGameAddress, QuestionOutcome.Yes) + + const child = await deployEscalationGameWithProofPool() + await startEscalationFromFork(child.escalationGameAddress, reportBond, nonDecisionThreshold, 0n) + await initializeSnapshotViaTestSecurityPool(child.testSecurityPoolAddress, [parentInvalidPeaks, parentYesPeaks, zeroPeakArray()], [parentInvalidLeafCount, parentLeafCount, 0n], [parentInvalidCarryTotal, parentCarryTotal, 0n], [parentInvalidNullifierRoot, parentNullifierRoot, zeroHash()]) + await depositOnOutcomeViaProofTestSecurityPool(child.testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, reportBond) + await advanceForkContinuationPastStart(child.escalationGameAddress, recursiveResolutionTargetCost) + + const childInvalidPeaks = await readCarryPeaks(child.escalationGameAddress, QuestionOutcome.Invalid) + const childYesPeaks = await readCarryPeaks(child.escalationGameAddress, QuestionOutcome.Yes) + const childInvalidLeafCount = await readCarryLeafCount(child.escalationGameAddress, QuestionOutcome.Invalid) + const childLeafCount = await readCarryLeafCount(child.escalationGameAddress, QuestionOutcome.Yes) + const childInvalidCarryTotal = await readCarryTotal(child.escalationGameAddress, QuestionOutcome.Invalid) + const childCarryTotal = await readCarryTotal(child.escalationGameAddress, QuestionOutcome.Yes) + const childInvalidNullifierRoot = await readNullifierRoot(child.escalationGameAddress, QuestionOutcome.Invalid) + const childNullifierRoot = await readNullifierRoot(child.escalationGameAddress, QuestionOutcome.Yes) + + const parentLeafHash = hashCarryLeaf(client.account.address, QuestionOutcome.Yes, 3n * reportBond, 0n, 3n * reportBond, 2n) + const childLocalLeafHash = hashCarryLeaf(client.account.address, QuestionOutcome.Yes, reportBond, 1n << 255n, 4n * reportBond, 1n) + + const grandchild = await deployEscalationGameWithProofPool() + await startEscalationFromFork(grandchild.escalationGameAddress, reportBond, nonDecisionThreshold, 0n) + await initializeSnapshotViaTestSecurityPool(grandchild.testSecurityPoolAddress, [childInvalidPeaks, childYesPeaks, zeroPeakArray()], [childInvalidLeafCount, childLeafCount, 0n], [childInvalidCarryTotal, childCarryTotal, 0n], [childInvalidNullifierRoot, childNullifierRoot, zeroHash()]) + await advanceForkContinuationPastStart(grandchild.escalationGameAddress, recursiveResolutionTargetCost) + + const nullifierTree = new SparseNullifierTree() + const proof = { + depositor: client.account.address, + amount: 3n * reportBond, + parentDepositIndex: 0n, + cumulativeAmount: 3n * reportBond, + sourceNodeId: 2n, + leafIndex: 0n, + merkleMountainRangePeakIndex: 1n, + merkleMountainRangeSiblings: [childLocalLeafHash], + nullifierSiblings: nullifierTree.getProof(0n), + } + await withdrawDepositViaProofTestSecurityPool(grandchild.testSecurityPoolAddress, QuestionOutcome.Yes, proof) + + const remainingCarryTotal = await readCarryTotal(grandchild.escalationGameAddress, QuestionOutcome.Yes) + assert.strictEqual(remainingCarryTotal, reportBond, 'only the child-local unresolved carry should remain after settling the inherited parent leaf') + const grandchildRoot = await readCarryRoot(grandchild.escalationGameAddress, QuestionOutcome.Yes) + assert.strictEqual(grandchildRoot, keccak256(concatHex([parentLeafHash, childLocalLeafHash])), 'grandchild should snapshot the recursive child carry set as a true two-leaf Merkle Mountain Range') + }) + + test('fork carry grandchild instances reject child-local leaves that were already settled before the recursive fork', async () => { + const parent = await deployEscalationGameWithProofPool() + await startEscalation(parent.escalationGameAddress, reportBond, nonDecisionThreshold) + await depositOnOutcomeViaProofTestSecurityPool(parent.testSecurityPoolAddress, client.account.address, QuestionOutcome.Invalid, 2n * reportBond) + await depositOnOutcomeViaProofTestSecurityPool(parent.testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, 3n * reportBond) + + const parentInvalidPeaks = await readCarryPeaks(parent.escalationGameAddress, QuestionOutcome.Invalid) + const parentYesPeaks = await readCarryPeaks(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentInvalidLeafCount = await readCarryLeafCount(parent.escalationGameAddress, QuestionOutcome.Invalid) + const parentYesLeafCount = await readCarryLeafCount(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentInvalidCarryTotal = await readCarryTotal(parent.escalationGameAddress, QuestionOutcome.Invalid) + const parentYesCarryTotal = await readCarryTotal(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentInvalidNullifierRoot = await readNullifierRoot(parent.escalationGameAddress, QuestionOutcome.Invalid) + const parentYesNullifierRoot = await readNullifierRoot(parent.escalationGameAddress, QuestionOutcome.Yes) + + const child = await deployEscalationGameWithProofPool() + await startEscalationFromFork(child.escalationGameAddress, reportBond, nonDecisionThreshold, 0n) + await initializeSnapshotViaTestSecurityPool(child.testSecurityPoolAddress, [parentInvalidPeaks, parentYesPeaks, zeroPeakArray()], [parentInvalidLeafCount, parentYesLeafCount, 0n], [parentInvalidCarryTotal, parentYesCarryTotal, 0n], [parentInvalidNullifierRoot, parentYesNullifierRoot, zeroHash()]) + await depositOnOutcomeViaProofTestSecurityPool(child.testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, reportBond) + await advanceForkContinuationPastStart(child.escalationGameAddress, recursiveResolutionTargetCost) + await claimDepositForWinningViaTestSecurityPool(child.testSecurityPoolAddress, 0n, QuestionOutcome.Yes) + + const childInvalidPeaks = await readCarryPeaks(child.escalationGameAddress, QuestionOutcome.Invalid) + const childYesPeaks = await readCarryPeaks(child.escalationGameAddress, QuestionOutcome.Yes) + const childInvalidLeafCount = await readCarryLeafCount(child.escalationGameAddress, QuestionOutcome.Invalid) + const childYesLeafCount = await readCarryLeafCount(child.escalationGameAddress, QuestionOutcome.Yes) + const childInvalidCarryTotal = await readCarryTotal(child.escalationGameAddress, QuestionOutcome.Invalid) + const childYesCarryTotal = await readCarryTotal(child.escalationGameAddress, QuestionOutcome.Yes) + const childInvalidNullifierRoot = await readNullifierRoot(child.escalationGameAddress, QuestionOutcome.Invalid) + const childYesNullifierRoot = await readNullifierRoot(child.escalationGameAddress, QuestionOutcome.Yes) + const parentLeafHash = hashCarryLeaf(client.account.address, QuestionOutcome.Yes, 3n * reportBond, 0n, 3n * reportBond, 2n) + const grandchild = await deployEscalationGameWithProofPool() + await startEscalationFromFork(grandchild.escalationGameAddress, reportBond, nonDecisionThreshold, 0n) + await initializeSnapshotViaTestSecurityPool(grandchild.testSecurityPoolAddress, [childInvalidPeaks, childYesPeaks, zeroPeakArray()], [childInvalidLeafCount, childYesLeafCount, 0n], [childInvalidCarryTotal, childYesCarryTotal, 0n], [childInvalidNullifierRoot, childYesNullifierRoot, zeroHash()]) + await advanceForkContinuationPastStart(grandchild.escalationGameAddress, recursiveResolutionTargetCost) + + const nullifierTree = new SparseNullifierTree() + const settledChildLocalLeafProof = { + depositor: client.account.address, + amount: reportBond, + parentDepositIndex: 1n << 255n, + cumulativeAmount: 4n * reportBond, + sourceNodeId: 1n, + leafIndex: 1n, + merkleMountainRangePeakIndex: 1n, + merkleMountainRangeSiblings: [parentLeafHash], + nullifierSiblings: nullifierTree.getProof(1n << 255n), + } + + await assert.rejects( + withdrawDepositViaProofTestSecurityPool(grandchild.testSecurityPoolAddress, QuestionOutcome.Yes, settledChildLocalLeafProof), + /invalid nullifier proof|deposit already settled|peak absent|invalid carry inclusion proof/, + 'grandchild carry settlement must reject a child-local leaf that was already settled before the recursive fork', + ) + + const grandchildRoot = await readCarryRoot(grandchild.escalationGameAddress, QuestionOutcome.Yes) + assert.strictEqual(grandchildRoot, parentLeafHash, 'the recursive grandchild snapshot should exclude child-local leaves that were already settled before the fork') + }) + + test('proof-backed withdrawDeposit reverts before question finalization', async () => { + const parent = await deployEscalationGameWithProofPool() + await startEscalation(parent.escalationGameAddress, reportBond, nonDecisionThreshold) + await depositOnOutcomeViaProofTestSecurityPool(parent.testSecurityPoolAddress, client.account.address, QuestionOutcome.Yes, reportBond) + + const parentLeafCount = await readCarryLeafCount(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentCarryTotal = await readCarryTotal(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentNullifierRoot = await readNullifierRoot(parent.escalationGameAddress, QuestionOutcome.Yes) + const parentYesPeaks = await readCarryPeaks(parent.escalationGameAddress, QuestionOutcome.Yes) + + const child = await deployEscalationGameWithProofPool() + await startEscalationFromFork(child.escalationGameAddress, reportBond, nonDecisionThreshold, 0n) + await initializeSnapshotViaTestSecurityPool(child.testSecurityPoolAddress, [zeroPeakArray(), parentYesPeaks, zeroPeakArray()], [0n, parentLeafCount, 0n], [0n, parentCarryTotal, 0n], [zeroHash(), parentNullifierRoot, zeroHash()]) + + const proof = await createCarryProof(parent.escalationGameAddress, 0n, 0n, 0n, [], new SparseNullifierTree().getProof(0n)) + await assert.rejects(withdrawDepositViaProofTestSecurityPool(child.testSecurityPoolAddress, QuestionOutcome.Yes, proof), /Question has not finalized!/i) + }) + // =================== Attrition Cost Function Tests =================== test('computeIterativeAttritionCost: edge cases - time 0 and max time', async () => { diff --git a/solidity/ts/tests/peripherals.test.ts b/solidity/ts/tests/peripherals.test.ts index 58f5705f..0e4a5731 100644 --- a/solidity/ts/tests/peripherals.test.ts +++ b/solidity/ts/tests/peripherals.test.ts @@ -1,7 +1,7 @@ import { beforeAll, beforeEach, describe, setDefaultTimeout, test } from 'bun:test' import assert from 'node:assert/strict' -import { decodeEventLog, encodeAbiParameters, keccak256 } from 'viem' -import type { Abi, Address, Hash } from 'viem' +import { concatHex, decodeEventLog, encodeAbiParameters, keccak256 } from 'viem' +import type { Abi, Address, Hash, Hex } from 'viem' import { AnvilWindowEthereum } from '../testsuite/simulator/AnvilWindowEthereum' import { TEST_TIMEOUT_MS, useIsolatedAnvilNode } from '../testsuite/simulator/useIsolatedAnvilNode' import { sortBigIntsAscending } from '@zoltar/shared/bigInt' @@ -22,7 +22,6 @@ import { approximatelyEqual, ensureDefined, strictEqual18Decimal, strictEqualTyp import { claimAuctionProceeds, createChildUniverse, - encodeImportedForkDepositIndex, finalizeTruthAuction, getMigratedRep, getQuestionOutcome, @@ -34,7 +33,7 @@ import { migrateVaultWithUnresolvedEscalation, startTruthAuction, } from '../testsuite/simulator/utils/contracts/securityPoolForker' -import { getEscalationGameDeposits, getEscalationGameTotalCost, getNonDecisionThreshold, getQuestionResolution, getStartBond, getUnsettledDepositIndexesByOutcomeAndDepositor, getUnsettledImportedDepositIndexesByOutcomeAndDepositor } from '../testsuite/simulator/utils/contracts/escalationGame' +import { getEscalationGameDeposits, getEscalationGameOutcomeState, getEscalationGameTotalCost, getNonDecisionThreshold, getQuestionResolution, getStartBond } from '../testsuite/simulator/utils/contracts/escalationGame' import { ensureZoltarDeployed, forkUniverse, getMigrationRepBalance, getRepTokenAddress, getTotalTheoreticalSupply, getZoltarAddress, getZoltarForkThreshold } from '../testsuite/simulator/utils/contracts/zoltar' import { getTotalRepPurchased } from '../testsuite/simulator/utils/contracts/auction' import { isIgnorableLogDecodeError } from './logDecodeErrors' @@ -92,6 +91,8 @@ const getMigrationProxyAddressAbi = [ }, ] satisfies Abi +const NULLIFIER_DEPTH = 64 + describe('Peripherals Contract Test Suite', () => { const { getAnvilWindowEthereum, setBaselineSnapshot } = useIsolatedAnvilNode() let mockWindow: AnvilWindowEthereum @@ -167,6 +168,86 @@ describe('Peripherals Contract Test Suite', () => { return await poolOwnershipToRep(client, securityPoolAddresses.securityPool, vault.repDepositShare) } + const zeroHash = () => `0x${'0'.repeat(64)}` as Hex + + const hashCarryLeaf = (depositor: Address, outcome: QuestionOutcome, amount: bigint, parentDepositIndex: bigint, cumulativeAmount: bigint, sourceNodeId: bigint) => + keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint8' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }, { type: 'uint256' }], [depositor, outcome, amount, parentDepositIndex, cumulativeAmount, sourceNodeId])) + + const hashParent = (left: Hex, right: Hex) => keccak256(concatHex([left, right])) + + const buildZeroHashes = () => { + const zeroHashes: Hex[] = [zeroHash()] + for (let depth = 0; depth < NULLIFIER_DEPTH; depth += 1) { + const nextZeroHash = zeroHashes[depth] + if (nextZeroHash === undefined) throw new Error(`Missing zero hash at depth ${depth}`) + zeroHashes.push(hashParent(nextZeroHash, nextZeroHash)) + } + return zeroHashes + } + + class SparseNullifierTree { + private readonly zeroHashes = buildZeroHashes() + private readonly nodes = new Map() + private readonly pathMask = (1n << BigInt(NULLIFIER_DEPTH)) - 1n + root: Hex = this.zeroHashes[NULLIFIER_DEPTH] ?? zeroHash() + + private getPath(parentDepositIndex: bigint) { + return BigInt(keccak256(encodeAbiParameters([{ type: 'uint256' }], [parentDepositIndex]))) & this.pathMask + } + + getProof(parentDepositIndex: bigint) { + const path = this.getPath(parentDepositIndex) + const siblings: Hex[] = [] + let nodeIndex = path + for (let depth = 0; depth < NULLIFIER_DEPTH; depth += 1) { + const siblingIndex = nodeIndex ^ 1n + const siblingHash = this.nodes.get(`${depth}:${siblingIndex}`) ?? this.zeroHashes[depth] + if (siblingHash === undefined) throw new Error(`Missing sibling hash at depth ${depth}`) + siblings.push(siblingHash) + nodeIndex >>= 1n + } + return siblings + } + + consume(parentDepositIndex: bigint) { + const path = this.getPath(parentDepositIndex) + let nodeIndex = path + let nodeHash = `0x${'0'.repeat(63)}1` as Hex + this.nodes.set(`0:${nodeIndex}`, nodeHash) + for (let depth = 0; depth < NULLIFIER_DEPTH; depth += 1) { + const isRightNode = (nodeIndex & 1n) === 1n + const siblingIndex = nodeIndex ^ 1n + const siblingHash = this.nodes.get(`${depth}:${siblingIndex}`) ?? this.zeroHashes[depth] + if (siblingHash === undefined) throw new Error(`Missing sibling hash at depth ${depth}`) + const parentHash = isRightNode ? hashParent(siblingHash, nodeHash) : hashParent(nodeHash, siblingHash) + nodeIndex >>= 1n + nodeHash = parentHash + this.nodes.set(`${depth + 1}:${nodeIndex}`, nodeHash) + } + this.root = nodeHash + } + } + + const createCarryProof = async (escalationGameAddress: Address, parentDepositIndex: bigint, leafIndex: bigint, merkleMountainRangePeakIndex: bigint, merkleMountainRangeSiblings: readonly Hex[], nullifierSiblings: readonly Hex[]) => { + const node = await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'nodes', + args: [leafIndex + 1n], + }) + return { + depositor: node[1], + amount: node[3], + parentDepositIndex, + cumulativeAmount: node[5], + sourceNodeId: leafIndex + 1n, + leafIndex, + merkleMountainRangeSiblings, + merkleMountainRangePeakIndex, + nullifierSiblings, + } + } + const finalizeQuestionAsYesWithoutFork = async () => { const endTime = await getQuestionEndDate(client, questionId) await mockWindow.setTime(endTime + 10000n) @@ -753,13 +834,13 @@ describe('Peripherals Contract Test Suite', () => { await depositToEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, reportBond) await mockWindow.advanceTime(10n * DAY) - const unsettledBefore = await getUnsettledDepositIndexesByOutcomeAndDepositor(client, securityPoolAddresses.escalationGame, QuestionOutcome.Yes, client.account.address, 0n, 10n) + const unsettledBefore = (await getEscalationGameDeposits(client, securityPoolAddresses.escalationGame, QuestionOutcome.Yes)).filter(deposit => deposit.depositor === client.account.address && deposit.amount > 0n).map(deposit => deposit.depositIndex) strictEqualTypeSafe(unsettledBefore.length, 1, 'the winning deposit should be discoverable before settlement') strictEqualTypeSafe(unsettledBefore[0], 0n, 'the first winning deposit should be returned') await withdrawFromEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [0n]) - const unsettledAfter = await getUnsettledDepositIndexesByOutcomeAndDepositor(client, securityPoolAddresses.escalationGame, QuestionOutcome.Yes, client.account.address, 0n, 10n) + const unsettledAfter = (await getEscalationGameDeposits(client, securityPoolAddresses.escalationGame, QuestionOutcome.Yes)).filter(deposit => deposit.depositor === client.account.address && deposit.amount > 0n).map(deposit => deposit.depositIndex) strictEqualTypeSafe(unsettledAfter.length, 0, 'settled winning deposits should disappear from discovery results') await assert.rejects(withdrawFromEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [0n]), /deposit already settled/) }) @@ -840,7 +921,8 @@ describe('Peripherals Contract Test Suite', () => { await assert.rejects(migrateVault(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes)) const parentVaultBeforeMigration = await getSecurityVault(client, securityPoolAddresses.securityPool, client.account.address) const parentForkData = await getSecurityPoolForkerForkData(client, securityPoolAddresses.securityPool) - await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [], [0n], []) + const parentOutcomeStateBeforeMigration = await getEscalationGameOutcomeState(client, securityPoolAddresses.escalationGame, QuestionOutcome.Yes) + await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes) const yesUniverse = getChildUniverseId(genesisUniverse, QuestionOutcome.Yes) const yesSecurityPool = getSecurityPoolAddresses(securityPoolAddresses.securityPool, yesUniverse, questionId, securityMultiplier) @@ -848,14 +930,106 @@ describe('Peripherals Contract Test Suite', () => { const childVaultAfterMigration = await getSecurityVault(client, yesSecurityPool.securityPool, client.account.address) const childForkData = await getSecurityPoolForkerForkData(client, yesSecurityPool.securityPool) const childEscalationGame = await getSecurityPoolsEscalationGame(client, yesSecurityPool.securityPool) - const importedIndexes = await getUnsettledImportedDepositIndexesByOutcomeAndDepositor(client, childEscalationGame, QuestionOutcome.Yes, client.account.address, 0n, 10n) + const childOutcomeState = await getEscalationGameOutcomeState(client, childEscalationGame, QuestionOutcome.Yes) strictEqualTypeSafe(parentVaultBeforeMigration.lockedRepInEscalationGame, unresolvedDeposit, 'the parent lock should equal the unresolved principal before migration') strictEqualTypeSafe(parentVaultAfterMigration.lockedRepInEscalationGame, 0n, 'atomic unresolved migration should clear the parent lock') strictEqualTypeSafe(childVaultAfterMigration.lockedRepInEscalationGame, unresolvedDeposit, 'the child vault should inherit the unresolved locked principal') strictEqualTypeSafe(childForkData.migratedRep, parentForkData.repAtFork, 'the child branch should receive the vaults total REP claim exactly once') - strictEqualTypeSafe(importedIndexes.length, 1, 'the imported unresolved deposit should be discoverable in the child continuation game') - strictEqualTypeSafe(importedIndexes[0], 0n, 'the original parent deposit index should be preserved in the child continuation game') + strictEqualTypeSafe(childOutcomeState.currentCarryRoot, parentOutcomeStateBeforeMigration.currentCarryRoot, 'the child continuation game should inherit the parent carry root by snapshot') + strictEqualTypeSafe(childOutcomeState.currentLeafCount, parentOutcomeStateBeforeMigration.currentLeafCount, 'the child continuation game should inherit the parent carry leaf count by snapshot') + strictEqualTypeSafe(childOutcomeState.currentCarryTotal, parentOutcomeStateBeforeMigration.currentCarryTotal, 'the child continuation game should inherit the parent unresolved carry total by snapshot') + }) + + test('withdrawForkedEscalationDeposits settles inherited child carry without imported indexes', async () => { + const endTime = await getQuestionEndDate(client, questionId) + await mockWindow.setTime(endTime + 10000n) + + await depositToEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, reportBond) + await depositToEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, 2n * reportBond) + + const attackerClient = createWriteClient(mockWindow, TEST_ADDRESSES[1], 0) + const otherQuestionData = { + ...questionData, + title: 'forked proof settlement source question', + } + const otherQuestionId = getQuestionId(otherQuestionData, outcomes) + await createQuestion(attackerClient, otherQuestionData, outcomes) + await approveToken(attackerClient, addressString(GENESIS_REPUTATION_TOKEN), getZoltarAddress()) + await forkUniverse(attackerClient, genesisUniverse, otherQuestionId) + await initiateSecurityPoolFork(client, securityPoolAddresses.securityPool) + await migrateRepToZoltar(client, securityPoolAddresses.securityPool, [QuestionOutcome.Yes]) + await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes) + + const yesUniverse = getChildUniverseId(genesisUniverse, QuestionOutcome.Yes) + const yesSecurityPool = getSecurityPoolAddresses(securityPoolAddresses.securityPool, yesUniverse, questionId, securityMultiplier) + await mockWindow.advanceTime(8n * 7n * DAY + DAY) + await startTruthAuction(client, yesSecurityPool.securityPool) + if ((await getSystemState(client, yesSecurityPool.securityPool)) === SystemState.ForkTruthAuction) { + await finalizeTruthAuction(client, yesSecurityPool.securityPool) + } + strictEqualTypeSafe(await getSystemState(client, yesSecurityPool.securityPool), SystemState.Operational, 'child pool should become operational before proof settlement') + + const childEscalationGame = await getSecurityPoolsEscalationGame(client, yesSecurityPool.securityPool) + const childCarryTotalBeforeSettlement = (await getEscalationGameOutcomeState(client, childEscalationGame, QuestionOutcome.Yes)).currentCarryTotal + strictEqualTypeSafe(childCarryTotalBeforeSettlement, 3n * reportBond, 'child carry total should equal the inherited unresolved principal before proof settlement') + + const firstLeafHash = hashCarryLeaf(client.account.address, QuestionOutcome.Yes, reportBond, 0n, reportBond, 1n) + const secondLeafHash = hashCarryLeaf(client.account.address, QuestionOutcome.Yes, 2n * reportBond, 1n, 3n * reportBond, 2n) + const nullifierTree = new SparseNullifierTree() + const firstProof = await createCarryProof(securityPoolAddresses.escalationGame, 0n, 0n, 1n, [secondLeafHash], nullifierTree.getProof(0n)) + nullifierTree.consume(0n) + const secondProof = await createCarryProof(securityPoolAddresses.escalationGame, 1n, 1n, 1n, [firstLeafHash], nullifierTree.getProof(1n)) + + await withdrawForkedEscalationDeposits(client, yesSecurityPool.securityPool, QuestionOutcome.Yes, [firstProof, secondProof]) + + const childVaultAfterSettlement = await getSecurityVault(client, yesSecurityPool.securityPool, client.account.address) + const childCarryTotalAfterSettlement = (await getEscalationGameOutcomeState(client, childEscalationGame, QuestionOutcome.Yes)).currentCarryTotal + strictEqualTypeSafe(childVaultAfterSettlement.lockedRepInEscalationGame, 0n, 'proof settlement should clear the inherited escalation lock') + strictEqualTypeSafe(childCarryTotalAfterSettlement, 0n, 'proof settlement should consume the inherited carry total') + await assert.rejects(withdrawForkedEscalationDeposits(client, yesSecurityPool.securityPool, QuestionOutcome.Yes, [firstProof]), /invalid nullifier proof|deposit already settled/) + }) + + test('withdrawForkedEscalationDeposits forfeits inherited losing child carry without imported indexes', async () => { + const endTime = await getQuestionEndDate(client, questionId) + await mockWindow.setTime(endTime + 10000n) + + await depositToEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, reportBond) + + const attackerClient = createWriteClient(mockWindow, TEST_ADDRESSES[1], 0) + const otherQuestionData = { + ...questionData, + title: 'forked proof losing settlement source question', + } + const otherQuestionId = getQuestionId(otherQuestionData, outcomes) + await createQuestion(attackerClient, otherQuestionData, outcomes) + await approveToken(attackerClient, addressString(GENESIS_REPUTATION_TOKEN), getZoltarAddress()) + await forkUniverse(attackerClient, genesisUniverse, otherQuestionId) + await initiateSecurityPoolFork(client, securityPoolAddresses.securityPool) + await migrateRepToZoltar(client, securityPoolAddresses.securityPool, [QuestionOutcome.No]) + await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.No) + + const noUniverse = getChildUniverseId(genesisUniverse, QuestionOutcome.No) + const noSecurityPool = getSecurityPoolAddresses(securityPoolAddresses.securityPool, noUniverse, questionId, securityMultiplier) + await mockWindow.advanceTime(8n * 7n * DAY + DAY) + await startTruthAuction(client, noSecurityPool.securityPool) + if ((await getSystemState(client, noSecurityPool.securityPool)) === SystemState.ForkTruthAuction) { + await finalizeTruthAuction(client, noSecurityPool.securityPool) + } + strictEqualTypeSafe(await getSystemState(client, noSecurityPool.securityPool), SystemState.Operational, 'child pool should become operational before proof forfeiture') + + const childEscalationGame = await getSecurityPoolsEscalationGame(client, noSecurityPool.securityPool) + const childCarryTotalBeforeSettlement = (await getEscalationGameOutcomeState(client, childEscalationGame, QuestionOutcome.Yes)).currentCarryTotal + strictEqualTypeSafe(childCarryTotalBeforeSettlement, reportBond, 'child carry total should equal the inherited unresolved principal before proof forfeiture') + + const proof = await createCarryProof(securityPoolAddresses.escalationGame, 0n, 0n, 0n, [], new SparseNullifierTree().getProof(0n)) + await withdrawForkedEscalationDeposits(client, noSecurityPool.securityPool, QuestionOutcome.Yes, [proof]) + + const childVaultAfterSettlement = await getSecurityVault(client, noSecurityPool.securityPool, client.account.address) + const childCarryTotalAfterSettlement = (await getEscalationGameOutcomeState(client, childEscalationGame, QuestionOutcome.Yes)).currentCarryTotal + strictEqualTypeSafe(childVaultAfterSettlement.lockedRepInEscalationGame, 0n, 'proof forfeiture should clear the inherited escalation lock') + strictEqualTypeSafe(childCarryTotalAfterSettlement, 0n, 'proof forfeiture should consume the inherited carry total') + await assert.rejects(withdrawForkedEscalationDeposits(client, noSecurityPool.securityPool, QuestionOutcome.Yes, [proof]), /invalid nullifier proof|deposit already settled/) }) test('one unmigrated unresolved lock cannot keep the child continuation branch frozen after the migration window', async () => { @@ -891,7 +1065,7 @@ describe('Peripherals Contract Test Suite', () => { assert.ok(await contractExists(client, childEscalationGame), 'child should initialize the paused continuation game as soon as the branch exists') strictEqualTypeSafe(await getAwaitingForkContinuation(client, yesSecurityPool.securityPool), true, 'child should await fork continuation while unresolved migration is pending') - await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [], [0n], []) + await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes) strictEqualTypeSafe(await getAwaitingForkContinuation(client, yesSecurityPool.securityPool), true, 'one remaining parent lock should still keep the branch paused during the migration window') await mockWindow.advanceTime(8n * 7n * DAY + DAY) @@ -901,10 +1075,10 @@ describe('Peripherals Contract Test Suite', () => { } strictEqualTypeSafe(await getSystemState(client, yesSecurityPool.securityPool), SystemState.Operational, 'child should become operational even before continuation migration') strictEqualTypeSafe(await getAwaitingForkContinuation(client, yesSecurityPool.securityPool), false, 'the migration deadline should clear the await marker even if another vault never migrates') - await assert.rejects(migrateVaultWithUnresolvedEscalation(attackerClient, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [], [1n], []), /migration window closed/) + await assert.rejects(migrateVaultWithUnresolvedEscalation(attackerClient, securityPoolAddresses.securityPool, QuestionOutcome.Yes), /migration window closed/) }) - test('large imported continuation migration keeps discovery bounded by scan window', async () => { + test('large unresolved continuation migration snapshots carry totals without replaying imported deposit indexes', async () => { const endTime = await getQuestionEndDate(client, questionId) await mockWindow.setTime(endTime + 10000n) @@ -914,9 +1088,7 @@ describe('Peripherals Contract Test Suite', () => { let depositCount = capacity > requestedDepositCount ? requestedDepositCount : capacity if (depositCount > 1n) depositCount -= 1n else depositCount = 1n - const depositIndexes: bigint[] = [] for (let index = 0n; index < depositCount; index += 1n) { - depositIndexes.push(index) await depositToEscalationGame(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, reportBond + index) } @@ -934,46 +1106,16 @@ describe('Peripherals Contract Test Suite', () => { await initiateSecurityPoolFork(client, securityPoolAddresses.securityPool) await migrateRepToZoltar(client, securityPoolAddresses.securityPool, [QuestionOutcome.Yes]) - await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [], depositIndexes, []) + const parentOutcomeStateBeforeMigration = await getEscalationGameOutcomeState(client, securityPoolAddresses.escalationGame, QuestionOutcome.Yes) + await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes) const yesUniverse = getChildUniverseId(genesisUniverse, QuestionOutcome.Yes) const yesSecurityPool = getSecurityPoolAddresses(securityPoolAddresses.securityPool, yesUniverse, questionId, securityMultiplier) const yesEscalationGame = await getSecurityPoolsEscalationGame(client, yesSecurityPool.securityPool) - const imported = await getUnsettledImportedDepositIndexesByOutcomeAndDepositor(client, yesEscalationGame, QuestionOutcome.Yes, client.account.address, 0n, 4n) - const importedMid = await getUnsettledImportedDepositIndexesByOutcomeAndDepositor(client, yesEscalationGame, QuestionOutcome.Yes, client.account.address, 4n, 4n) - const importedTail = await getUnsettledImportedDepositIndexesByOutcomeAndDepositor(client, yesEscalationGame, QuestionOutcome.Yes, client.account.address, 8n, 10n) - let expectedImportedCount = 0n - let expectedImportedMidCount = 0n - let expectedImportedTailCount = 0n - if (depositCount > 4n) { - expectedImportedCount = 4n - expectedImportedMidCount = depositCount - 4n >= 4n ? 4n : depositCount - 4n - expectedImportedTailCount = depositCount > 8n ? depositCount - 8n : 0n - } else { - expectedImportedCount = depositCount - expectedImportedMidCount = 0n - expectedImportedTailCount = 0n - } - strictEqualTypeSafe(imported.length, Number(expectedImportedCount), 'scan window should cap result size') - strictEqualTypeSafe(importedMid.length, Number(expectedImportedMidCount), 'scan window should cap result size') - strictEqualTypeSafe(importedTail.length, Number(expectedImportedTailCount), 'final scan should still return only available unsettled entries') - - const allImported = [...imported, ...importedMid, ...importedTail] - strictEqualTypeSafe(allImported.length, Number(depositCount), 'combined bounded scans should return all migrated imported indexes') - allImported.forEach((importedDepositIndex, index) => { - strictEqualTypeSafe(importedDepositIndex, BigInt(index), 'imports should preserve original parent deposit ordering under bounded scans') - }) - - await mockWindow.advanceTime(8n * 7n * DAY + DAY) - await startTruthAuction(client, yesSecurityPool.securityPool) - if ((await getSystemState(client, yesSecurityPool.securityPool)) === SystemState.ForkTruthAuction) { - await finalizeTruthAuction(client, yesSecurityPool.securityPool) - } - await mockWindow.advanceTime(1n) - strictEqualTypeSafe(await getQuestionOutcome(client, yesSecurityPool.securityPool), QuestionOutcome.Yes, 'child continuation should resolve to yes before imported settlement') - await withdrawForkedEscalationDeposits(client, yesSecurityPool.securityPool, QuestionOutcome.Yes, allImported) - const remaining = await getUnsettledImportedDepositIndexesByOutcomeAndDepositor(client, yesEscalationGame, QuestionOutcome.Yes, client.account.address, 0n, 20n) - strictEqualTypeSafe(remaining.length, 0, 'settling all migrated imported deposits should clear discovery state') + const childOutcomeState = await getEscalationGameOutcomeState(client, yesEscalationGame, QuestionOutcome.Yes) + strictEqualTypeSafe(childOutcomeState.currentCarryRoot, parentOutcomeStateBeforeMigration.currentCarryRoot, 'snapshot-only migration should preserve the parent carry root') + strictEqualTypeSafe(childOutcomeState.currentLeafCount, parentOutcomeStateBeforeMigration.currentLeafCount, 'snapshot-only migration should preserve the parent carry leaf count') + strictEqualTypeSafe(childOutcomeState.currentCarryTotal, parentOutcomeStateBeforeMigration.currentCarryTotal, 'snapshot-only migration should preserve the parent unresolved carry total') }) test('forked continuation freezes escalation cost until the child pool becomes operational', async () => { @@ -1009,7 +1151,7 @@ describe('Peripherals Contract Test Suite', () => { await forkUniverse(attackerClient, genesisUniverse, forkSourceQuestionId) await initiateSecurityPoolFork(client, securityPoolAddresses.securityPool) await migrateRepToZoltar(client, securityPoolAddresses.securityPool, [QuestionOutcome.Yes]) - await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [], [0n], []) + await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes) const yesUniverse = getChildUniverseId(genesisUniverse, QuestionOutcome.Yes) const yesSecurityPool = getSecurityPoolAddresses(securityPoolAddresses.securityPool, yesUniverse, questionId, securityMultiplier) @@ -1051,14 +1193,13 @@ describe('Peripherals Contract Test Suite', () => { await forkUniverse(attackerClient, genesisUniverse, firstForkQuestionId) await initiateSecurityPoolFork(client, securityPoolAddresses.securityPool) await migrateRepToZoltar(client, securityPoolAddresses.securityPool, [QuestionOutcome.Yes]) - await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [], [0n], []) - await migrateVaultWithUnresolvedEscalation(attackerClient, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [], [], [0n]) + await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes) + await migrateVaultWithUnresolvedEscalation(attackerClient, securityPoolAddresses.securityPool, QuestionOutcome.Yes) const yesUniverse = getChildUniverseId(genesisUniverse, QuestionOutcome.Yes) const yesSecurityPool = getSecurityPoolAddresses(securityPoolAddresses.securityPool, yesUniverse, questionId, securityMultiplier) const childEscalationGame = await getSecurityPoolsEscalationGame(client, yesSecurityPool.securityPool) - const importedYesIndexes = await getUnsettledImportedDepositIndexesByOutcomeAndDepositor(client, childEscalationGame, QuestionOutcome.Yes, client.account.address, 0n, 10n) - strictEqualTypeSafe(importedYesIndexes.length, 1, 'the carried yes deposit should be discoverable in the child continuation game before the second fork') + strictEqualTypeSafe((await getEscalationGameOutcomeState(client, childEscalationGame, QuestionOutcome.Yes)).currentCarryTotal, recursiveDeposit, 'the child continuation snapshot should carry the unresolved yes-side total before the second fork') const secondForkQuestionData = { ...questionData, @@ -1086,19 +1227,18 @@ describe('Peripherals Contract Test Suite', () => { await forkUniverse(attackerClient, yesUniverse, secondForkQuestionId) await initiateSecurityPoolFork(client, yesSecurityPool.securityPool) await migrateRepToZoltar(client, yesSecurityPool.securityPool, [QuestionOutcome.Yes]) - await migrateVaultWithUnresolvedEscalation(client, yesSecurityPool.securityPool, QuestionOutcome.Yes, [], [encodeImportedForkDepositIndex(importedYesIndexes[0])], []) + await migrateVaultWithUnresolvedEscalation(client, yesSecurityPool.securityPool, QuestionOutcome.Yes) const grandchildUniverse = getChildUniverseId(yesUniverse, QuestionOutcome.Yes) const grandchildSecurityPool = getSecurityPoolAddresses(yesSecurityPool.securityPool, grandchildUniverse, questionId, securityMultiplier) const childVaultAfterMigration = await getSecurityVault(client, yesSecurityPool.securityPool, client.account.address) const grandchildVault = await getSecurityVault(client, grandchildSecurityPool.securityPool, client.account.address) const grandchildEscalationGame = await getSecurityPoolsEscalationGame(client, grandchildSecurityPool.securityPool) - const grandchildImportedYesIndexes = await getUnsettledImportedDepositIndexesByOutcomeAndDepositor(client, grandchildEscalationGame, QuestionOutcome.Yes, client.account.address, 0n, 10n) + const grandchildOutcomeState = await getEscalationGameOutcomeState(client, grandchildEscalationGame, QuestionOutcome.Yes) strictEqualTypeSafe(childVaultAfterMigration.lockedRepInEscalationGame, 0n, 'the second migration should clear the carried lock from the child continuation vault') strictEqualTypeSafe(grandchildVault.lockedRepInEscalationGame, recursiveDeposit, 'the carried unresolved principal should survive into the grandchild continuation vault') - strictEqualTypeSafe(grandchildImportedYesIndexes.length, 1, 'the grandchild continuation game should receive the carried unresolved deposit') - strictEqualTypeSafe(grandchildImportedYesIndexes[0], 0n, 'the recursive continuation migration should preserve the carried deposit ordering key') + strictEqualTypeSafe(grandchildOutcomeState.currentCarryTotal, recursiveDeposit, 'the recursive continuation migration should preserve the carried unresolved total by snapshot') }) test('many unresolved continuation deposits survive multiple unrelated forks recursively', async () => { @@ -1126,15 +1266,13 @@ describe('Peripherals Contract Test Suite', () => { await initiateSecurityPoolFork(client, securityPoolAddresses.securityPool) await migrateRepToZoltar(client, securityPoolAddresses.securityPool, [QuestionOutcome.Yes, QuestionOutcome.No]) - const firstForkIndexes: bigint[] = depositIndexes.map(index => index) - await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [], firstForkIndexes, []) - await migrateVaultWithUnresolvedEscalation(attackerClient, securityPoolAddresses.securityPool, QuestionOutcome.Yes, [], [], [0n]) + await migrateVaultWithUnresolvedEscalation(client, securityPoolAddresses.securityPool, QuestionOutcome.Yes) + await migrateVaultWithUnresolvedEscalation(attackerClient, securityPoolAddresses.securityPool, QuestionOutcome.Yes) const firstChildUniverse = getChildUniverseId(genesisUniverse, QuestionOutcome.Yes) const firstChildPool = getSecurityPoolAddresses(securityPoolAddresses.securityPool, firstChildUniverse, questionId, securityMultiplier) const firstChildEscalationGame = await getSecurityPoolsEscalationGame(client, firstChildPool.securityPool) - const firstChildImportedYes = await getUnsettledImportedDepositIndexesByOutcomeAndDepositor(client, firstChildEscalationGame, QuestionOutcome.Yes, client.account.address, 0n, 20n) - strictEqualTypeSafe(firstChildImportedYes.length, Number(recursiveDepositCount), 'first child should inherit all migrated yes-side deposits') + strictEqualTypeSafe((await getEscalationGameOutcomeState(client, firstChildEscalationGame, QuestionOutcome.Yes)).currentCarryTotal, recursiveDepositCount * reportBond, 'first child should inherit all unresolved yes-side principal by snapshot') const firstChildRepToken = await getRepToken(client, firstChildPool.securityPool) const firstChildForkThreshold = await getZoltarForkThreshold(client, firstChildUniverse) @@ -1166,14 +1304,12 @@ describe('Peripherals Contract Test Suite', () => { await initiateSecurityPoolFork(client, firstChildPool.securityPool) await migrateRepToZoltar(client, firstChildPool.securityPool, [QuestionOutcome.Yes, QuestionOutcome.No]) - const encodedFirstChildYes = firstChildImportedYes.map(depositIndex => encodeImportedForkDepositIndex(depositIndex)) - await migrateVaultWithUnresolvedEscalation(client, firstChildPool.securityPool, QuestionOutcome.Yes, [], encodedFirstChildYes, []) + await migrateVaultWithUnresolvedEscalation(client, firstChildPool.securityPool, QuestionOutcome.Yes) const secondChildUniverse = getChildUniverseId(firstChildUniverse, QuestionOutcome.Yes) const secondChildPool = getSecurityPoolAddresses(firstChildPool.securityPool, secondChildUniverse, questionId, securityMultiplier) const secondChildEscalationGame = await getSecurityPoolsEscalationGame(client, secondChildPool.securityPool) - const secondChildImportedYes = await getUnsettledImportedDepositIndexesByOutcomeAndDepositor(client, secondChildEscalationGame, QuestionOutcome.Yes, client.account.address, 0n, 20n) - strictEqualTypeSafe(secondChildImportedYes.length, Number(recursiveDepositCount), 'second child should inherit all migrated yes-side deposits from the first child') + strictEqualTypeSafe((await getEscalationGameOutcomeState(client, secondChildEscalationGame, QuestionOutcome.Yes)).currentCarryTotal, recursiveDepositCount * reportBond, 'second child should inherit all unresolved yes-side principal from the first child by snapshot') }) test('cannot refund an active escalation deposit before zoltar forks', async () => { diff --git a/solidity/ts/testsuite/simulator/utils/contracts/escalationGame.ts b/solidity/ts/testsuite/simulator/utils/contracts/escalationGame.ts index 6bd22642..012191f4 100644 --- a/solidity/ts/testsuite/simulator/utils/contracts/escalationGame.ts +++ b/solidity/ts/testsuite/simulator/utils/contracts/escalationGame.ts @@ -69,20 +69,12 @@ export const getEscalationGameDeposits = async (client: ReadClient, escalationGa return pages } -export const getUnsettledDepositIndexesByOutcomeAndDepositor = async (client: ReadClient, escalationGame: AccountAddress, outcome: QuestionOutcome, depositor: AccountAddress, startIndex: bigint, scanCount: bigint) => +export const getEscalationGameOutcomeState = async (client: ReadClient, escalationGame: AccountAddress, outcome: QuestionOutcome) => await client.readContract({ abi: peripherals_EscalationGame_EscalationGame.abi, - functionName: 'getUnsettledDepositIndexesByOutcomeAndDepositor', + functionName: 'getOutcomeState', address: escalationGame, - args: [outcome, depositor, startIndex, scanCount], - }) - -export const getUnsettledImportedDepositIndexesByOutcomeAndDepositor = async (client: ReadClient, escalationGame: AccountAddress, outcome: QuestionOutcome, depositor: AccountAddress, startIndex: bigint, scanCount: bigint) => - await client.readContract({ - abi: peripherals_EscalationGame_EscalationGame.abi, - functionName: 'getUnsettledImportedDepositIndexesByOutcomeAndDepositor', - address: escalationGame, - args: [outcome, depositor, startIndex, scanCount], + args: [outcome], }) export const deployEscalationGame = async (writeClient: WriteClient, startBond: bigint, nonDecisionThreshold: bigint) => { @@ -106,13 +98,27 @@ export const deployEscalationGame = async (writeClient: WriteClient, startBond: } export const getBalances = async (client: ReadClient, escalationGame: AccountAddress) => { - const [invalid, yes, no] = await client.readContract({ - abi: peripherals_EscalationGame_EscalationGame.abi, - functionName: 'getBalances', - address: escalationGame, - args: [], - }) - return { invalid, yes, no } + const [invalidState, yesState, noState] = await Promise.all([ + client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + functionName: 'getOutcomeState', + address: escalationGame, + args: [0], + }), + client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + functionName: 'getOutcomeState', + address: escalationGame, + args: [1], + }), + client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + functionName: 'getOutcomeState', + address: escalationGame, + args: [2], + }), + ]) + return { invalid: invalidState.balance, yes: yesState.balance, no: noState.balance } } export const getActivationTime = async (client: ReadClient, escalationGame: AccountAddress) => diff --git a/solidity/ts/testsuite/simulator/utils/contracts/securityPool.ts b/solidity/ts/testsuite/simulator/utils/contracts/securityPool.ts index f4ee52cb..27007c8e 100644 --- a/solidity/ts/testsuite/simulator/utils/contracts/securityPool.ts +++ b/solidity/ts/testsuite/simulator/utils/contracts/securityPool.ts @@ -1,9 +1,21 @@ import { peripherals_SecurityPool_SecurityPool } from '../../../../types/contractArtifact' -import type { Address } from 'viem' +import type { Address, Hex } from 'viem' import { SystemState } from '../../types/peripheralTypes' import { QuestionOutcome } from '../../types/types' import { ReadClient, WriteClient, writeContractAndWait } from '../viem' +type CarriedDepositProof = { + depositor: Address + amount: bigint + parentDepositIndex: bigint + cumulativeAmount: bigint + sourceNodeId: bigint + leafIndex: bigint + merkleMountainRangeSiblings: readonly Hex[] + merkleMountainRangePeakIndex: bigint + nullifierSiblings: readonly Hex[] +} + const getAwaitingForkContinuationAbi = [ { inputs: [], @@ -36,13 +48,20 @@ export const withdrawFromEscalationGame = async (client: WriteClient, securityPo return hash } -export const withdrawForkedEscalationDeposits = async (client: WriteClient, securityPoolAddress: Address, outcome: QuestionOutcome, depositIndexes: bigint[]) => +export const withdrawForkedEscalationDeposits = async (client: WriteClient, securityPoolAddress: Address, outcome: QuestionOutcome, proofs: readonly CarriedDepositProof[]) => await writeContractAndWait(client, () => client.writeContract({ abi: peripherals_SecurityPool_SecurityPool.abi, functionName: 'withdrawForkedEscalationDeposits', address: securityPoolAddress, - args: [outcome, depositIndexes], + args: [ + outcome, + proofs.map(proof => ({ + ...proof, + merkleMountainRangeSiblings: Array.from(proof.merkleMountainRangeSiblings), + nullifierSiblings: Array.from(proof.nullifierSiblings), + })), + ], }), ) diff --git a/solidity/ts/testsuite/simulator/utils/contracts/securityPoolForker.ts b/solidity/ts/testsuite/simulator/utils/contracts/securityPoolForker.ts index a3ea55d9..ffdfabf3 100644 --- a/solidity/ts/testsuite/simulator/utils/contracts/securityPoolForker.ts +++ b/solidity/ts/testsuite/simulator/utils/contracts/securityPoolForker.ts @@ -57,18 +57,16 @@ export const migrateVault = async (client: WriteClient, securityPoolAddress: Add }), ) -export const migrateVaultWithUnresolvedEscalation = async (client: WriteClient, securityPoolAddress: Address, childOutcome: bigint | QuestionOutcome, invalidDepositIndexes: bigint[], yesDepositIndexes: bigint[], noDepositIndexes: bigint[]) => +export const migrateVaultWithUnresolvedEscalation = async (client: WriteClient, securityPoolAddress: Address, childOutcome: bigint | QuestionOutcome) => await writeContractAndWait(client, () => client.writeContract({ abi: peripherals_SecurityPoolForker_SecurityPoolForker.abi, functionName: 'migrateVaultWithUnresolvedEscalation', address: getInfraContractAddresses().securityPoolForker, - args: [securityPoolAddress, Number(childOutcome), invalidDepositIndexes, yesDepositIndexes, noDepositIndexes], + args: [securityPoolAddress, Number(childOutcome)], }), ) -export const encodeImportedForkDepositIndex = (depositIndex: bigint) => (1n << 256n) - 1n - depositIndex - export const startTruthAuction = async (client: WriteClient, securityPoolAddress: Address) => await writeContractAndWait(client, () => client.writeContract({ diff --git a/ui/ts/App.tsx b/ui/ts/App.tsx index b9481389..37859b55 100644 --- a/ui/ts/App.tsx +++ b/ui/ts/App.tsx @@ -534,7 +534,7 @@ export function App() { outcome, ...(depositIndexes === undefined ? {} : { depositIndexes }), }), - onMigrateUnresolvedEscalation: (selectedChildOutcome, selectedByOutcome) => void migrateUnresolvedEscalation(selectedChildOutcome, selectedByOutcome), + onMigrateUnresolvedEscalation: selectedChildOutcome => void migrateUnresolvedEscalation(selectedChildOutcome), onMigrateRepToZoltar: outcomes => void migrateRepToZoltar(outcomes), onMigrateVault: () => void migrateVault(), onRefundLosingBids: (securityPoolAddressOverride, selectedBids) => void refundLosingBids(securityPoolAddressOverride, selectedBids), diff --git a/ui/ts/components/ForkAuctionSection.tsx b/ui/ts/components/ForkAuctionSection.tsx index 19a6e861..986d6898 100644 --- a/ui/ts/components/ForkAuctionSection.tsx +++ b/ui/ts/components/ForkAuctionSection.tsx @@ -721,7 +721,6 @@ export function ForkAuctionSection({ const showSelectedEscalationMigrationDeposits = !loadingReportingDetails && reportingDetails?.status === 'active' const hasSelectedEscalationMigrationDeposits = selectedEscalationMigrationDeposits.length > 0 const unresolvedMigrationSides = activeReportingDetails?.sides ?? [] - const selectedUnresolvedMigrationDepositIndexesByOutcome = reportingForm?.selectedWithdrawDepositIndexesByOutcome ?? { invalid: [], yes: [], no: [] } const [selectedOutcomeMigrationSeedStatus, setSelectedOutcomeMigrationSeedStatus] = useState(undefined) const [selectedOutcomeMigrationSeedStatusError, setSelectedOutcomeMigrationSeedStatusError] = useState(undefined) const [loadingSelectedOutcomeMigrationSeedStatus, setLoadingSelectedOutcomeMigrationSeedStatus] = useState(false) @@ -820,14 +819,6 @@ export function ForkAuctionSection({ const migrateEscalationBalanceGuardMessage = connectedWalletVaultSummary !== undefined && !hasWalletEscalationMigrationBalance ? 'No locked REP remains to migrate for the connected wallet.' : undefined const totalUnresolvedMigrationDepositCount = unresolvedMigrationSides.reduce((count, side) => count + side.userDeposits.length, 0) const hasUnresolvedMigrationDeposits = totalUnresolvedMigrationDepositCount > 0 - const selectedUnresolvedMigrationDepositCount = unresolvedMigrationSides.reduce((count, side) => count + selectedUnresolvedMigrationDepositIndexesByOutcome[side.key].length, 0) - const unresolvedMigrationSelectionComplete = - unresolvedMigrationSides.every(side => - sameBigIntArray( - selectedUnresolvedMigrationDepositIndexesByOutcome[side.key], - side.userDeposits.map(deposit => deposit.depositIndex), - ), - ) && selectedUnresolvedMigrationDepositCount === totalUnresolvedMigrationDepositCount const importedForkSettlementSides = activeReportingDetails?.sides.filter(side => side.importedUserDeposits.length > 0) ?? [] const hasImportedForkSettlementDeposits = importedForkSettlementSides.length > 0 const importedForkSettlementResolved = isPoolQuestionFinalized(activeReportingDetails) @@ -1113,7 +1104,6 @@ export function ForkAuctionSection({ if (loadingReportingDetails) return 'Loading unresolved escalation deposits.' if (activeReportingDetails === undefined) return 'Unresolved escalation deposit details are unavailable for this pool right now.' if (!hasUnresolvedMigrationDeposits) return 'No unresolved parent escalation deposits remain for the connected wallet.' - if (!unresolvedMigrationSelectionComplete) return 'All remaining unresolved parent escalation deposits for this vault must migrate together.' return undefined })() const migratePoolToUniverseGuardMessage = (() => { @@ -1267,7 +1257,7 @@ export function ForkAuctionSection({ const onMigrateUnresolvedEscalationSubmit = () => { setPendingEscalationMigrationSelection(undefined) setIsVaultMigrationPending(true) - onMigrateUnresolvedEscalation(forkAuctionForm.selectedOutcome, selectedUnresolvedMigrationDepositIndexesByOutcome) + onMigrateUnresolvedEscalation(forkAuctionForm.selectedOutcome) } const onWithdrawForkedEscalationSubmit = (outcome: ReportingOutcomeKey) => { const selectedDepositIndexes = selectedImportedForkDepositIndexesByOutcome[outcome] @@ -2227,7 +2217,7 @@ export function ForkAuctionSection({ <> {hasUnresolvedMigrationState ? ( -

{isMigrationExpired ? 'The migration window for these unresolved parent escalation deposits has closed.' : 'Unresolved parent escalation locks migrate together with your vault into the selected child universe.'}

+

{isMigrationExpired ? 'The migration window for these unresolved parent escalation deposits has closed.' : 'All unresolved parent escalation locks on this wallet will migrate together with your vault into the selected child universe.'}

{loadingReportingDetails ?

Loading unresolved escalation deposits for the connected wallet…

: undefined} {loadingReportingDetails || activeReportingDetails !== undefined ? undefined :

Unresolved escalation deposit details are unavailable for this pool right now.

} {activeReportingDetails !== undefined && !hasUnresolvedMigrationDeposits ?

No unresolved parent escalation deposits remain for the connected wallet.

: undefined} @@ -2240,7 +2230,7 @@ export function ForkAuctionSection({

No {side.label.toLowerCase()} unresolved deposits remain for this wallet.

) : ( ({ deposit, details: [ @@ -2253,21 +2243,12 @@ export function ForkAuctionSection({ , ], }))} - onSelectionChange={nextSelectedDepositIndexes => { - if (onReportingFormChange === undefined || reportingForm === undefined) return - onReportingFormChange({ - selectedWithdrawDepositIndexesByOutcome: { - ...reportingForm.selectedWithdrawDepositIndexesByOutcome, - [side.key]: nextSelectedDepositIndexes, - }, - }) - }} - selectedDepositIndexes={selectedUnresolvedMigrationDepositIndexesByOutcome[side.key]} + onSelectionChange={() => undefined} + selectedDepositIndexes={side.userDeposits.map(deposit => deposit.depositIndex)} /> )} ))} - {unresolvedMigrationSelectionComplete || isMigrationExpired ? undefined :

All remaining unresolved parent escalation deposits for this vault must migrate together.

} {isMigrationExpired ? undefined : (
{renderStageActionButton({ diff --git a/ui/ts/contracts.ts b/ui/ts/contracts.ts index 94b09523..d35df092 100644 --- a/ui/ts/contracts.ts +++ b/ui/ts/contracts.ts @@ -1,4 +1,4 @@ -import { decodeEventLog, parseAbiItem, zeroAddress, type Address, type ContractFunctionParameters, type Hash, type Hex, type TransactionReceipt } from 'viem' +import { concatHex, decodeEventLog, encodeAbiParameters, keccak256, parseAbiItem, parseAbiParameters, zeroAddress, type Address, type ContractFunctionParameters, type Hash, type Hex, type TransactionReceipt } from 'viem' import { ABIS } from './abis.js' import { sortBigIntsAscending } from '@zoltar/shared/bigInt' import { assertNever } from './lib/assert.js' @@ -20,6 +20,7 @@ import { peripherals_tokens_ShareToken_ShareToken, } from './contractArtifact.js' import type { + CarriedDepositProof, DeploymentStepId, EscalationDeposit, EscalationSide, @@ -67,7 +68,6 @@ import { hasTimestamp, hasTimestampAndNumber, isBigintTriple, - requireEscalationGameTuple, requireOpenOracleExtraDataTuple, requireOpenOracleExtraDataTupleArray, requireOpenOracleReportMetaTuple, @@ -92,6 +92,8 @@ const TRUTH_AUCTION_TIME_LENGTH = 604800n const QUESTION_OUTCOME_ABI = [parseAbiItem('function getQuestionOutcome(address securityPool) view returns (uint8 outcome)')] const CONTRACT_PAGE_SIZE = 30n const OPEN_ORACLE_PRICE_UNITS = 30n +const NULLIFIER_DEPTH = 64 +const CARRY_LEAF_ABI = parseAbiParameters('address depositor, uint8 outcome, uint256 amount, uint256 parentDepositIndex, uint256 cumulativeAmount, uint256 sourceNodeId') type ReadWriteContractClient = TransactionReceipt> = Pick & WriteContractClient type ForkDataTuple = readonly [bigint, Address, bigint, bigint, bigint, bigint, bigint, bigint, boolean, boolean, number] type AuctionClearingTuple = readonly [boolean, bigint, bigint, bigint] @@ -112,8 +114,14 @@ type TruthAuctionBidViewStruct = { claimed: boolean refunded: boolean } -type ImportedDepositTuple = readonly [Address, bigint, bigint, boolean] -type ReportingBootstrapReadResult = readonly [bigint, Address, bigint, bigint, Address, bigint, bigint, bigint] +type ReportingBootstrapReadResult = readonly [bigint, Address, bigint, bigint, Address, bigint, bigint, bigint, Address] +type CarryLeafViewStruct = { + cumulativeAmount: bigint + depositor: Address + parentDepositIndex: bigint + amount: bigint + sourceNodeId: bigint +} type LoadAllSecurityPoolsOptions = { selectedSecurityPoolAddress?: Address | string vaultDetailMode?: 'all' | 'selected' @@ -236,50 +244,321 @@ export async function loadEscalationDeposits(client: Pick + return typeof candidate['depositor'] === 'string' && typeof candidate['amount'] === 'bigint' && typeof candidate['parentDepositIndex'] === 'bigint' && typeof candidate['cumulativeAmount'] === 'bigint' && typeof candidate['sourceNodeId'] === 'bigint' } -function requireImportedDepositTuple(value: unknown, context: string): ImportedDepositTuple { - if (isImportedDepositTuple(value)) return value - throw new Error(`Unexpected imported escalation deposit response for ${context}`) +async function loadCarryLeafPage(client: Pick, escalationGameAddress: Address, outcome: ReportingOutcomeKey) { + let startNodeId = 0n + const carryLeaves: CarryLeafViewStruct[] = [] + while (true) { + const result = await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'getCarryLeafPageByOutcome', + args: [getReportingOutcomeValue(outcome), startNodeId, CONTRACT_PAGE_SIZE], + }) + if (!Array.isArray(result) || result.length !== 2) throw new Error('Unexpected carry leaf page response') + const [page, nextNodeId] = result + if (!Array.isArray(page) || typeof nextNodeId !== 'bigint') throw new Error('Unexpected carry leaf page response') + const normalizedPage = page.filter(isCarryLeafView) + carryLeaves.push(...normalizedPage) + if (nextNodeId === 0n) break + startNodeId = nextNodeId + } + return carryLeaves } -export async function loadImportedEscalationDeposits(client: Pick, escalationGameAddress: Address, outcome: ReportingOutcomeKey, depositor: Address): Promise { +async function loadProofConsumedCarriedDepositIndexes(client: Pick, escalationGameAddress: Address, outcome: ReportingOutcomeKey) { let startIndex = 0n - const importedDeposits: ImportedEscalationDeposit[] = [] + const parentDepositIndexes: bigint[] = [] while (true) { const page = await client.readContract({ abi: peripherals_EscalationGame_EscalationGame.abi, address: escalationGameAddress, - functionName: 'getUnsettledImportedDepositIndexesByOutcomeAndDepositor', - args: [getReportingOutcomeValue(outcome), depositor, startIndex, BigInt(CONTRACT_PAGE_SIZE)], + functionName: 'getProofConsumedCarriedDepositIndexesByOutcome', + args: [getReportingOutcomeValue(outcome), startIndex, CONTRACT_PAGE_SIZE], }) - if (!Array.isArray(page)) throw new Error('Unexpected imported escalation index page response') - const parentDepositIndexes = page.filter((value): value is bigint => typeof value === 'bigint') - if (parentDepositIndexes.length !== page.length) throw new Error('Unexpected imported escalation index page response') - for (const parentDepositIndex of parentDepositIndexes) { - const importedDepositTuple = requireImportedDepositTuple( - await client.readContract({ - abi: peripherals_EscalationGame_EscalationGame.abi, - address: escalationGameAddress, - functionName: 'importedDeposits', - args: [BigInt(getReportingOutcomeValue(outcome)), parentDepositIndex], - }), - `${outcome}:${parentDepositIndex.toString()}`, - ) - const [loadedDepositor, amount, cumulativeAmount] = importedDepositTuple - importedDeposits.push({ - amount, - cumulativeAmount, - depositor: loadedDepositor, - parentDepositIndex, - }) - } - if (BigInt(parentDepositIndexes.length) !== CONTRACT_PAGE_SIZE) break + if (!Array.isArray(page)) throw new Error('Unexpected consumed carried deposit index page response') + const normalizedPage = page.filter((value): value is bigint => typeof value === 'bigint') + parentDepositIndexes.push(...normalizedPage) + if (BigInt(normalizedPage.length) !== CONTRACT_PAGE_SIZE) break startIndex += CONTRACT_PAGE_SIZE } - return importedDeposits + return parentDepositIndexes +} + +async function readForkContinuation(client: Pick, escalationGameAddress: Address) { + try { + return await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'forkContinuation', + args: [], + }) + } catch (error) { + if (isIgnorableLogDecodeError(error)) return undefined + return undefined + } +} + +async function readEscalationOutcomeState(client: Pick, escalationGameAddress: Address, outcome: ReportingOutcomeKey) { + return await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'getOutcomeState', + args: [getReportingOutcomeValue(outcome)], + }) +} + +async function loadRecursiveCarrySnapshot( + client: Pick, + escalationGameAddress: Address, + outcome: ReportingOutcomeKey, +): Promise<{ + orderedLeaves: CarryLeafViewStruct[] + carryRoot: Hex + carryLeafCount: bigint + nullifierRoot: Hex +}> { + const [outcomeState, forkContinuation, localLeaves] = await Promise.all([readEscalationOutcomeState(client, escalationGameAddress, outcome), readForkContinuation(client, escalationGameAddress), loadCarryLeafPage(client, escalationGameAddress, outcome)]) + const { currentCarryRoot: carryRoot, currentLeafCount: carryLeafCount, currentNullifierRoot: nullifierRoot } = outcomeState + const orderedLocalLeaves = [...localLeaves].sort((left, right) => compareBigintAscending(left.parentDepositIndex, right.parentDepositIndex)) + if (forkContinuation !== true) { + return { + orderedLeaves: orderedLocalLeaves, + carryRoot, + carryLeafCount, + nullifierRoot, + } + } + const securityPoolAddress = await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'securityPool', + args: [], + }) + const parentSecurityPoolAddress = await client.readContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + address: securityPoolAddress, + functionName: 'parent', + args: [], + }) + if (parentSecurityPoolAddress === zeroAddress) { + return { + orderedLeaves: orderedLocalLeaves, + carryRoot, + carryLeafCount, + nullifierRoot, + } + } + const parentEscalationGameAddress = await client.readContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + address: parentSecurityPoolAddress, + functionName: 'escalationGame', + args: [], + }) + if (parentEscalationGameAddress === zeroAddress) { + return { + orderedLeaves: orderedLocalLeaves, + carryRoot, + carryLeafCount, + nullifierRoot, + } + } + const parentSnapshot = await loadRecursiveCarrySnapshot(client, parentEscalationGameAddress, outcome) + return { + orderedLeaves: [...parentSnapshot.orderedLeaves, ...orderedLocalLeaves].sort((left, right) => compareBigintAscending(left.parentDepositIndex, right.parentDepositIndex)), + carryRoot, + carryLeafCount, + nullifierRoot, + } +} + +async function loadForkCarriedEscalationDepositsFromParentSnapshot(client: Pick, childEscalationGameAddress: Address, parentSecurityPoolAddress: Address, outcome: ReportingOutcomeKey, depositor: Address): Promise { + const parentEscalationGameAddress = await client.readContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + address: parentSecurityPoolAddress, + functionName: 'escalationGame', + args: [], + }) + if (parentEscalationGameAddress === zeroAddress) return [] + const [{ orderedLeaves: parentSnapshotLeaves }, consumedParentDepositIndexes] = await Promise.all([loadRecursiveCarrySnapshot(client, parentEscalationGameAddress, outcome), loadProofConsumedCarriedDepositIndexes(client, childEscalationGameAddress, outcome)]) + const consumedParentDepositIndexSet = new Set(consumedParentDepositIndexes.map(value => value.toString())) + return parentSnapshotLeaves + .filter(leaf => sameAddress(leaf.depositor, depositor) && !consumedParentDepositIndexSet.has(leaf.parentDepositIndex.toString())) + .map(leaf => ({ + amount: leaf.amount, + cumulativeAmount: leaf.cumulativeAmount, + depositor: leaf.depositor, + parentDepositIndex: leaf.parentDepositIndex, + })) +} + +function hashCarryLeaf(leaf: CarryLeafViewStruct, outcome: ReportingOutcomeKey): Hex { + return keccak256(encodeAbiParameters(CARRY_LEAF_ABI, [leaf.depositor, getReportingOutcomeValue(outcome), leaf.amount, leaf.parentDepositIndex, leaf.cumulativeAmount, leaf.sourceNodeId])) +} + +function hashCarryParent(left: Hex, right: Hex): Hex { + return keccak256(concatHex([left, right])) +} + +function bagCarryPeaks(peaks: readonly Hex[]): Hex { + if (peaks.length === 0) return ('0x' + '00'.repeat(32)) as Hex + let root = peaks[peaks.length - 1] + if (root === undefined) throw new Error('Missing carry peak root') + for (let index = peaks.length - 1; index > 0; index -= 1) { + const previousPeak = peaks[index - 1] + if (previousPeak === undefined) throw new Error('Missing carry peak root') + root = hashCarryParent(previousPeak, root) + } + return root +} + +function buildCarryPeakHeights(leafCount: bigint) { + const peakHeights: number[] = [] + let remainingLeafCount = leafCount + let currentHeight = 0 + while (remainingLeafCount > 0n) { + if ((remainingLeafCount & 1n) === 1n) peakHeights.unshift(currentHeight) + remainingLeafCount >>= 1n + currentHeight += 1 + } + return peakHeights +} + +function compareBigintAscending(left: bigint, right: bigint) { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} + +function buildCarryMerkleMountainRangeProof(leafHashes: readonly Hex[], targetLeafIndex: number) { + const leafCount = BigInt(leafHashes.length) + const peakHeights = buildCarryPeakHeights(leafCount) + let offset = 0 + let targetPeakHeight: number | undefined + let targetPeakLeaves: Hex[] | undefined + let targetPeakOffset: number | undefined + const peakRootsByHeight = new Map() + for (const peakHeight of peakHeights) { + const peakSize = 1 << peakHeight + const peakLeaves = leafHashes.slice(offset, offset + peakSize) + let levelHashes = [...peakLeaves] + while (levelHashes.length > 1) { + const nextLevelHashes: Hex[] = [] + for (let index = 0; index < levelHashes.length; index += 2) { + const left = levelHashes[index] + const right = levelHashes[index + 1] + if (left === undefined || right === undefined) throw new Error('Invalid carry Merkle Mountain Range level') + nextLevelHashes.push(hashCarryParent(left, right)) + } + levelHashes = nextLevelHashes + } + const peakRoot = levelHashes[0] + if (peakRoot === undefined) throw new Error('Missing carry Merkle Mountain Range peak root') + peakRootsByHeight.set(peakHeight, peakRoot) + if (targetLeafIndex >= offset && targetLeafIndex < offset + peakSize) { + targetPeakHeight = peakHeight + targetPeakLeaves = peakLeaves + targetPeakOffset = offset + } + offset += peakSize + } + if (targetPeakHeight === undefined || targetPeakLeaves === undefined || targetPeakOffset === undefined) { + throw new Error('Target carry leaf is not inside the Merkle Mountain Range') + } + let relativeLeafIndex = targetLeafIndex - targetPeakOffset + let levelHashes = [...targetPeakLeaves] + const merkleMountainRangeSiblings: Hex[] = [] + while (levelHashes.length > 1) { + const siblingIndex = relativeLeafIndex ^ 1 + const siblingHash = levelHashes[siblingIndex] + if (siblingHash === undefined) throw new Error('Missing carry Merkle Mountain Range sibling') + merkleMountainRangeSiblings.push(siblingHash) + const nextLevelHashes: Hex[] = [] + for (let index = 0; index < levelHashes.length; index += 2) { + const left = levelHashes[index] + const right = levelHashes[index + 1] + if (left === undefined || right === undefined) throw new Error('Invalid carry Merkle Mountain Range level') + nextLevelHashes.push(hashCarryParent(left, right)) + } + levelHashes = nextLevelHashes + relativeLeafIndex = Math.floor(relativeLeafIndex / 2) + } + const orderedPeakHeights = [...peakRootsByHeight.keys()].sort((left, right) => left - right) + for (const peakHeight of orderedPeakHeights) { + if (peakHeight === targetPeakHeight) continue + const peakRoot = peakRootsByHeight.get(peakHeight) + if (peakRoot === undefined) throw new Error('Missing carry Merkle Mountain Range peak root') + merkleMountainRangeSiblings.push(peakRoot) + } + const orderedPeaks = orderedPeakHeights.map(peakHeight => { + const peakRoot = peakRootsByHeight.get(peakHeight) + if (peakRoot === undefined) throw new Error('Missing carry Merkle Mountain Range peak root') + return peakRoot + }) + const root = bagCarryPeaks(orderedPeaks) + return { merkleMountainRangePeakIndex: BigInt(targetPeakHeight), merkleMountainRangeSiblings, root } +} + +function buildZeroHashes() { + const zeroHashes: Hex[] = [] + let currentHash = ('0x' + '00'.repeat(32)) as Hex + for (let depth = 0; depth < NULLIFIER_DEPTH; depth += 1) { + zeroHashes.push(currentHash) + currentHash = hashCarryParent(currentHash, currentHash) + } + return zeroHashes +} + +class SparseNullifier { + private readonly nodes = new Map() + private readonly zeroHashes = buildZeroHashes() + + constructor(consumedParentDepositIndexes: readonly bigint[]) { + for (const parentDepositIndex of consumedParentDepositIndexes) this.consume(parentDepositIndex) + } + + private getNode(level: number, index: bigint) { + return this.nodes.get(`${level}:${index.toString()}`) ?? this.zeroHashes[level] + } + + getProof(parentDepositIndex: bigint) { + const siblings: Hex[] = [] + let index = BigInt.asUintN(64, BigInt(keccak256(encodeAbiParameters(parseAbiParameters('uint256 parentDepositIndex'), [parentDepositIndex])))) + for (let level = 0; level < NULLIFIER_DEPTH; level += 1) { + const siblingIndex = index ^ 1n + const siblingHash = this.getNode(level, siblingIndex) + if (siblingHash === undefined) throw new Error('Missing nullifier sibling hash') + siblings.push(siblingHash) + index >>= 1n + } + return siblings + } + + consume(parentDepositIndex: bigint) { + let index = BigInt.asUintN(64, BigInt(keccak256(encodeAbiParameters(parseAbiParameters('uint256 parentDepositIndex'), [parentDepositIndex])))) + let currentHash = ('0x' + '00'.repeat(31) + '01') as Hex + for (let level = 0; level < NULLIFIER_DEPTH; level += 1) { + this.nodes.set(`${level}:${index.toString()}`, currentHash) + const siblingIndex = index ^ 1n + const siblingHash = this.getNode(level, siblingIndex) + if (siblingHash === undefined) throw new Error('Missing nullifier sibling hash') + currentHash = (index & 1n) === 0n ? hashCarryParent(currentHash, siblingHash) : hashCarryParent(siblingHash, currentHash) + index >>= 1n + } + this.nodes.set(`${NULLIFIER_DEPTH}:0`, currentHash) + } + + getRoot() { + const root = this.nodes.get(`${NULLIFIER_DEPTH}:0`) + const fallbackRoot = this.zeroHashes[this.zeroHashes.length - 1] + if (fallbackRoot === undefined) throw new Error('Missing empty nullifier root') + return root ?? fallbackRoot + } } async function loadViewerReportingVaultState(client: ReadClient, securityPoolAddress: Address, accountAddress: Address | undefined) { if (accountAddress === undefined) @@ -366,11 +645,17 @@ export async function loadReportingDetails(client: ReadClient, securityPoolAddre address: getInfraContractAddresses().securityPoolForker, args: [securityPoolAddress], }, + { + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'parent', + address: securityPoolAddress, + args: [], + }, ] - const [questionId, escalationGameAddress, completeSetCollateralAmount, universeId, zoltarAddress, initialEscalationGameDeposit, systemStateValue, questionOutcomeValue] = (await readRequiredMulticall(client, reportingPoolReads)) as unknown as ReportingBootstrapReadResult + const [questionId, escalationGameAddress, completeSetCollateralAmount, universeId, zoltarAddress, initialEscalationGameDeposit, systemStateValue, questionOutcomeValue, parentSecurityPoolAddress] = (await readRequiredMulticall(client, reportingPoolReads)) as unknown as ReportingBootstrapReadResult const systemState = getSecurityPoolSystemState(systemStateValue) const normalizedQuestionOutcome = getReportingOutcomeKey(questionOutcomeValue) - const [marketDetails, block, escalationGameCode, viewerVaultState, forkThreshold] = await Promise.all([ + const [marketDetails, block, escalationGameCode, viewerVaultState, forkThreshold, forkContinuationSnapshot] = await Promise.all([ loadMarketDetails(client, questionId), client.getBlock(), escalationGameAddress === zeroAddress ? Promise.resolve('0x' as const) : client.getCode({ address: escalationGameAddress }), @@ -381,6 +666,7 @@ export async function loadReportingDetails(client: ReadClient, securityPoolAddre functionName: 'getForkThreshold', args: [universeId], }), + escalationGameAddress === zeroAddress ? Promise.resolve(undefined) : readForkContinuation(client, escalationGameAddress), ]) if (!hasTimestamp(block)) throw new Error('Unexpected block response') if (escalationGameAddress === zeroAddress || escalationGameCode === undefined || escalationGameCode === '0x') @@ -390,6 +676,7 @@ export async function loadReportingDetails(client: ReadClient, securityPoolAddre forkThreshold, marketDetails, nonDecisionThreshold: forkThreshold / 2n, + parentSecurityPoolAddress, questionOutcome: normalizedQuestionOutcome, securityPoolAddress, settlementState: normalizedQuestionOutcome !== 'none' && systemState === 'operational' ? 'resolved' : 'locked', @@ -400,83 +687,86 @@ export async function loadReportingDetails(client: ReadClient, securityPoolAddre parentWithdrawalEnabled: false, ...viewerVaultState, } - const [startBond, nonDecisionThreshold, activationTime, totalCost, bindingCapital, balances, escalationEndTime, _questionOutcome, universeForkTime, hasReachedNonDecision] = requireEscalationGameTuple( - await readRequiredMulticall(client, [ - { - abi: peripherals_EscalationGame_EscalationGame.abi, - functionName: 'startBond', - address: escalationGameAddress, - args: [], - }, - { - abi: peripherals_EscalationGame_EscalationGame.abi, - functionName: 'nonDecisionThreshold', - address: escalationGameAddress, - args: [], - }, - { - abi: peripherals_EscalationGame_EscalationGame.abi, - functionName: 'activationTime', - address: escalationGameAddress, - args: [], - }, - { - abi: peripherals_EscalationGame_EscalationGame.abi, - functionName: 'totalCost', - address: escalationGameAddress, - args: [], - }, - { - abi: peripherals_EscalationGame_EscalationGame.abi, - functionName: 'getBindingCapital', - address: escalationGameAddress, - args: [], - }, - { - abi: peripherals_EscalationGame_EscalationGame.abi, - functionName: 'getBalances', - address: escalationGameAddress, - args: [], - }, - { - abi: peripherals_EscalationGame_EscalationGame.abi, - functionName: 'getEscalationGameEndDate', - address: escalationGameAddress, - args: [], - }, - { - abi: QUESTION_OUTCOME_ABI, - functionName: 'getQuestionOutcome', - address: getInfraContractAddresses().securityPoolForker, - args: [securityPoolAddress], - }, - { - abi: Zoltar_Zoltar.abi, - functionName: 'getForkTime', - address: getInfraContractAddresses().zoltar, - args: [universeId], - }, - { - abi: peripherals_EscalationGame_EscalationGame.abi, - functionName: 'hasReachedNonDecision', - address: escalationGameAddress, - args: [], - }, - ]), - 'escalation game', - ) - const [invalidDeposits, yesDeposits, noDeposits, invalidImportedDeposits, yesImportedDeposits, noImportedDeposits] = await Promise.all([ + const [startBond, nonDecisionThreshold, activationTime, totalCost, bindingCapital, invalidOutcomeState, yesOutcomeState, noOutcomeState, escalationEndTime, _questionOutcome, universeForkTime, hasReachedNonDecision] = await Promise.all([ + client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + functionName: 'startBond', + address: escalationGameAddress, + args: [], + }), + client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + functionName: 'nonDecisionThreshold', + address: escalationGameAddress, + args: [], + }), + client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + functionName: 'activationTime', + address: escalationGameAddress, + args: [], + }), + client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + functionName: 'totalCost', + address: escalationGameAddress, + args: [], + }), + client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + functionName: 'getBindingCapital', + address: escalationGameAddress, + args: [], + }), + readEscalationOutcomeState(client, escalationGameAddress, 'invalid'), + readEscalationOutcomeState(client, escalationGameAddress, 'yes'), + readEscalationOutcomeState(client, escalationGameAddress, 'no'), + client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + functionName: 'getEscalationGameEndDate', + address: escalationGameAddress, + args: [], + }), + client.readContract({ + abi: QUESTION_OUTCOME_ABI, + functionName: 'getQuestionOutcome', + address: getInfraContractAddresses().securityPoolForker, + args: [securityPoolAddress], + }), + client.readContract({ + abi: Zoltar_Zoltar.abi, + functionName: 'getForkTime', + address: getInfraContractAddresses().zoltar, + args: [universeId], + }), + client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + functionName: 'hasReachedNonDecision', + address: escalationGameAddress, + args: [], + }), + ]) + const balances: [bigint, bigint, bigint] = [invalidOutcomeState.balance, yesOutcomeState.balance, noOutcomeState.balance] + const useCarrySnapshot = forkContinuationSnapshot !== undefined + const [invalidDeposits, yesDeposits, noDeposits, invalidParentSnapshotDeposits, yesParentSnapshotDeposits, noParentSnapshotDeposits] = await Promise.all([ loadEscalationDeposits(client, escalationGameAddress, 'invalid'), loadEscalationDeposits(client, escalationGameAddress, 'yes'), loadEscalationDeposits(client, escalationGameAddress, 'no'), - accountAddress === undefined ? Promise.resolve([]) : loadImportedEscalationDeposits(client, escalationGameAddress, 'invalid', accountAddress), - accountAddress === undefined ? Promise.resolve([]) : loadImportedEscalationDeposits(client, escalationGameAddress, 'yes', accountAddress), - accountAddress === undefined ? Promise.resolve([]) : loadImportedEscalationDeposits(client, escalationGameAddress, 'no', accountAddress), + accountAddress === undefined || parentSecurityPoolAddress === zeroAddress || !useCarrySnapshot ? Promise.resolve([]) : loadForkCarriedEscalationDepositsFromParentSnapshot(client, escalationGameAddress, parentSecurityPoolAddress, 'invalid', accountAddress), + accountAddress === undefined || parentSecurityPoolAddress === zeroAddress || !useCarrySnapshot ? Promise.resolve([]) : loadForkCarriedEscalationDepositsFromParentSnapshot(client, escalationGameAddress, parentSecurityPoolAddress, 'yes', accountAddress), + accountAddress === undefined || parentSecurityPoolAddress === zeroAddress || !useCarrySnapshot ? Promise.resolve([]) : loadForkCarriedEscalationDepositsFromParentSnapshot(client, escalationGameAddress, parentSecurityPoolAddress, 'no', accountAddress), ]) const sides: EscalationSide[] = [ - { balance: balances[0] ?? 0n, deposits: invalidDeposits, importedUserDeposits: invalidImportedDeposits, key: 'invalid', label: getEscalationSideLabel('invalid'), userDeposits: accountAddress === undefined ? [] : invalidDeposits.filter(deposit => deposit.depositor === accountAddress) }, - { balance: balances[1] ?? 0n, deposits: yesDeposits, importedUserDeposits: yesImportedDeposits, key: 'yes', label: getEscalationSideLabel('yes'), userDeposits: accountAddress === undefined ? [] : yesDeposits.filter(deposit => deposit.depositor === accountAddress) }, - { balance: balances[2] ?? 0n, deposits: noDeposits, importedUserDeposits: noImportedDeposits, key: 'no', label: getEscalationSideLabel('no'), userDeposits: accountAddress === undefined ? [] : noDeposits.filter(deposit => deposit.depositor === accountAddress) }, + { + balance: balances[0] ?? 0n, + deposits: invalidDeposits, + importedUserDeposits: invalidParentSnapshotDeposits, + key: 'invalid', + label: getEscalationSideLabel('invalid'), + userDeposits: accountAddress === undefined ? [] : invalidDeposits.filter(deposit => deposit.depositor === accountAddress), + }, + { balance: balances[1] ?? 0n, deposits: yesDeposits, importedUserDeposits: yesParentSnapshotDeposits, key: 'yes', label: getEscalationSideLabel('yes'), userDeposits: accountAddress === undefined ? [] : yesDeposits.filter(deposit => deposit.depositor === accountAddress) }, + { balance: balances[2] ?? 0n, deposits: noDeposits, importedUserDeposits: noParentSnapshotDeposits, key: 'no', label: getEscalationSideLabel('no'), userDeposits: accountAddress === undefined ? [] : noDeposits.filter(deposit => deposit.depositor === accountAddress) }, ] let settlementState: ReportingSettlementState = 'locked' if (normalizedQuestionOutcome !== 'none' && systemState === 'operational') { @@ -495,6 +785,7 @@ export async function loadReportingDetails(client: ReadClient, securityPoolAddre hasReachedNonDecision, marketDetails, nonDecisionThreshold, + parentSecurityPoolAddress, questionOutcome: normalizedQuestionOutcome, securityPoolAddress, sides, @@ -1649,7 +1940,7 @@ export async function migrateEscalationDeposits(client: WriteClient, securityPoo })), ) } -export async function migrateVaultWithUnresolvedEscalation(client: WriteClient, securityPoolAddress: Address, universeId: bigint, outcome: ReportingOutcomeKey, invalidDepositIndexes: bigint[], yesDepositIndexes: bigint[], noDepositIndexes: bigint[]) { +export async function migrateVaultWithUnresolvedEscalation(client: WriteClient, securityPoolAddress: Address, universeId: bigint, outcome: ReportingOutcomeKey) { return await executeForkAuctionAction( client, 'migrateUnresolvedEscalation', @@ -1660,7 +1951,7 @@ export async function migrateVaultWithUnresolvedEscalation(client: WriteClient, address: getInfraContractAddresses().securityPoolForker, abi: peripherals_SecurityPoolForker_SecurityPoolForker.abi, functionName: 'migrateVaultWithUnresolvedEscalation', - args: [securityPoolAddress, getReportingOutcomeValue(outcome), toUint8Array(invalidDepositIndexes), toUint8Array(yesDepositIndexes), toUint8Array(noDepositIndexes)], + args: [securityPoolAddress, getReportingOutcomeValue(outcome)], })), ) } @@ -2114,7 +2405,71 @@ export async function withdrawEscalationFromSecurityPool(client: WriteClient, se universeId, } satisfies ReportingActionResult } -export async function withdrawForkedEscalationDeposits(client: WriteClient, securityPoolAddress: Address, outcome: ReportingOutcomeKey, parentDepositIndexes: bigint[]) { + +export async function buildForkCarriedEscalationProofs(client: ReadClient, securityPoolAddress: Address, outcome: ReportingOutcomeKey, parentDepositIndexes: readonly bigint[]): Promise { + const [parentSecurityPoolAddress, childEscalationGameAddress] = await readRequiredMulticall(client, [ + { + address: securityPoolAddress, + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'parent', + args: [], + }, + { + address: securityPoolAddress, + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'escalationGame', + args: [], + }, + ]) + if (parentSecurityPoolAddress === zeroAddress) throw new Error('Fork-carried escalation proofs require a child pool.') + if (childEscalationGameAddress === zeroAddress) throw new Error('Child escalation game unavailable for fork-carried settlement.') + const parentEscalationGameAddress = await client.readContract({ + address: parentSecurityPoolAddress, + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'escalationGame', + args: [], + }) + if (parentEscalationGameAddress === zeroAddress) throw new Error('Parent escalation game unavailable for fork-carried settlement.') + const [parentSnapshot, consumedParentDepositIndexes, childOutcomeState] = await Promise.all([ + loadRecursiveCarrySnapshot(client, parentEscalationGameAddress, outcome), + loadProofConsumedCarriedDepositIndexes(client, childEscalationGameAddress, outcome), + readEscalationOutcomeState(client, childEscalationGameAddress, outcome), + ]) + const { currentNullifierRoot: childNullifierRoot } = childOutcomeState + const { orderedLeaves, carryRoot: parentCarryRoot, carryLeafCount: parentCarryLeafCount } = parentSnapshot + if (BigInt(orderedLeaves.length) !== parentCarryLeafCount) throw new Error('Parent carry snapshot is not locally reconstructible.') + const leafHashes = orderedLeaves.map(leaf => hashCarryLeaf(leaf, outcome)) + if (leafHashes.length > 0) { + const { root: reconstructedRoot } = buildCarryMerkleMountainRangeProof(leafHashes, 0) + if (reconstructedRoot !== parentCarryRoot) throw new Error('Parent carry snapshot root is not locally reconstructible.') + } + const nullifierTree = new SparseNullifier(consumedParentDepositIndexes) + if (nullifierTree.getRoot() !== childNullifierRoot) throw new Error('Child proof-consumed carry state is not locally reconstructible.') + const proofs: CarriedDepositProof[] = [] + for (const parentDepositIndex of parentDepositIndexes) { + const leafIndex = orderedLeaves.findIndex(leaf => leaf.parentDepositIndex === parentDepositIndex) + if (leafIndex === -1) throw new Error(`Parent carry leaf ${parentDepositIndex.toString()} is unavailable.`) + const targetLeaf = orderedLeaves[leafIndex] + if (targetLeaf === undefined) throw new Error(`Parent carry leaf ${parentDepositIndex.toString()} is unavailable.`) + const { merkleMountainRangePeakIndex, merkleMountainRangeSiblings } = buildCarryMerkleMountainRangeProof(leafHashes, leafIndex) + const nullifierSiblings = nullifierTree.getProof(parentDepositIndex) + proofs.push({ + amount: targetLeaf.amount, + cumulativeAmount: targetLeaf.cumulativeAmount, + depositor: targetLeaf.depositor, + leafIndex: BigInt(leafIndex), + merkleMountainRangePeakIndex, + merkleMountainRangeSiblings, + nullifierSiblings, + parentDepositIndex: targetLeaf.parentDepositIndex, + sourceNodeId: targetLeaf.sourceNodeId, + }) + nullifierTree.consume(parentDepositIndex) + } + return proofs +} + +export async function withdrawForkedEscalationDeposits(client: WriteClient, securityPoolAddress: Address, outcome: ReportingOutcomeKey, proofs: readonly CarriedDepositProof[]) { const universeId = await readSecurityPoolUniverseId(client, securityPoolAddress) return await executeForkAuctionAction( client, @@ -2126,7 +2481,14 @@ export async function withdrawForkedEscalationDeposits(client: WriteClient, secu address: securityPoolAddress, abi: peripherals_SecurityPool_SecurityPool.abi, functionName: 'withdrawForkedEscalationDeposits', - args: [getReportingOutcomeValue(outcome), toUint8Array(parentDepositIndexes)], + args: [ + getReportingOutcomeValue(outcome), + proofs.map(proof => ({ + ...proof, + merkleMountainRangeSiblings: Array.from(proof.merkleMountainRangeSiblings), + nullifierSiblings: Array.from(proof.nullifierSiblings), + })), + ], })), ) } diff --git a/ui/ts/hooks/useForkAuctionOperations.ts b/ui/ts/hooks/useForkAuctionOperations.ts index f93b9ea7..55a21395 100644 --- a/ui/ts/hooks/useForkAuctionOperations.ts +++ b/ui/ts/hooks/useForkAuctionOperations.ts @@ -4,6 +4,7 @@ import { useLoadController } from './useLoadController.js' import type { Address } from 'viem' import { createChildUniverseFromSecurityPool, + buildForkCarriedEscalationProofs, finalizeSecurityPoolTruthAuction, forkUniverseDirectly, forkZoltarWithOwnEscalation, @@ -146,10 +147,10 @@ export function useForkAuctionOperations({ accountAddress, onTransactionFailed, 'Failed to migrate escalation deposits', ) - const migrateUnresolvedEscalation = async (selectedChildOutcome: ReportingOutcomeKey, selectedByOutcome: Record) => + const migrateUnresolvedEscalation = async (selectedChildOutcome: ReportingOutcomeKey) => await runForkAuctionAction( 'migrateUnresolvedEscalation', - async (walletAddress, details) => await migrateVaultWithUnresolvedEscalation(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), details.securityPoolAddress, details.universeId, selectedChildOutcome, selectedByOutcome.invalid, selectedByOutcome.yes, selectedByOutcome.no), + async (walletAddress, details) => await migrateVaultWithUnresolvedEscalation(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), details.securityPoolAddress, details.universeId, selectedChildOutcome), 'Failed to migrate unresolved escalation deposits', ) @@ -216,7 +217,14 @@ export function useForkAuctionOperations({ accountAddress, onTransactionFailed, ) const settleForkedEscalation = async (outcome: ReportingOutcomeKey, parentDepositIndexes: bigint[]) => - await runForkAuctionAction('settleForkedEscalation', async (walletAddress, details) => await withdrawForkedEscalationDeposits(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), details.securityPoolAddress, outcome, parentDepositIndexes), 'Failed to settle fork-carried escalation deposits') + await runForkAuctionAction( + 'settleForkedEscalation', + async (walletAddress, details) => { + const proofs = await buildForkCarriedEscalationProofs(createConnectedReadClient(), details.securityPoolAddress, outcome, parentDepositIndexes) + return await withdrawForkedEscalationDeposits(createWalletWriteClient(walletAddress, { onTransactionSubmitted }), details.securityPoolAddress, outcome, proofs) + }, + 'Failed to settle fork-carried escalation deposits', + ) const forkUniverse = async () => await runForkAuctionAction( diff --git a/ui/ts/tests/contracts.test.ts b/ui/ts/tests/contracts.test.ts index 9c4ee489..b02e0e33 100644 --- a/ui/ts/tests/contracts.test.ts +++ b/ui/ts/tests/contracts.test.ts @@ -7,7 +7,6 @@ import { loadAllSecurityPools, loadEscalationDeposits, loadForkAuctionDetails, - loadImportedEscalationDeposits, loadOpenOracleReportSummaries, loadReportingDetails, loadSecurityPoolPage, @@ -449,46 +448,6 @@ describe('contracts helpers', () => { expect(decodedCall.args).toEqual([7n]) }) - test('loadImportedEscalationDeposits pages imported indexes and maps imported deposits correctly', async () => { - const depositor = getAddress('0x00000000000000000000000000000000000000e5') - const requestedPageStarts: bigint[] = [] - const importedIndexesByStart = new Map([ - [0n, Array.from({ length: 30 }, (_, index) => BigInt(index + 1))], - [30n, [31n]], - ]) - const client = createMockReadClient(async request => { - if (request.functionName === 'getUnsettledImportedDepositIndexesByOutcomeAndDepositor') { - const args = request.args - if (!Array.isArray(args) || typeof args[2] !== 'bigint') throw new Error('Expected imported pagination args') - requestedPageStarts.push(args[2]) - return importedIndexesByStart.get(args[2]) ?? [] - } - if (request.functionName === 'importedDeposits') { - const args = request.args - if (!Array.isArray(args) || typeof args[1] !== 'bigint') throw new Error('Expected imported deposit args') - return [depositor, args[1] * 10n, args[1] * 100n, false] - } - throw new Error(`Unexpected readContract function: ${request.functionName}`) - }) - - const deposits = await loadImportedEscalationDeposits(client, escalationGameAddress, 'yes', depositor) - - expect(requestedPageStarts).toEqual([0n, 30n]) - expect(deposits).toHaveLength(31) - expect(deposits[0]).toEqual({ - amount: 10n, - cumulativeAmount: 100n, - depositor, - parentDepositIndex: 1n, - }) - expect(deposits[30]).toEqual({ - amount: 310n, - cumulativeAmount: 3100n, - depositor, - parentDepositIndex: 31n, - }) - }) - test('loadReportingDetails marks unrelated external-fork unresolved parent deposits as migration-required, not withdrawable', async () => { const viewerAddress = getAddress('0x00000000000000000000000000000000000000ef') const questionTuple = ['Question', 'Description', 1n, 2n, 2n, 0n, 100n, ''] as const @@ -498,12 +457,29 @@ describe('contracts helpers', () => { multicall: createMulticallStub(async request => { const firstContract = request.contracts[0] const functionName = getContractFunctionName(firstContract) - if (functionName === 'questionId') return [1n, escalationGameAddress, 20n, 3n, zoltarAddress, 5n, 0n] + if (functionName === 'questionId') return [1n, escalationGameAddress, 20n, 3n, zoltarAddress, 5n, 0n, 3n, zeroAddress] if (functionName === 'questions') return [questionTuple, 10n] if (functionName === 'startBond') return [7n, 50n, 12n, 22n, 11n, [1n, 14n, 3n], 150n, 3n, 123n, false] throw new Error(`Unexpected multicall contract: ${functionName}`) }), readContract: createReadContractStub(async request => { + if (request.functionName === 'startBond') return 7n + if (request.functionName === 'nonDecisionThreshold') return 50n + if (request.functionName === 'activationTime') return 12n + if (request.functionName === 'totalCost') return 22n + if (request.functionName === 'getBindingCapital') return 11n + if (request.functionName === 'getOutcomeState') { + const args = request.args + if (!Array.isArray(args) || typeof args[0] !== 'number') throw new Error('Expected outcome state args') + if (args[0] === 0) return { balance: 1n } + if (args[0] === 1) return { balance: 14n } + if (args[0] === 2) return { balance: 3n } + throw new Error(`Unexpected outcome state index: ${args[0].toString()}`) + } + if (request.functionName === 'getEscalationGameEndDate') return 150n + if (request.functionName === 'getQuestionOutcome') return 3 + if (request.functionName === 'getForkTime') return 123n + if (request.functionName === 'hasReachedNonDecision') return false if (request.functionName === 'getForkThreshold') return 100n if (request.functionName === 'securityVaults') return [0n, 0n, 0n, 0n, 0n] if (request.functionName === 'getOutcomeLabels') return ['Yes', 'No'] @@ -515,12 +491,6 @@ describe('contracts helpers', () => { } return [] } - if (request.functionName === 'getUnsettledImportedDepositIndexesByOutcomeAndDepositor') { - const args = request.args - if (!Array.isArray(args) || typeof args[0] !== 'number') throw new Error('Expected imported outcome args') - return args[0] === 1 ? [41n] : [] - } - if (request.functionName === 'importedDeposits') return [viewerAddress, 8n, 16n, false] throw new Error(`Unexpected readContract function: ${request.functionName}`) }), } as unknown as Parameters[0] @@ -534,14 +504,7 @@ describe('contracts helpers', () => { const yesSide = details.sides.find((side: EscalationSide) => side.key === 'yes') if (yesSide === undefined) throw new Error('Expected yes side') expect(yesSide.userDeposits).toHaveLength(1) - expect(yesSide.importedUserDeposits).toEqual([ - { - amount: 8n, - cumulativeAmount: 16n, - depositor: viewerAddress, - parentDepositIndex: 41n, - }, - ]) + expect(yesSide.importedUserDeposits).toEqual([]) }) test('loadReportingDetails keeps parent settlement locked when the unrelated external fork happened after escalation ended', async () => { @@ -553,12 +516,29 @@ describe('contracts helpers', () => { multicall: createMulticallStub(async request => { const firstContract = request.contracts[0] const functionName = getContractFunctionName(firstContract) - if (functionName === 'questionId') return [1n, escalationGameAddress, 20n, 3n, zoltarAddress, 5n, 0n] + if (functionName === 'questionId') return [1n, escalationGameAddress, 20n, 3n, zoltarAddress, 5n, 0n, 3n, zeroAddress] if (functionName === 'questions') return [questionTuple, 10n] if (functionName === 'startBond') return [7n, 50n, 12n, 22n, 11n, [1n, 14n, 3n], 99n, 3n, 120n, false] throw new Error(`Unexpected multicall contract: ${functionName}`) }), readContract: createReadContractStub(async request => { + if (request.functionName === 'startBond') return 7n + if (request.functionName === 'nonDecisionThreshold') return 50n + if (request.functionName === 'activationTime') return 12n + if (request.functionName === 'totalCost') return 22n + if (request.functionName === 'getBindingCapital') return 11n + if (request.functionName === 'getOutcomeState') { + const args = request.args + if (!Array.isArray(args) || typeof args[0] !== 'number') throw new Error('Expected outcome state args') + if (args[0] === 0) return { balance: 1n } + if (args[0] === 1) return { balance: 14n } + if (args[0] === 2) return { balance: 3n } + throw new Error(`Unexpected outcome state index: ${args[0].toString()}`) + } + if (request.functionName === 'getEscalationGameEndDate') return 99n + if (request.functionName === 'getQuestionOutcome') return 3 + if (request.functionName === 'getForkTime') return 120n + if (request.functionName === 'hasReachedNonDecision') return false if (request.functionName === 'getForkThreshold') return 100n if (request.functionName === 'securityVaults') return [0n, 0n, 0n, 0n, 0n] if (request.functionName === 'getOutcomeLabels') return ['Yes', 'No'] @@ -570,7 +550,6 @@ describe('contracts helpers', () => { } return [] } - if (request.functionName === 'getUnsettledImportedDepositIndexesByOutcomeAndDepositor') return [] throw new Error(`Unexpected readContract function: ${request.functionName}`) }), } as unknown as Parameters[0] @@ -590,7 +569,7 @@ describe('contracts helpers', () => { multicall: createMulticallStub(async request => { const firstContract = request.contracts[0] const functionName = getContractFunctionName(firstContract) - if (functionName === 'questionId') return [1n, zeroAddress, 20n, 3n, zoltarAddress, 5n, 0n, 1n] + if (functionName === 'questionId') return [1n, zeroAddress, 20n, 3n, zoltarAddress, 5n, 0n, 1n, zeroAddress] if (functionName === 'questions') return [questionTuple, 10n] throw new Error(`Unexpected multicall contract: ${functionName}`) }), @@ -618,7 +597,7 @@ describe('contracts helpers', () => { multicall: createMulticallStub(async request => { const firstContract = request.contracts[0] const functionName = getContractFunctionName(firstContract) - if (functionName === 'questionId') return [1n, zeroAddress, 20n, 3n, zoltarAddress, 5n, 2n, 1n] + if (functionName === 'questionId') return [1n, zeroAddress, 20n, 3n, zoltarAddress, 5n, 2n, 1n, zeroAddress] if (functionName === 'questions') return [questionTuple, 10n] throw new Error(`Unexpected multicall contract: ${functionName}`) }), @@ -677,7 +656,7 @@ describe('contracts helpers', () => { expect(deposits[29]?.depositIndex).toBe(30n) }) - test('migrateVaultWithUnresolvedEscalation helper encodes invalid, yes, and no deposit indexes correctly', async () => { + test('migrateVaultWithUnresolvedEscalation helper encodes the selected child outcome correctly', async () => { let capturedData: Hex | undefined let capturedTo: Address | null | undefined const client = createMockWriteClient(request => { @@ -685,7 +664,7 @@ describe('contracts helpers', () => { capturedTo = request.to }) - const result = await migrateVaultWithUnresolvedEscalation(asWriteClient(client), securityPoolAddress, 9n, 'no', [1n, 4n], [7n], [9n, 10n]) + const result = await migrateVaultWithUnresolvedEscalation(asWriteClient(client), securityPoolAddress, 9n, 'no') expect(capturedTo).toBeDefined() expect(capturedData).toBeDefined() @@ -694,7 +673,7 @@ describe('contracts helpers', () => { data: capturedData ?? ('0x' satisfies Hex), }) expect(decodedCall.functionName).toBe('migrateVaultWithUnresolvedEscalation') - expect(decodedCall.args).toEqual([securityPoolAddress, 2, [1n, 4n], [7n], [9n, 10n]]) + expect(decodedCall.args).toEqual([securityPoolAddress, 2]) expect(result).toEqual({ action: 'migrateUnresolvedEscalation', hash: transactionHash, @@ -703,15 +682,29 @@ describe('contracts helpers', () => { }) }) - test('withdrawForkedEscalationDeposits helper encodes parent deposit indexes correctly', async () => { + test('withdrawForkedEscalationDeposits helper encodes proof batches correctly', async () => { let capturedData: Hex | undefined let capturedTo: Address | null | undefined + const merkleMountainRangeSibling = ('0x' + '11'.repeat(32)) as Hex + const nullifierSibling = ('0x' + '22'.repeat(32)) as Hex const client = createMockWriteClient(request => { capturedData = request.data capturedTo = request.to }) - const result = await withdrawForkedEscalationDeposits(asWriteClient(client), securityPoolAddress, 'yes', [3n, 8n]) + const result = await withdrawForkedEscalationDeposits(asWriteClient(client), securityPoolAddress, 'yes', [ + { + depositor: vaultAddress, + amount: 5n, + parentDepositIndex: 3n, + cumulativeAmount: 8n, + sourceNodeId: 2n, + leafIndex: 1n, + merkleMountainRangeSiblings: [merkleMountainRangeSibling], + merkleMountainRangePeakIndex: 1n, + nullifierSiblings: [nullifierSibling], + }, + ]) expect(capturedTo).toBe(securityPoolAddress) expect(capturedData).toBeDefined() @@ -720,7 +713,22 @@ describe('contracts helpers', () => { data: capturedData ?? ('0x' satisfies Hex), }) expect(decodedCall.functionName).toBe('withdrawForkedEscalationDeposits') - expect(decodedCall.args).toEqual([1, [3n, 8n]]) + expect(decodedCall.args).toEqual([ + 1, + [ + { + depositor: vaultAddress, + amount: 5n, + parentDepositIndex: 3n, + cumulativeAmount: 8n, + sourceNodeId: 2n, + leafIndex: 1n, + merkleMountainRangeSiblings: [merkleMountainRangeSibling], + merkleMountainRangePeakIndex: 1n, + nullifierSiblings: [nullifierSibling], + }, + ], + ]) expect(result).toEqual({ action: 'settleForkedEscalation', hash: transactionHash, diff --git a/ui/ts/tests/forkAuctionChildPoolRecovery.test.tsx b/ui/ts/tests/forkAuctionChildPoolRecovery.test.tsx index c96051f8..b0d0199c 100644 --- a/ui/ts/tests/forkAuctionChildPoolRecovery.test.tsx +++ b/ui/ts/tests/forkAuctionChildPoolRecovery.test.tsx @@ -202,7 +202,7 @@ function createProps(overrides: Partial = {}): ForkAuct onInitiateFork: () => undefined, onLoadForkAuction: () => undefined, onMigrateEscalationDeposits: () => undefined, - onMigrateUnresolvedEscalation: (_selectedChildOutcome, _selectedByOutcome) => undefined, + onMigrateUnresolvedEscalation: _selectedChildOutcome => undefined, onMigrateRepToZoltar: () => undefined, onMigrateVault: () => undefined, onRefundLosingBids: () => undefined, diff --git a/ui/ts/tests/forkAuctionSection.test.tsx b/ui/ts/tests/forkAuctionSection.test.tsx index 0d79b00c..53bfa9f3 100644 --- a/ui/ts/tests/forkAuctionSection.test.tsx +++ b/ui/ts/tests/forkAuctionSection.test.tsx @@ -216,7 +216,7 @@ function createProps(overrides: Partial = {}): ForkAuct onInitiateFork: () => undefined, onLoadForkAuction: () => undefined, onMigrateEscalationDeposits: () => undefined, - onMigrateUnresolvedEscalation: (_selectedChildOutcome, _selectedByOutcome) => undefined, + onMigrateUnresolvedEscalation: _selectedChildOutcome => undefined, onMigrateRepToZoltar: () => undefined, onMigrateVault: () => undefined, onRefundLosingBids: () => undefined, diff --git a/ui/ts/tests/securityPoolWorkflowSection.test.tsx b/ui/ts/tests/securityPoolWorkflowSection.test.tsx index 720e4cbd..5038ca01 100644 --- a/ui/ts/tests/securityPoolWorkflowSection.test.tsx +++ b/ui/ts/tests/securityPoolWorkflowSection.test.tsx @@ -203,7 +203,7 @@ function createForkAuctionProps(overrides: Partial onInitiateFork: () => undefined, onLoadForkAuction: () => undefined, onMigrateEscalationDeposits: (_outcome, _depositIndexes) => undefined, - onMigrateUnresolvedEscalation: (_selectedChildOutcome, _selectedByOutcome) => undefined, + onMigrateUnresolvedEscalation: _selectedChildOutcome => undefined, onMigrateRepToZoltar: _outcomes => undefined, onMigrateVault: () => undefined, onRefundLosingBids: () => undefined, diff --git a/ui/ts/tests/securityPoolsSection.test.ts b/ui/ts/tests/securityPoolsSection.test.ts index afded686..4aa96a79 100644 --- a/ui/ts/tests/securityPoolsSection.test.ts +++ b/ui/ts/tests/securityPoolsSection.test.ts @@ -152,7 +152,7 @@ function createForkAuctionProps(overrides: Partial onInitiateFork: () => undefined, onLoadForkAuction: () => undefined, onMigrateEscalationDeposits: (_outcome, _depositIndexes) => undefined, - onMigrateUnresolvedEscalation: (_selectedChildOutcome, _selectedByOutcome) => undefined, + onMigrateUnresolvedEscalation: _selectedChildOutcome => undefined, onMigrateRepToZoltar: _outcomes => undefined, onMigrateVault: () => undefined, onRefundLosingBids: () => undefined, diff --git a/ui/ts/types/components.ts b/ui/ts/types/components.ts index c6ce69dd..3ee344c6 100644 --- a/ui/ts/types/components.ts +++ b/ui/ts/types/components.ts @@ -692,7 +692,7 @@ export type ForkAuctionRouteContentProps = { onInitiateFork: () => void onLoadForkAuction: (securityPoolAddressOverride?: Address) => void onMigrateEscalationDeposits: (outcome: ReportingOutcomeKey, depositIndexes?: bigint[]) => void - onMigrateUnresolvedEscalation: (selectedChildOutcome: ReportingOutcomeKey, selectedByOutcome: Record) => void + onMigrateUnresolvedEscalation: (selectedChildOutcome: ReportingOutcomeKey) => void onMigrateRepToZoltar: (outcomes?: ReportingOutcomeKey[]) => void onMigrateVault: () => void onRefundLosingBids: (securityPoolAddressOverride?: Address, selectedBids?: readonly SettlementSelectedBid[]) => void diff --git a/ui/ts/types/contracts.ts b/ui/ts/types/contracts.ts index 2f27e726..ae703de2 100644 --- a/ui/ts/types/contracts.ts +++ b/ui/ts/types/contracts.ts @@ -353,6 +353,18 @@ export type ImportedEscalationDeposit = { parentDepositIndex: bigint } +export type CarriedDepositProof = { + depositor: Address + amount: bigint + parentDepositIndex: bigint + cumulativeAmount: bigint + sourceNodeId: bigint + leafIndex: bigint + merkleMountainRangeSiblings: Hex[] + merkleMountainRangePeakIndex: bigint + nullifierSiblings: Hex[] +} + export type EscalationSide = { balance: bigint deposits: EscalationDeposit[] @@ -370,6 +382,7 @@ type ReportingDetailsBase = { forkThreshold: bigint marketDetails: MarketDetails nonDecisionThreshold: bigint + parentSecurityPoolAddress?: Address questionOutcome: ReportingOutcomeKey | 'none' securityPoolAddress: Address settlementState: ReportingSettlementState