From 6697a797c4528e9d56452be3e49c3ce3b389c2a1 Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Tue, 23 Jun 2026 07:34:18 +0000 Subject: [PATCH 1/4] Refactor contract API into reporting, truth auction, and fork modules - Extracted reporting logic (deposits, escrow ops, and fork proof helpers) from `ui/ts/contracts.ts` into `ui/ts/contracts/reporting.ts` - Moved truth-auction query helpers into `ui/ts/contracts/truthAuctions.ts` - Moved fork-auction action execution helper into `ui/ts/contracts/securityPoolActions.ts` - Added shared decoders/type helpers in `ui/ts/contracts/decoders.ts` and updated imports/exports in `ui/ts/contracts.ts` - Updated `ui/ts/tests/contracts.test.ts` to match the reorganized contract module structure --- ui/ts/contracts.ts | 909 +------------------------ ui/ts/contracts/decoders.ts | 33 + ui/ts/contracts/reporting.ts | 814 ++++++++++++++++++++++ ui/ts/contracts/securityPoolActions.ts | 23 + ui/ts/contracts/truthAuctions.ts | 161 +++++ ui/ts/tests/contracts.test.ts | 60 ++ 6 files changed, 1095 insertions(+), 905 deletions(-) create mode 100644 ui/ts/contracts/decoders.ts create mode 100644 ui/ts/contracts/reporting.ts create mode 100644 ui/ts/contracts/securityPoolActions.ts create mode 100644 ui/ts/contracts/truthAuctions.ts diff --git a/ui/ts/contracts.ts b/ui/ts/contracts.ts index d7508440e..8a6d0779d 100644 --- a/ui/ts/contracts.ts +++ b/ui/ts/contracts.ts @@ -1,4 +1,4 @@ -import { concatHex, decodeEventLog, encodeAbiParameters, keccak256, parseAbiItem, parseAbiParameters, zeroAddress, type Address, type ContractFunctionParameters, type Hash, type Hex, type TransactionReceipt } from 'viem' +import { decodeEventLog, parseAbiItem, 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,14 +20,9 @@ import { peripherals_tokens_ShareToken_ShareToken, } from './contractArtifact.js' import type { - CarriedDepositProof, DeploymentStepId, - EscalationDeposit, - EscalationSide, - ForkAuctionAction, ForkAuctionActionResult, ForkAuctionDetails, - ImportedEscalationDeposit, ListedSecurityPool, OpenOracleActionResult, OracleManagerDetails, @@ -35,10 +30,7 @@ import type { ReadClient, OpenOracleReportSummary, OpenOracleReportSummaryPage, - ReportingActionResult, - ReportingDetails, ReportingOutcomeKey, - ReportingSettlementState, SecurityPoolVaultSummary, StagedOracleExecutionResult, StagedOracleQueuedResult, @@ -46,11 +38,6 @@ import type { TradingActionResult, TradingDetails, TradingShareBalances, - TruthAuctionBidView, - TruthAuctionBidderBidPage, - TruthAuctionTickSummary, - TruthAuctionTickBidPage, - TruthAuctionTickPage, TruthAuctionMetrics, WriteClient, ZoltarChildUniverseActionResult, @@ -58,7 +45,6 @@ import type { ZoltarMigrationActionResult, } from './types/contracts.js' import { - getEscalationSideLabel, getForkOutcomeKey, getMinBigintValue, getQuestionIdHex, @@ -79,50 +65,25 @@ import { } from './contracts/helpers.js' import { type ContractRevertReasonParams, type WriteContractClient, readRequiredMulticall, writeContractAndWait, writeContractAndWaitForReceipt } from './contracts/core.js' import { getInfraContractAddresses, getOpenOracleAddress, getZoltarAddress } from './contracts/deploymentHelpers.js' +import { executeForkAuctionAction, readSecurityPoolUniverseId } from './contracts/securityPoolActions.js' export { getDeploymentSteps, loadDeploymentStatusOracleSnapshot, loadErc20Allowance, loadErc20Balance } from './contracts/deployment.js' import { getDeploymentSteps } from './contracts/deployment.js' export { createSecurityPool, loadSecurityPoolPage, loadSecurityVaultDetails, originSecurityPoolExists } from './contracts/securityPools.js' export { createMarket, loadAllZoltarQuestions, loadMarketDetails, loadZoltarQuestionCount, loadZoltarQuestionPage, loadZoltarUniverseSummary } from './contracts/zoltar.js' import { loadMarketDetails } from './contracts/zoltar.js' +export { buildForkCarriedEscalationProofs, loadEscalationDeposits, loadReportingDetails, reportOutcomeInSecurityPool, withdrawEscalationFromSecurityPool, withdrawForkedEscalationDeposits } from './contracts/reporting.js' +export { loadTruthAuctionActiveTickPage, loadTruthAuctionBidderBidPage, loadTruthAuctionTickBidPage, loadTruthAuctionTickPage, loadTruthAuctionTickSummary } from './contracts/truthAuctions.js' export { readOptionalMulticall } from './contracts/core.js' export { getMulticall3Address, getOpenOracleAddress, getZoltarAddress } from './contracts/deploymentHelpers.js' const LIQUIDATION_OPERATION_TYPE = 0 const MIGRATION_TIME_LENGTH = 4838400n 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 UNRESOLVED_ESCALATION_MIGRATION_BATCH_LIMIT = 128 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, bigint] type AuctionClearingTuple = readonly [boolean, bigint, bigint, bigint] -type TruthAuctionTickSummaryStruct = { - tick: bigint - price: bigint - currentTotalEth: bigint - submissionCount: bigint - active: boolean -} -type TruthAuctionBidViewStruct = { - tick: bigint - bidIndex: bigint - bidder: Address - ethAmount: bigint - cumulativeEth: bigint - activeCumulativeEthBeforeBid: bigint - claimed: boolean - refunded: boolean -} -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 = { accountAddress?: Address selectedSecurityPoolAddress?: Address | string @@ -205,12 +166,6 @@ function getStagedOracleQueuedResult(receipt: TransactionReceipt, expectedOperat return undefined } -function getTruthAuctionPageOffset(pageIndex: number, pageSize: number) { - if (!Number.isInteger(pageIndex) || pageIndex < 0) throw new Error('Page index must be a non-negative integer') - if (!Number.isInteger(pageSize) || pageSize <= 0) throw new Error('Page size must be a positive integer') - return BigInt(pageIndex) * BigInt(pageSize) -} - function requireBigintValue(value: unknown, context: string) { if (typeof value === 'bigint') return value throw new Error(`Unexpected ${context} response`) @@ -226,614 +181,11 @@ function requireBigintArray(value: unknown, context: string) { return result } -async function readSecurityPoolUniverseId(client: Pick, securityPoolAddress: Address) { - return await client.readContract({ - address: securityPoolAddress, - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'universeId', - args: [], - }) -} function getDeploymentStep(id: DeploymentStepId) { const step = getDeploymentSteps().find(candidate => candidate.id === id) if (step === undefined) throw new Error(`Unknown deployment step: ${id}`) return step } -export async function loadEscalationDeposits(client: Pick, escalationGameAddress: Address, outcome: ReportingOutcomeKey): Promise { - let currentIndex = 0n - const deposits: EscalationDeposit[] = [] - while (true) { - const page = await client.readContract({ - abi: peripherals_EscalationGame_EscalationGame.abi, - address: escalationGameAddress, - functionName: 'getDepositsByOutcome', - args: [getReportingOutcomeValue(outcome), currentIndex, CONTRACT_PAGE_SIZE], - }) - const normalizedPage = page - .map((deposit, index) => ({ - amount: deposit.amount, - cumulativeAmount: deposit.cumulativeAmount, - depositIndex: currentIndex + BigInt(index), - depositor: deposit.depositor, - })) - .filter(deposit => deposit.depositor !== zeroAddress && deposit.amount > 0n) - deposits.push(...normalizedPage) - if (BigInt(page.length) !== CONTRACT_PAGE_SIZE) break - currentIndex += CONTRACT_PAGE_SIZE - } - return deposits -} - -function isCarryLeafView(value: unknown): value is CarryLeafViewStruct { - if (typeof value !== 'object' || value === undefined || value === null) return false - const candidate = value as Record - return typeof candidate['depositor'] === 'string' && typeof candidate['amount'] === 'bigint' && typeof candidate['parentDepositIndex'] === 'bigint' && typeof candidate['cumulativeAmount'] === 'bigint' && typeof candidate['sourceNodeId'] === 'bigint' -} - -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 -} - -async function loadProofConsumedCarriedDepositIndexes(client: Pick, escalationGameAddress: Address, outcome: ReportingOutcomeKey) { - let startIndex = 0n - const parentDepositIndexes: bigint[] = [] - while (true) { - const page = await client.readContract({ - abi: peripherals_EscalationGame_EscalationGame.abi, - address: escalationGameAddress, - functionName: 'getProofConsumedCarriedDepositIndexesByOutcome', - args: [getReportingOutcomeValue(outcome), startIndex, CONTRACT_PAGE_SIZE], - }) - 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 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) - return { - viewerVaultAvailableEscalationRep: undefined, - viewerVaultExists: false, - viewerVaultEscrowedRep: undefined, - viewerVaultRepDepositShare: undefined, - } - const viewerVaultTuple = await client.readContract({ - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'securityVaults', - address: securityPoolAddress, - args: [accountAddress], - }) - const viewerVaultTuples = requireSecurityVaultTupleArray([viewerVaultTuple], 'viewer security vault tuple') - const [viewerPoolOwnership, viewerSecurityBondAllowance, viewerUnpaidEthFees, viewerFeeIndex] = viewerVaultTuples[0] ?? [] - if (typeof viewerPoolOwnership !== 'bigint' || typeof viewerSecurityBondAllowance !== 'bigint' || typeof viewerUnpaidEthFees !== 'bigint' || typeof viewerFeeIndex !== 'bigint') throw new Error('Unexpected viewer security vault tuple response') - const viewerVaultRepDepositShare = - viewerPoolOwnership === 0n - ? 0n - : await client.readContract({ - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'poolOwnershipToRep', - address: securityPoolAddress, - args: [viewerPoolOwnership], - }) - const escalationGameAddress = await client.readContract({ - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'escalationGame', - address: securityPoolAddress, - args: [], - }) - const viewerVaultEscrowedRep = sameAddress(escalationGameAddress, zeroAddress) - ? 0n - : await client.readContract({ - abi: peripherals_EscalationGame_EscalationGame.abi, - functionName: 'escrowedRepByVault', - address: escalationGameAddress, - args: [accountAddress], - }) - const viewerVaultExists = viewerPoolOwnership !== 0n || viewerSecurityBondAllowance !== 0n || viewerUnpaidEthFees !== 0n || viewerFeeIndex !== 0n || viewerVaultEscrowedRep !== 0n - const viewerVaultAvailableEscalationRep = viewerVaultRepDepositShare - return { - viewerVaultAvailableEscalationRep, - viewerVaultExists, - viewerVaultEscrowedRep, - viewerVaultRepDepositShare, - } -} -export async function loadReportingDetails(client: ReadClient, securityPoolAddress: Address, accountAddress: Address | undefined): Promise { - const reportingPoolReads: readonly ContractFunctionParameters[] = [ - { - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'questionId', - address: securityPoolAddress, - args: [], - }, - { - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'escalationGame', - address: securityPoolAddress, - args: [], - }, - { - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'completeSetCollateralAmount', - address: securityPoolAddress, - args: [], - }, - { - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'universeId', - address: securityPoolAddress, - args: [], - }, - { - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'zoltar', - address: securityPoolAddress, - args: [], - }, - { - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'initialEscalationGameDeposit', - address: securityPoolAddress, - args: [], - }, - { - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'systemState', - address: securityPoolAddress, - args: [], - }, - { - abi: QUESTION_OUTCOME_ABI, - functionName: 'getQuestionOutcome', - address: getInfraContractAddresses().securityPoolForker, - args: [securityPoolAddress], - }, - { - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'parent', - address: securityPoolAddress, - args: [], - }, - ] - 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, forkContinuationSnapshot] = await Promise.all([ - loadMarketDetails(client, questionId), - client.getBlock(), - escalationGameAddress === zeroAddress ? Promise.resolve('0x' as const) : client.getCode({ address: escalationGameAddress }), - loadViewerReportingVaultState(client, securityPoolAddress, accountAddress), - client.readContract({ - abi: Zoltar_Zoltar.abi, - address: zoltarAddress, - 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') - return { - completeSetCollateralAmount, - currentTime: block.timestamp, - forkThreshold, - marketDetails, - nonDecisionThreshold: forkThreshold / 2n, - parentSecurityPoolAddress, - questionOutcome: normalizedQuestionOutcome, - securityPoolAddress, - settlementState: normalizedQuestionOutcome !== 'none' && systemState === 'operational' ? 'resolved' : 'locked', - startBond: initialEscalationGameDeposit, - status: 'not-started', - systemState, - universeId, - parentWithdrawalEnabled: false, - ...viewerVaultState, - } - 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 || 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: 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') { - settlementState = 'resolved' - } else if (universeForkTime > 0n && universeForkTime < escalationEndTime && hasReachedNonDecision === false) { - settlementState = block.timestamp <= universeForkTime + MIGRATION_TIME_LENGTH ? 'migration-required' : 'migration-expired' - } - return { - bindingCapital, - completeSetCollateralAmount, - currentRequiredBond: totalCost === 0n ? startBond : totalCost, - currentTime: block.timestamp, - escalationEndTime, - escalationGameAddress, - forkThreshold, - hasReachedNonDecision, - marketDetails, - nonDecisionThreshold, - parentSecurityPoolAddress, - questionOutcome: normalizedQuestionOutcome, - securityPoolAddress, - sides, - startBond, - status: 'active', - systemState, - settlementState, - activationTime, - totalCost, - universeId, - parentWithdrawalEnabled: settlementState === 'resolved', - ...viewerVaultState, - } -} async function getSecurityPoolVaultCount(client: ReadClient, securityPoolAddress: Address) { return await client.readContract({ abi: peripherals_SecurityPool_SecurityPool.abi, @@ -1832,140 +1184,6 @@ export async function loadForkAuctionDetails(client: ReadClient, securityPoolAdd } } -function mapTruthAuctionTickSummary(summary: TruthAuctionTickSummaryStruct): TruthAuctionTickSummary { - return { - tick: summary.tick, - price: summary.price, - currentTotalEth: summary.currentTotalEth, - submissionCount: summary.submissionCount, - active: summary.active, - } -} - -function mapTruthAuctionBidView(bid: TruthAuctionBidViewStruct): TruthAuctionBidView { - return { - tick: bid.tick, - bidIndex: bid.bidIndex, - bidder: bid.bidder, - ethAmount: bid.ethAmount, - cumulativeEth: bid.cumulativeEth, - activeCumulativeEthBeforeBid: bid.activeCumulativeEthBeforeBid, - claimed: bid.claimed, - refunded: bid.refunded, - } -} - -export async function loadTruthAuctionTickSummary(client: Pick, truthAuctionAddress: Address, tick: bigint): Promise { - const summary = (await client.readContract({ - abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, - functionName: 'getTickSummary', - address: truthAuctionAddress, - args: [tick], - })) as TruthAuctionTickSummaryStruct - return mapTruthAuctionTickSummary(summary) -} - -export async function loadTruthAuctionTickPage(client: Pick, truthAuctionAddress: Address, pageIndex: number, pageSize: number): Promise { - const offset = getTruthAuctionPageOffset(pageIndex, pageSize) - const tickCount = await client.readContract({ - abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, - functionName: 'getTickCount', - address: truthAuctionAddress, - args: [], - }) - const tickPage = (await client.readContract({ - abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, - functionName: 'getTickPage', - address: truthAuctionAddress, - args: [offset, BigInt(pageSize)], - })) as readonly TruthAuctionTickSummaryStruct[] - return { - pageIndex, - pageSize, - tickCount, - ticks: tickPage.map(summary => mapTruthAuctionTickSummary(summary)), - } -} - -export async function loadTruthAuctionActiveTickPage(client: Pick, truthAuctionAddress: Address, pageIndex: number, pageSize: number): Promise { - const offset = getTruthAuctionPageOffset(pageIndex, pageSize) - const tickCount = await client.readContract({ - abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, - functionName: 'activeTickCount', - address: truthAuctionAddress, - args: [], - }) - const tickPage = (await client.readContract({ - abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, - functionName: 'getActiveTickPage', - address: truthAuctionAddress, - args: [offset, BigInt(pageSize)], - })) as readonly TruthAuctionTickSummaryStruct[] - return { - pageIndex, - pageSize, - tickCount, - ticks: tickPage.map(summary => mapTruthAuctionTickSummary(summary)), - } -} - -export async function loadTruthAuctionTickBidPage(client: Pick, truthAuctionAddress: Address, tick: bigint, pageIndex: number, pageSize: number): Promise { - const offset = getTruthAuctionPageOffset(pageIndex, pageSize) - const bidCount = await client.readContract({ - abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, - functionName: 'getBidCountAtTick', - address: truthAuctionAddress, - args: [tick], - }) - const bidPage = (await client.readContract({ - abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, - functionName: 'getBidPageAtTick', - address: truthAuctionAddress, - args: [tick, offset, BigInt(pageSize)], - })) as readonly TruthAuctionBidViewStruct[] - return { - tick, - pageIndex, - pageSize, - bidCount, - bids: bidPage.map(bid => mapTruthAuctionBidView(bid)), - } -} - -export async function loadTruthAuctionBidderBidPage(client: Pick, truthAuctionAddress: Address, bidder: Address, pageIndex: number, pageSize: number): Promise { - const offset = getTruthAuctionPageOffset(pageIndex, pageSize) - const bidCount = await client.readContract({ - abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, - functionName: 'getBidderBidCount', - address: truthAuctionAddress, - args: [bidder], - }) - const bidPage = (await client.readContract({ - abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, - functionName: 'getBidderBidPage', - address: truthAuctionAddress, - args: [bidder, offset, BigInt(pageSize)], - })) as readonly TruthAuctionBidViewStruct[] - return { - bidder, - pageIndex, - pageSize, - bidCount, - bids: bidPage.map(bid => mapTruthAuctionBidView(bid)), - } -} - -async function executeForkAuctionAction(client: WriteClient, action: ForkAuctionAction, securityPoolAddress: Address, universeId: bigint, request: () => Promise) { - const hash = await request() - await client.waitForTransactionReceipt({ hash }) - return { - action, - hash, - securityPoolAddress, - universeId, - } satisfies ForkAuctionActionResult -} - export async function forkZoltarWithOwnEscalation(client: WriteClient, securityPoolAddress: Address, universeId: bigint) { return await executeForkAuctionAction( client, @@ -2559,122 +1777,3 @@ export async function redeemCompleteSetInSecurityPool(client: WriteClient, secur universeId, } satisfies TradingActionResult } -export async function reportOutcomeInSecurityPool(client: WriteClient, securityPoolAddress: Address, outcome: ReportingOutcomeKey, amount: bigint) { - const universeId = await readSecurityPoolUniverseId(client, securityPoolAddress) - const hash = await writeContractAndWait(client, () => ({ - address: securityPoolAddress, - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'depositToEscalationGame', - args: [getReportingOutcomeValue(outcome), amount], - })) - return { - action: 'reportOutcome', - hash, - outcome, - securityPoolAddress, - universeId, - } satisfies ReportingActionResult -} -export async function withdrawEscalationFromSecurityPool(client: WriteClient, securityPoolAddress: Address, outcome: ReportingOutcomeKey, depositIndexes: bigint[]) { - const universeId = await readSecurityPoolUniverseId(client, securityPoolAddress) - const hash = await writeContractAndWait(client, () => ({ - address: securityPoolAddress, - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'withdrawFromEscalationGame', - args: [getReportingOutcomeValue(outcome), depositIndexes], - })) - return { - action: 'withdrawEscalation', - hash, - outcome, - securityPoolAddress, - universeId, - } satisfies ReportingActionResult -} - -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, - 'settleForkedEscalation', - securityPoolAddress, - universeId, - async () => - await writeContractAndWait(client, () => ({ - address: securityPoolAddress, - abi: peripherals_SecurityPool_SecurityPool.abi, - functionName: 'withdrawForkedEscalationDeposits', - args: [ - getReportingOutcomeValue(outcome), - proofs.map(proof => ({ - ...proof, - merkleMountainRangeSiblings: Array.from(proof.merkleMountainRangeSiblings), - nullifierSiblings: Array.from(proof.nullifierSiblings), - })), - ], - })), - ) -} diff --git a/ui/ts/contracts/decoders.ts b/ui/ts/contracts/decoders.ts new file mode 100644 index 000000000..c9b50dbc1 --- /dev/null +++ b/ui/ts/contracts/decoders.ts @@ -0,0 +1,33 @@ +import { isAddress, type Address } from 'viem' + +export function requireArrayValue(value: unknown, context: string): unknown[] { + if (Array.isArray(value)) return value + throw new Error(`Unexpected ${context} response`) +} + +export function requireTupleValue(value: unknown, length: number, context: string): unknown[] { + const tuple = requireArrayValue(value, context) + if (tuple.length === length) return tuple + throw new Error(`Unexpected ${context} response`) +} + +export function requireBigintValue(value: unknown, context: string) { + if (typeof value === 'bigint') return value + throw new Error(`Unexpected ${context} response`) +} + +export function requireIntegerLikeValue(value: unknown, context: string) { + if (typeof value === 'bigint') return value + if (typeof value === 'number' && Number.isInteger(value)) return value + throw new Error(`Unexpected ${context} response`) +} + +export function requireAddressValue(value: unknown, context: string): Address { + if (typeof value === 'string' && isAddress(value)) return value + throw new Error(`Unexpected ${context} response`) +} + +export function requireObjectValue(value: unknown, context: string): object { + if (typeof value === 'object' && value !== null) return value + throw new Error(`Unexpected ${context} response`) +} diff --git a/ui/ts/contracts/reporting.ts b/ui/ts/contracts/reporting.ts new file mode 100644 index 000000000..f10da19f6 --- /dev/null +++ b/ui/ts/contracts/reporting.ts @@ -0,0 +1,814 @@ +import { concatHex, encodeAbiParameters, keccak256, parseAbiItem, parseAbiParameters, zeroAddress, type Address, type ContractFunctionParameters, type Hex } from 'viem' +import { Zoltar_Zoltar, peripherals_EscalationGame_EscalationGame, peripherals_SecurityPool_SecurityPool } from '../contractArtifact.js' +import { sameAddress } from '../lib/address.js' +import { isIgnorableLogDecodeError } from '../lib/errors.js' +import type { CarriedDepositProof, EscalationDeposit, EscalationSide, ImportedEscalationDeposit, ReadClient, ReportingActionResult, ReportingDetails, ReportingOutcomeKey, ReportingSettlementState, WriteClient } from '../types/contracts.js' +import { readRequiredMulticall, writeContractAndWait } from './core.js' +import { requireAddressValue, requireArrayValue, requireBigintValue, requireIntegerLikeValue, requireObjectValue, requireTupleValue } from './decoders.js' +import { getInfraContractAddresses } from './deploymentHelpers.js' +import { getEscalationSideLabel, getReportingOutcomeKey, getReportingOutcomeValue, getSecurityPoolSystemState, hasTimestamp, requireSecurityVaultTupleArray } from './helpers.js' +import { executeForkAuctionAction, readSecurityPoolUniverseId } from './securityPoolActions.js' +import { loadMarketDetails } from './zoltar.js' + +const MIGRATION_TIME_LENGTH = 4838400n +const QUESTION_OUTCOME_ABI = [parseAbiItem('function getQuestionOutcome(address securityPool) view returns (uint8 outcome)')] +const CONTRACT_PAGE_SIZE = 30n +const NULLIFIER_DEPTH = 64 +const CARRY_LEAF_ABI = parseAbiParameters('address depositor, uint8 outcome, uint256 amount, uint256 parentDepositIndex, uint256 cumulativeAmount, uint256 sourceNodeId') + +type ReportingBootstrapReadResult = { + questionId: bigint + escalationGameAddress: Address + completeSetCollateralAmount: bigint + universeId: bigint + zoltarAddress: Address + initialEscalationGameDeposit: bigint + systemStateValue: bigint | number + questionOutcomeValue: bigint | number + parentSecurityPoolAddress: Address +} + +type CarryLeafViewStruct = { + cumulativeAmount: bigint + depositor: Address + parentDepositIndex: bigint + amount: bigint + sourceNodeId: bigint +} + +type EscalationDepositViewStruct = { + amount: bigint + cumulativeAmount: bigint + depositor: Address +} + +function requireReportingBootstrapReadResult(value: unknown): ReportingBootstrapReadResult { + const [questionId, escalationGameAddress, completeSetCollateralAmount, universeId, zoltarAddress, initialEscalationGameDeposit, systemStateValue, questionOutcomeValue, parentSecurityPoolAddress] = requireTupleValue(value, 9, 'reporting bootstrap') + return { + questionId: requireBigintValue(questionId, 'reporting question id'), + escalationGameAddress: requireAddressValue(escalationGameAddress, 'reporting escalation game address'), + completeSetCollateralAmount: requireBigintValue(completeSetCollateralAmount, 'reporting complete set collateral amount'), + universeId: requireBigintValue(universeId, 'reporting universe id'), + zoltarAddress: requireAddressValue(zoltarAddress, 'reporting zoltar address'), + initialEscalationGameDeposit: requireBigintValue(initialEscalationGameDeposit, 'reporting initial escalation game deposit'), + systemStateValue: requireIntegerLikeValue(systemStateValue, 'reporting system state'), + questionOutcomeValue: requireIntegerLikeValue(questionOutcomeValue, 'reporting question outcome'), + parentSecurityPoolAddress: requireAddressValue(parentSecurityPoolAddress, 'reporting parent security pool address'), + } +} + +function requireEscalationDepositView(value: unknown, context: string): EscalationDepositViewStruct { + const deposit = requireObjectValue(value, context) + if ('amount' in deposit && 'cumulativeAmount' in deposit && 'depositor' in deposit) { + return { + amount: requireBigintValue(deposit.amount, context), + cumulativeAmount: requireBigintValue(deposit.cumulativeAmount, context), + depositor: requireAddressValue(deposit.depositor, context), + } + } + throw new Error(`Unexpected ${context} response`) +} + +function requireEscalationDepositArray(value: unknown, context: string): EscalationDepositViewStruct[] { + return requireArrayValue(value, context).map(deposit => requireEscalationDepositView(deposit, context)) +} + +export async function loadEscalationDeposits(client: Pick, escalationGameAddress: Address, outcome: ReportingOutcomeKey): Promise { + let currentIndex = 0n + const deposits: EscalationDeposit[] = [] + while (true) { + const page = requireEscalationDepositArray( + await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'getDepositsByOutcome', + args: [getReportingOutcomeValue(outcome), currentIndex, CONTRACT_PAGE_SIZE], + }), + 'escalation deposit page', + ) + const normalizedPage = page + .map((deposit, index) => ({ + amount: deposit.amount, + cumulativeAmount: deposit.cumulativeAmount, + depositIndex: currentIndex + BigInt(index), + depositor: deposit.depositor, + })) + .filter(deposit => deposit.depositor !== zeroAddress && deposit.amount > 0n) + deposits.push(...normalizedPage) + if (BigInt(page.length) !== CONTRACT_PAGE_SIZE) break + currentIndex += CONTRACT_PAGE_SIZE + } + return deposits +} + +function requireCarryLeafView(value: unknown, context: string): CarryLeafViewStruct { + const leaf = requireObjectValue(value, context) + if ('amount' in leaf && 'cumulativeAmount' in leaf && 'depositor' in leaf && 'parentDepositIndex' in leaf && 'sourceNodeId' in leaf) { + return { + cumulativeAmount: requireBigintValue(leaf.cumulativeAmount, context), + depositor: requireAddressValue(leaf.depositor, context), + parentDepositIndex: requireBigintValue(leaf.parentDepositIndex, context), + amount: requireBigintValue(leaf.amount, context), + sourceNodeId: requireBigintValue(leaf.sourceNodeId, context), + } + } + throw new Error(`Unexpected ${context} response`) +} + +function requireCarryLeafPageResponse(value: unknown): { + page: CarryLeafViewStruct[] + nextNodeId: bigint +} { + const [page, nextNodeId] = requireTupleValue(value, 2, 'carry leaf page') + return { + page: requireArrayValue(page, 'carry leaf page').map(leaf => requireCarryLeafView(leaf, 'carry leaf page')), + nextNodeId: requireBigintValue(nextNodeId, 'carry leaf page'), + } +} + +async function loadCarryLeafPage(client: Pick, escalationGameAddress: Address, outcome: ReportingOutcomeKey) { + let startNodeId = 0n + const carryLeaves: CarryLeafViewStruct[] = [] + while (true) { + const { page, nextNodeId } = requireCarryLeafPageResponse( + await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'getCarryLeafPageByOutcome', + args: [getReportingOutcomeValue(outcome), startNodeId, CONTRACT_PAGE_SIZE], + }), + ) + carryLeaves.push(...page) + if (nextNodeId === 0n) break + startNodeId = nextNodeId + } + return carryLeaves +} + +async function loadProofConsumedCarriedDepositIndexes(client: Pick, escalationGameAddress: Address, outcome: ReportingOutcomeKey) { + let startIndex = 0n + const parentDepositIndexes: bigint[] = [] + while (true) { + const page = requireArrayValue( + await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + address: escalationGameAddress, + functionName: 'getProofConsumedCarriedDepositIndexesByOutcome', + args: [getReportingOutcomeValue(outcome), startIndex, CONTRACT_PAGE_SIZE], + }), + 'consumed carried deposit index page', + ).map(item => requireBigintValue(item, 'consumed carried deposit index page')) + parentDepositIndexes.push(...page) + if (BigInt(page.length) !== CONTRACT_PAGE_SIZE) break + startIndex += CONTRACT_PAGE_SIZE + } + 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) + return { + viewerVaultAvailableEscalationRep: undefined, + viewerVaultExists: false, + viewerVaultEscrowedRep: undefined, + viewerVaultRepDepositShare: undefined, + } + const viewerVaultTuple = await client.readContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'securityVaults', + address: securityPoolAddress, + args: [accountAddress], + }) + const viewerVaultTuples = requireSecurityVaultTupleArray([viewerVaultTuple], 'viewer security vault tuple') + const [viewerPoolOwnership, viewerSecurityBondAllowance, viewerUnpaidEthFees, viewerFeeIndex] = viewerVaultTuples[0] ?? [] + if (typeof viewerPoolOwnership !== 'bigint' || typeof viewerSecurityBondAllowance !== 'bigint' || typeof viewerUnpaidEthFees !== 'bigint' || typeof viewerFeeIndex !== 'bigint') throw new Error('Unexpected viewer security vault tuple response') + const viewerVaultRepDepositShare = + viewerPoolOwnership === 0n + ? 0n + : await client.readContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'poolOwnershipToRep', + address: securityPoolAddress, + args: [viewerPoolOwnership], + }) + const escalationGameAddress = await client.readContract({ + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'escalationGame', + address: securityPoolAddress, + args: [], + }) + const viewerVaultEscrowedRep = sameAddress(escalationGameAddress, zeroAddress) + ? 0n + : await client.readContract({ + abi: peripherals_EscalationGame_EscalationGame.abi, + functionName: 'escrowedRepByVault', + address: escalationGameAddress, + args: [accountAddress], + }) + const viewerVaultExists = viewerPoolOwnership !== 0n || viewerSecurityBondAllowance !== 0n || viewerUnpaidEthFees !== 0n || viewerFeeIndex !== 0n || viewerVaultEscrowedRep !== 0n + const viewerVaultAvailableEscalationRep = viewerVaultRepDepositShare + return { + viewerVaultAvailableEscalationRep, + viewerVaultExists, + viewerVaultEscrowedRep, + viewerVaultRepDepositShare, + } +} + +export async function loadReportingDetails(client: ReadClient, securityPoolAddress: Address, accountAddress: Address | undefined): Promise { + const reportingPoolReads: readonly ContractFunctionParameters[] = [ + { + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'questionId', + address: securityPoolAddress, + args: [], + }, + { + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'escalationGame', + address: securityPoolAddress, + args: [], + }, + { + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'completeSetCollateralAmount', + address: securityPoolAddress, + args: [], + }, + { + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'universeId', + address: securityPoolAddress, + args: [], + }, + { + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'zoltar', + address: securityPoolAddress, + args: [], + }, + { + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'initialEscalationGameDeposit', + address: securityPoolAddress, + args: [], + }, + { + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'systemState', + address: securityPoolAddress, + args: [], + }, + { + abi: QUESTION_OUTCOME_ABI, + functionName: 'getQuestionOutcome', + address: getInfraContractAddresses().securityPoolForker, + args: [securityPoolAddress], + }, + { + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'parent', + address: securityPoolAddress, + args: [], + }, + ] + const { questionId, escalationGameAddress, completeSetCollateralAmount, universeId, zoltarAddress, initialEscalationGameDeposit, systemStateValue, questionOutcomeValue, parentSecurityPoolAddress } = requireReportingBootstrapReadResult(await readRequiredMulticall(client, reportingPoolReads)) + const systemState = getSecurityPoolSystemState(systemStateValue) + const normalizedQuestionOutcome = getReportingOutcomeKey(questionOutcomeValue) + 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 }), + loadViewerReportingVaultState(client, securityPoolAddress, accountAddress), + client.readContract({ + abi: Zoltar_Zoltar.abi, + address: zoltarAddress, + 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') + return { + completeSetCollateralAmount, + currentTime: block.timestamp, + forkThreshold, + marketDetails, + nonDecisionThreshold: forkThreshold / 2n, + parentSecurityPoolAddress, + questionOutcome: normalizedQuestionOutcome, + securityPoolAddress, + settlementState: normalizedQuestionOutcome !== 'none' && systemState === 'operational' ? 'resolved' : 'locked', + startBond: initialEscalationGameDeposit, + status: 'not-started', + systemState, + universeId, + parentWithdrawalEnabled: false, + ...viewerVaultState, + } + 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 || 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: 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') { + settlementState = 'resolved' + } else if (universeForkTime > 0n && universeForkTime < escalationEndTime && hasReachedNonDecision === false) { + settlementState = block.timestamp <= universeForkTime + MIGRATION_TIME_LENGTH ? 'migration-required' : 'migration-expired' + } + return { + bindingCapital, + completeSetCollateralAmount, + currentRequiredBond: totalCost === 0n ? startBond : totalCost, + currentTime: block.timestamp, + escalationEndTime, + escalationGameAddress, + forkThreshold, + hasReachedNonDecision, + marketDetails, + nonDecisionThreshold, + parentSecurityPoolAddress, + questionOutcome: normalizedQuestionOutcome, + securityPoolAddress, + sides, + startBond, + status: 'active', + systemState, + settlementState, + activationTime, + totalCost, + universeId, + parentWithdrawalEnabled: settlementState === 'resolved', + ...viewerVaultState, + } +} + +export async function reportOutcomeInSecurityPool(client: WriteClient, securityPoolAddress: Address, outcome: ReportingOutcomeKey, amount: bigint) { + const universeId = await readSecurityPoolUniverseId(client, securityPoolAddress) + const hash = await writeContractAndWait(client, () => ({ + address: securityPoolAddress, + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'depositToEscalationGame', + args: [getReportingOutcomeValue(outcome), amount], + })) + return { + action: 'reportOutcome', + hash, + outcome, + securityPoolAddress, + universeId, + } satisfies ReportingActionResult +} + +export async function withdrawEscalationFromSecurityPool(client: WriteClient, securityPoolAddress: Address, outcome: ReportingOutcomeKey, depositIndexes: bigint[]) { + const universeId = await readSecurityPoolUniverseId(client, securityPoolAddress) + const hash = await writeContractAndWait(client, () => ({ + address: securityPoolAddress, + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'withdrawFromEscalationGame', + args: [getReportingOutcomeValue(outcome), depositIndexes], + })) + return { + action: 'withdrawEscalation', + hash, + outcome, + securityPoolAddress, + universeId, + } satisfies ReportingActionResult +} + +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, + 'settleForkedEscalation', + securityPoolAddress, + universeId, + async () => + await writeContractAndWait(client, () => ({ + address: securityPoolAddress, + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'withdrawForkedEscalationDeposits', + args: [ + getReportingOutcomeValue(outcome), + proofs.map(proof => ({ + ...proof, + merkleMountainRangeSiblings: Array.from(proof.merkleMountainRangeSiblings), + nullifierSiblings: Array.from(proof.nullifierSiblings), + })), + ], + })), + ) +} diff --git a/ui/ts/contracts/securityPoolActions.ts b/ui/ts/contracts/securityPoolActions.ts new file mode 100644 index 000000000..8ce598a9d --- /dev/null +++ b/ui/ts/contracts/securityPoolActions.ts @@ -0,0 +1,23 @@ +import type { Address, Hash } from 'viem' +import { peripherals_SecurityPool_SecurityPool } from '../contractArtifact.js' +import type { ForkAuctionAction, ForkAuctionActionResult, ReadClient, WriteClient } from '../types/contracts.js' + +export async function readSecurityPoolUniverseId(client: Pick, securityPoolAddress: Address) { + return await client.readContract({ + address: securityPoolAddress, + abi: peripherals_SecurityPool_SecurityPool.abi, + functionName: 'universeId', + args: [], + }) +} + +export async function executeForkAuctionAction(client: WriteClient, action: ForkAuctionAction, securityPoolAddress: Address, universeId: bigint, request: () => Promise) { + const hash = await request() + await client.waitForTransactionReceipt({ hash }) + return { + action, + hash, + securityPoolAddress, + universeId, + } satisfies ForkAuctionActionResult +} diff --git a/ui/ts/contracts/truthAuctions.ts b/ui/ts/contracts/truthAuctions.ts new file mode 100644 index 000000000..a4e01dfa1 --- /dev/null +++ b/ui/ts/contracts/truthAuctions.ts @@ -0,0 +1,161 @@ +import type { Address } from 'viem' +import { peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction } from '../contractArtifact.js' +import type { ReadClient, TruthAuctionBidView, TruthAuctionBidderBidPage, TruthAuctionTickBidPage, TruthAuctionTickPage, TruthAuctionTickSummary } from '../types/contracts.js' +import { requireAddressValue, requireArrayValue, requireBigintValue, requireObjectValue } from './decoders.js' + +function getTruthAuctionPageOffset(pageIndex: number, pageSize: number) { + if (!Number.isInteger(pageIndex) || pageIndex < 0) throw new Error('Page index must be a non-negative integer') + if (!Number.isInteger(pageSize) || pageSize <= 0) throw new Error('Page size must be a positive integer') + return BigInt(pageIndex) * BigInt(pageSize) +} + +function requireTruthAuctionTickSummary(value: unknown, context: string): TruthAuctionTickSummary { + const summary = requireObjectValue(value, context) + if ('tick' in summary && 'price' in summary && 'currentTotalEth' in summary && 'submissionCount' in summary && 'active' in summary && typeof summary.active === 'boolean') { + return { + tick: requireBigintValue(summary.tick, context), + price: requireBigintValue(summary.price, context), + currentTotalEth: requireBigintValue(summary.currentTotalEth, context), + submissionCount: requireBigintValue(summary.submissionCount, context), + active: summary.active, + } + } + throw new Error(`Unexpected ${context} response`) +} + +function requireTruthAuctionTickSummaryArray(value: unknown, context: string): TruthAuctionTickSummary[] { + return requireArrayValue(value, context).map(summary => requireTruthAuctionTickSummary(summary, context)) +} + +function requireTruthAuctionBidView(value: unknown, context: string): TruthAuctionBidView { + const bid = requireObjectValue(value, context) + if ('tick' in bid && 'bidIndex' in bid && 'bidder' in bid && 'ethAmount' in bid && 'cumulativeEth' in bid && 'activeCumulativeEthBeforeBid' in bid && 'claimed' in bid && typeof bid.claimed === 'boolean' && 'refunded' in bid && typeof bid.refunded === 'boolean') { + return { + tick: requireBigintValue(bid.tick, context), + bidIndex: requireBigintValue(bid.bidIndex, context), + bidder: requireAddressValue(bid.bidder, context), + ethAmount: requireBigintValue(bid.ethAmount, context), + cumulativeEth: requireBigintValue(bid.cumulativeEth, context), + activeCumulativeEthBeforeBid: requireBigintValue(bid.activeCumulativeEthBeforeBid, context), + claimed: bid.claimed, + refunded: bid.refunded, + } + } + throw new Error(`Unexpected ${context} response`) +} + +function requireTruthAuctionBidViewArray(value: unknown, context: string): TruthAuctionBidView[] { + return requireArrayValue(value, context).map(bid => requireTruthAuctionBidView(bid, context)) +} + +export async function loadTruthAuctionTickSummary(client: Pick, truthAuctionAddress: Address, tick: bigint): Promise { + const summary = await client.readContract({ + abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, + functionName: 'getTickSummary', + address: truthAuctionAddress, + args: [tick], + }) + return requireTruthAuctionTickSummary(summary, 'truth auction tick summary') +} + +export async function loadTruthAuctionTickPage(client: Pick, truthAuctionAddress: Address, pageIndex: number, pageSize: number): Promise { + const offset = getTruthAuctionPageOffset(pageIndex, pageSize) + const tickCount = await client.readContract({ + abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, + functionName: 'getTickCount', + address: truthAuctionAddress, + args: [], + }) + const tickPage = requireTruthAuctionTickSummaryArray( + await client.readContract({ + abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, + functionName: 'getTickPage', + address: truthAuctionAddress, + args: [offset, BigInt(pageSize)], + }), + 'truth auction tick page', + ) + return { + pageIndex, + pageSize, + tickCount, + ticks: tickPage, + } +} + +export async function loadTruthAuctionActiveTickPage(client: Pick, truthAuctionAddress: Address, pageIndex: number, pageSize: number): Promise { + const offset = getTruthAuctionPageOffset(pageIndex, pageSize) + const tickCount = await client.readContract({ + abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, + functionName: 'activeTickCount', + address: truthAuctionAddress, + args: [], + }) + const tickPage = requireTruthAuctionTickSummaryArray( + await client.readContract({ + abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, + functionName: 'getActiveTickPage', + address: truthAuctionAddress, + args: [offset, BigInt(pageSize)], + }), + 'truth auction active tick page', + ) + return { + pageIndex, + pageSize, + tickCount, + ticks: tickPage, + } +} + +export async function loadTruthAuctionTickBidPage(client: Pick, truthAuctionAddress: Address, tick: bigint, pageIndex: number, pageSize: number): Promise { + const offset = getTruthAuctionPageOffset(pageIndex, pageSize) + const bidCount = await client.readContract({ + abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, + functionName: 'getBidCountAtTick', + address: truthAuctionAddress, + args: [tick], + }) + const bidPage = requireTruthAuctionBidViewArray( + await client.readContract({ + abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, + functionName: 'getBidPageAtTick', + address: truthAuctionAddress, + args: [tick, offset, BigInt(pageSize)], + }), + 'truth auction tick bid page', + ) + return { + tick, + pageIndex, + pageSize, + bidCount, + bids: bidPage, + } +} + +export async function loadTruthAuctionBidderBidPage(client: Pick, truthAuctionAddress: Address, bidder: Address, pageIndex: number, pageSize: number): Promise { + const offset = getTruthAuctionPageOffset(pageIndex, pageSize) + const bidCount = await client.readContract({ + abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, + functionName: 'getBidderBidCount', + address: truthAuctionAddress, + args: [bidder], + }) + const bidPage = requireTruthAuctionBidViewArray( + await client.readContract({ + abi: peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction.abi, + functionName: 'getBidderBidPage', + address: truthAuctionAddress, + args: [bidder, offset, BigInt(pageSize)], + }), + 'truth auction bidder bid page', + ) + return { + bidder, + pageIndex, + pageSize, + bidCount, + bids: bidPage, + } +} diff --git a/ui/ts/tests/contracts.test.ts b/ui/ts/tests/contracts.test.ts index 95e57339e..9e7835ef2 100644 --- a/ui/ts/tests/contracts.test.ts +++ b/ui/ts/tests/contracts.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from 'bun:test' import { decodeFunctionData, getAddress, zeroAddress, type Address, type Hash, type Hex, type TransactionReceipt } from 'viem' import { + buildForkCarriedEscalationProofs, getOpenOracleAddress, loadAllSecurityPools, loadEscalationDeposits, @@ -893,6 +894,45 @@ describe('contracts helpers', () => { expect(deposits[29]?.depositIndex).toBe(30n) }) + test('loadEscalationDeposits rejects malformed deposit pages instead of dropping entries', async () => { + const client = createMockReadClient(async request => { + if (request.functionName === 'getDepositsByOutcome') { + return [{ amount: 1n, cumulativeAmount: 1n, depositor: 'not-an-address' }] + } + throw new Error(`Unexpected readContract function: ${request.functionName}`) + }) + + await expect(loadEscalationDeposits(client, escalationGameAddress, 'yes')).rejects.toThrow('Unexpected escalation deposit page response') + }) + + test('buildForkCarriedEscalationProofs rejects malformed carry leaf pages instead of dropping entries', async () => { + const parentEscalationGameAddress = getAddress('0x00000000000000000000000000000000000000f1') + const zeroHash = '0x0000000000000000000000000000000000000000000000000000000000000000' satisfies Hex + const client = { + multicall: createMulticallStub(async request => { + const firstContract = request.contracts[0] + const functionName = getContractFunctionName(firstContract) + if (functionName === 'parent') return [alternateSecurityPoolAddress, escalationGameAddress] + throw new Error(`Unexpected multicall contract: ${functionName}`) + }), + readContract: createReadContractStub(async request => { + if (request.functionName === 'escalationGame') return parentEscalationGameAddress + if (request.functionName === 'getOutcomeState') + return { + currentCarryRoot: zeroHash, + currentLeafCount: 0n, + currentNullifierRoot: zeroHash, + } + if (request.functionName === 'forkContinuation') return false + if (request.functionName === 'getCarryLeafPageByOutcome') return [[{ amount: 1n, cumulativeAmount: 1n, depositor: vaultAddress, parentDepositIndex: 'bad-index', sourceNodeId: 1n }], 0n] + if (request.functionName === 'getProofConsumedCarriedDepositIndexesByOutcome') return [] + throw new Error(`Unexpected readContract function: ${request.functionName}`) + }), + } as unknown as Parameters[0] + + await expect(buildForkCarriedEscalationProofs(client, securityPoolAddress, 'yes', [1n])).rejects.toThrow('Unexpected carry leaf page response') + }) + test('migrateVaultWithUnresolvedEscalation helper encodes the selected child outcome correctly', async () => { let capturedData: Hex | undefined let capturedTo: Address | null | undefined @@ -1061,6 +1101,16 @@ describe('contracts helpers', () => { }) }) + test('loadTruthAuctionTickPage rejects malformed tick summary pages instead of trusting ABI shapes', async () => { + const client = createMockReadClient(async request => { + if (request.functionName === 'getTickCount') return 1n + if (request.functionName === 'getTickPage') return [{ tick: 1n, price: 2n, currentTotalEth: 3n, submissionCount: 'bad-count', active: true }] + throw new Error(`Unexpected readContract function: ${request.functionName}`) + }) + + await expect(loadTruthAuctionTickPage(client, truthAuctionAddress, 0, 10)).rejects.toThrow('Unexpected truth auction tick page response') + }) + test('loadTruthAuctionActiveTickPage maps active ladder pages and converts page indexes to offsets', async () => { const readCalls: Array<{ functionName: string; args: unknown[] | undefined }> = [] const client = createMockReadClient(async request => { @@ -1121,6 +1171,16 @@ describe('contracts helpers', () => { }) }) + test('loadTruthAuctionBidderBidPage rejects malformed bid pages instead of trusting ABI shapes', async () => { + const client = createMockReadClient(async request => { + if (request.functionName === 'getBidderBidCount') return 1n + if (request.functionName === 'getBidderBidPage') return [{ tick: 10n, bidIndex: 0n, bidder: 'not-an-address', ethAmount: 3n, cumulativeEth: 3n, activeCumulativeEthBeforeBid: 0n, claimed: false, refunded: false }] + throw new Error(`Unexpected readContract function: ${request.functionName}`) + }) + + await expect(loadTruthAuctionBidderBidPage(client, truthAuctionAddress, securityPoolAddress, 0, 10)).rejects.toThrow('Unexpected truth auction bidder bid page response') + }) + test('loadTruthAuctionBidderBidPage maps bidder bid tuples and converts bidder pages to offsets', async () => { const readCalls: Array<{ functionName: string; args: unknown[] | undefined }> = [] const bidder = getAddress('0x00000000000000000000000000000000000000a7') From 75d7e184a9a2a4dc5475c015b01aadc8b9b92267 Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Tue, 23 Jun 2026 07:40:47 +0000 Subject: [PATCH 2/4] Add test for malformed per-tick truth auction bid page rejection - Add regression test ensuring `loadTruthAuctionTickBidPage` throws on invalid ABI-shaped bid page data - Prevent trusting unchecked fields like malformed `refunded` in `getBidPageAtTick` responses --- ui/ts/tests/contracts.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ui/ts/tests/contracts.test.ts b/ui/ts/tests/contracts.test.ts index 9e7835ef2..8f9621520 100644 --- a/ui/ts/tests/contracts.test.ts +++ b/ui/ts/tests/contracts.test.ts @@ -1171,6 +1171,16 @@ describe('contracts helpers', () => { }) }) + test('loadTruthAuctionTickBidPage rejects malformed per-tick bid pages instead of trusting ABI shapes', async () => { + const client = createMockReadClient(async request => { + if (request.functionName === 'getBidCountAtTick') return 1n + if (request.functionName === 'getBidPageAtTick') return [{ tick: 11n, bidIndex: 0n, bidder: securityPoolAddress, ethAmount: 3n, cumulativeEth: 3n, activeCumulativeEthBeforeBid: 0n, claimed: false, refunded: 'bad-refund-flag' }] + throw new Error(`Unexpected readContract function: ${request.functionName}`) + }) + + await expect(loadTruthAuctionTickBidPage(client, truthAuctionAddress, 11n, 0, 10)).rejects.toThrow('Unexpected truth auction tick bid page response') + }) + test('loadTruthAuctionBidderBidPage rejects malformed bid pages instead of trusting ABI shapes', async () => { const client = createMockReadClient(async request => { if (request.functionName === 'getBidderBidCount') return 1n From afe4727342badd642e79ef0167761c3b71d04677 Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:04:20 +0000 Subject: [PATCH 3/4] Tighten contract ABI decoders --- ui/ts/contracts/decoders.ts | 9 +++-- ui/ts/contracts/securityPools.ts | 25 ++++++++++++-- ui/ts/contracts/truthAuctions.ts | 32 +++++++++--------- ui/ts/tests/contracts.test.ts | 57 +++++++++++++++++++++++++++----- 4 files changed, 95 insertions(+), 28 deletions(-) diff --git a/ui/ts/contracts/decoders.ts b/ui/ts/contracts/decoders.ts index c9b50dbc1..421c6776f 100644 --- a/ui/ts/contracts/decoders.ts +++ b/ui/ts/contracts/decoders.ts @@ -1,11 +1,11 @@ import { isAddress, type Address } from 'viem' -export function requireArrayValue(value: unknown, context: string): unknown[] { +export function requireArrayValue(value: unknown, context: string): readonly unknown[] { if (Array.isArray(value)) return value throw new Error(`Unexpected ${context} response`) } -export function requireTupleValue(value: unknown, length: number, context: string): unknown[] { +export function requireTupleValue(value: unknown, length: number, context: string): readonly unknown[] { const tuple = requireArrayValue(value, context) if (tuple.length === length) return tuple throw new Error(`Unexpected ${context} response`) @@ -22,6 +22,11 @@ export function requireIntegerLikeValue(value: unknown, context: string) { throw new Error(`Unexpected ${context} response`) } +export function requireBooleanValue(value: unknown, context: string) { + if (typeof value === 'boolean') return value + throw new Error(`Unexpected ${context} response`) +} + export function requireAddressValue(value: unknown, context: string): Address { if (typeof value === 'string' && isAddress(value)) return value throw new Error(`Unexpected ${context} response`) diff --git a/ui/ts/contracts/securityPools.ts b/ui/ts/contracts/securityPools.ts index f295550d0..4cafad691 100644 --- a/ui/ts/contracts/securityPools.ts +++ b/ui/ts/contracts/securityPools.ts @@ -13,6 +13,7 @@ import { deriveHasForkActivity } from '../lib/forkAuction.js' import { sameAddress } from '../lib/address.js' import type { ListedSecurityPool, SecurityPoolCreationResult, SecurityPoolPage, SecurityVaultDetails, WriteClient, ReadClient } from '../types/contracts.js' import { readRequiredMulticall, writeContractAndWaitForReceipt } from './core.js' +import { requireAddressValue, requireBigintValue, requireBooleanValue, requireTupleValue } from './decoders.js' import { getForkOutcomeKey, getQuestionIdHex, getReportingOutcomeKey, getSecurityPoolSystemState, requireSecurityVaultTupleArray } from './helpers.js' import { getDeploymentSteps } from './deployment.js' import { getInfraContractAddresses, getZoltarAddress } from './deploymentHelpers.js' @@ -22,7 +23,6 @@ const QUESTION_OUTCOME_ABI = [parseAbiItem('function getQuestionOutcome(address const ACTIVE_SECURITY_POOL_VAULT_PREVIEW_LIMIT = 3n -type ForkDataTuple = readonly [bigint, Address, bigint, bigint, bigint, bigint, bigint, bigint, boolean, boolean, bigint] type SecurityPoolDeploymentQueryResult = { parent: Address priceOracleManagerAndOperatorQueuer: Address @@ -33,6 +33,27 @@ type SecurityPoolDeploymentQueryResult = { universeId: bigint } +function requireForkDataView(value: unknown) { + const [auctionableRepAtFork, truthAuctionAddress, truthAuctionStartedAt, migratedRep, auctionedSecurityBondAllowance, escalationElapsedAtFork, escalationStartBondAtFork, escalationNonDecisionThresholdAtFork, forkOwnSecurityPool, unresolvedEscalationAtFork, forkOutcomeIndex] = requireTupleValue( + value, + 11, + 'security pool fork data', + ) + return { + auctionableRepAtFork: requireBigintValue(auctionableRepAtFork, 'security pool fork data auctionable REP at fork'), + truthAuctionAddress: requireAddressValue(truthAuctionAddress, 'security pool fork data truth auction address'), + truthAuctionStartedAt: requireBigintValue(truthAuctionStartedAt, 'security pool fork data truth auction start time'), + migratedRep: requireBigintValue(migratedRep, 'security pool fork data migrated REP'), + auctionedSecurityBondAllowance: requireBigintValue(auctionedSecurityBondAllowance, 'security pool fork data auctioned security bond allowance'), + escalationElapsedAtFork: requireBigintValue(escalationElapsedAtFork, 'security pool fork data escalation elapsed at fork'), + escalationStartBondAtFork: requireBigintValue(escalationStartBondAtFork, 'security pool fork data escalation start bond at fork'), + escalationNonDecisionThresholdAtFork: requireBigintValue(escalationNonDecisionThresholdAtFork, 'security pool fork data escalation non-decision threshold at fork'), + forkOwnSecurityPool: requireBooleanValue(forkOwnSecurityPool, 'security pool fork data own-pool flag'), + unresolvedEscalationAtFork: requireBooleanValue(unresolvedEscalationAtFork, 'security pool fork data unresolved escalation flag'), + forkOutcomeIndex: requireBigintValue(forkOutcomeIndex, 'security pool fork data fork outcome index'), + } +} + function getDeploymentStepAddress(id: 'securityPoolFactory' | 'zoltarQuestionData') { const step = getDeploymentSteps().find(candidate => candidate.id === id) if (step === undefined) throw new Error(`Unknown deployment step: ${id}`) @@ -329,7 +350,7 @@ export async function loadSecurityPoolPage(client: ReadClient, pageIndex: number previewLimit: ACTIVE_SECURITY_POOL_VAULT_PREVIEW_LIMIT, }), ]) - const [, , truthAuctionStartedAt, migratedRep, , , , , forkOwnSecurityPool, , forkOutcomeIndex] = forkData as ForkDataTuple + const { truthAuctionStartedAt, migratedRep, forkOwnSecurityPool, forkOutcomeIndex } = requireForkDataView(forkData) const forkOutcome = getForkOutcomeKey(forkOutcomeIndex, parent) const systemState = getSecurityPoolSystemState(systemStateValue) const hasForkActivity = deriveHasForkActivity({ diff --git a/ui/ts/contracts/truthAuctions.ts b/ui/ts/contracts/truthAuctions.ts index a4e01dfa1..bf2e54dbc 100644 --- a/ui/ts/contracts/truthAuctions.ts +++ b/ui/ts/contracts/truthAuctions.ts @@ -1,7 +1,7 @@ import type { Address } from 'viem' import { peripherals_UniformPriceDualCapBatchAuction_UniformPriceDualCapBatchAuction } from '../contractArtifact.js' import type { ReadClient, TruthAuctionBidView, TruthAuctionBidderBidPage, TruthAuctionTickBidPage, TruthAuctionTickPage, TruthAuctionTickSummary } from '../types/contracts.js' -import { requireAddressValue, requireArrayValue, requireBigintValue, requireObjectValue } from './decoders.js' +import { requireAddressValue, requireArrayValue, requireBigintValue, requireBooleanValue, requireObjectValue } from './decoders.js' function getTruthAuctionPageOffset(pageIndex: number, pageSize: number) { if (!Number.isInteger(pageIndex) || pageIndex < 0) throw new Error('Page index must be a non-negative integer') @@ -11,13 +11,13 @@ function getTruthAuctionPageOffset(pageIndex: number, pageSize: number) { function requireTruthAuctionTickSummary(value: unknown, context: string): TruthAuctionTickSummary { const summary = requireObjectValue(value, context) - if ('tick' in summary && 'price' in summary && 'currentTotalEth' in summary && 'submissionCount' in summary && 'active' in summary && typeof summary.active === 'boolean') { + if ('tick' in summary && 'price' in summary && 'currentTotalEth' in summary && 'submissionCount' in summary && 'active' in summary) { return { - tick: requireBigintValue(summary.tick, context), - price: requireBigintValue(summary.price, context), - currentTotalEth: requireBigintValue(summary.currentTotalEth, context), - submissionCount: requireBigintValue(summary.submissionCount, context), - active: summary.active, + tick: requireBigintValue(summary.tick, `${context} tick`), + price: requireBigintValue(summary.price, `${context} price`), + currentTotalEth: requireBigintValue(summary.currentTotalEth, `${context} current total ETH`), + submissionCount: requireBigintValue(summary.submissionCount, `${context} submission count`), + active: requireBooleanValue(summary.active, `${context} active flag`), } } throw new Error(`Unexpected ${context} response`) @@ -29,16 +29,16 @@ function requireTruthAuctionTickSummaryArray(value: unknown, context: string): T function requireTruthAuctionBidView(value: unknown, context: string): TruthAuctionBidView { const bid = requireObjectValue(value, context) - if ('tick' in bid && 'bidIndex' in bid && 'bidder' in bid && 'ethAmount' in bid && 'cumulativeEth' in bid && 'activeCumulativeEthBeforeBid' in bid && 'claimed' in bid && typeof bid.claimed === 'boolean' && 'refunded' in bid && typeof bid.refunded === 'boolean') { + if ('tick' in bid && 'bidIndex' in bid && 'bidder' in bid && 'ethAmount' in bid && 'cumulativeEth' in bid && 'activeCumulativeEthBeforeBid' in bid && 'claimed' in bid && 'refunded' in bid) { return { - tick: requireBigintValue(bid.tick, context), - bidIndex: requireBigintValue(bid.bidIndex, context), - bidder: requireAddressValue(bid.bidder, context), - ethAmount: requireBigintValue(bid.ethAmount, context), - cumulativeEth: requireBigintValue(bid.cumulativeEth, context), - activeCumulativeEthBeforeBid: requireBigintValue(bid.activeCumulativeEthBeforeBid, context), - claimed: bid.claimed, - refunded: bid.refunded, + tick: requireBigintValue(bid.tick, `${context} tick`), + bidIndex: requireBigintValue(bid.bidIndex, `${context} bid index`), + bidder: requireAddressValue(bid.bidder, `${context} bidder`), + ethAmount: requireBigintValue(bid.ethAmount, `${context} ETH amount`), + cumulativeEth: requireBigintValue(bid.cumulativeEth, `${context} cumulative ETH`), + activeCumulativeEthBeforeBid: requireBigintValue(bid.activeCumulativeEthBeforeBid, `${context} active cumulative ETH before bid`), + claimed: requireBooleanValue(bid.claimed, `${context} claimed flag`), + refunded: requireBooleanValue(bid.refunded, `${context} refunded flag`), } } throw new Error(`Unexpected ${context} response`) diff --git a/ui/ts/tests/contracts.test.ts b/ui/ts/tests/contracts.test.ts index 8f9621520..ae5851dac 100644 --- a/ui/ts/tests/contracts.test.ts +++ b/ui/ts/tests/contracts.test.ts @@ -37,6 +37,7 @@ const zoltarAddress = getAddress('0x00000000000000000000000000000000000000e7') const token1Address = getAddress('0x00000000000000000000000000000000000000d1') const token2Address = getAddress('0x00000000000000000000000000000000000000d2') const transactionHash = '0x00000000000000000000000000000000000000000000000000000000000000c3' satisfies Hash +const defaultForkData = [0n, zeroAddress, 0n, 0n, 0n, 0n, 0n, 0n, false, false, 0n] as const type MockReadClient = Parameters[0] type MockLoaderClient = Parameters[0] @@ -276,7 +277,7 @@ describe('contracts helpers', () => { const contracts = request.contracts const firstContract = contracts[0] if (getContractFunctionName(firstContract) === 'completeSetCollateralAmount') { - return [0n, 10n, [0n, zeroAddress, 0n, 0n, 0n, false, 0], 0n, 0n, 3n, 0n, 0n, 0n, 0n] + return [0n, 10n, defaultForkData, 0n, 0n, 3n, 0n, 0n, 0n, 0n] } if (getContractFunctionName(firstContract) === 'questions') return [questionTuple, 1n] throw new Error(`Unexpected multicall contract: ${getContractFunctionName(firstContract)}`) @@ -314,6 +315,46 @@ describe('contracts helpers', () => { expect(pool.hasForkActivity).toBe(false) }) + test('loadSecurityPoolPage rejects malformed fork data instead of casting tuple reads', async () => { + const questionId = 1n + const questionTuple = ['Question', 'Description', 1n, 2n, 2n, 0n, 100n, ''] as const + const client = createMockLoaderClient({ + getBlock: async () => createBlockWithTimestamp(0n), + multicall: async request => { + const firstContract = request.contracts[0] + if (getContractFunctionName(firstContract) === 'completeSetCollateralAmount') { + return [0n, 10n, [0n, zeroAddress, 0n, 'bad-migrated-rep', 0n, 0n, 0n, 0n, false, false, 0n], 0n, 0n, 3n, 0n, 0n, 0n, 0n] + } + if (getContractFunctionName(firstContract) === 'questions') return [questionTuple, 1n] + throw new Error(`Unexpected multicall contract: ${getContractFunctionName(firstContract)}`) + }, + readContract: async request => { + if (request.functionName === 'securityPoolDeploymentCount') return 1n + if (request.functionName === 'securityPoolDeploymentsRange') { + return [ + { + completeSetCollateralAmount: 0n, + currentRetentionRate: 0n, + parent: zeroAddress, + priceOracleManagerAndOperatorQueuer: zeroAddress, + questionId, + securityMultiplier: 2n, + securityPool: securityPoolAddress, + shareToken: shareTokenAddress, + truthAuction: zeroAddress, + universeId: 1n, + }, + ] + } + if (request.functionName === 'getVaultCount' || request.functionName === 'getActiveVaultCount') return 0n + if (request.functionName === 'getOutcomeLabels') return ['Yes', 'No'] + throw new Error(`Unexpected readContract function: ${request.functionName}`) + }, + }) + + await expect(loadSecurityPoolPage(client, 0, 1)).rejects.toThrow('Unexpected security pool fork data migrated REP response') + }) + test('loadSecurityPoolPage does not infer parent fork activity from other pools on the same page', async () => { const questionId = 1n const questionTuple = ['Question', 'Description', 1n, 2n, 2n, 0n, 100n, ''] as const @@ -327,8 +368,8 @@ describe('contracts helpers', () => { if (getContractFunctionName(firstContract) === 'completeSetCollateralAmount') { const contractAddress = Reflect.get(firstContract, 'address') if (typeof contractAddress !== 'string') throw new Error('Expected security pool address') - if (getAddress(contractAddress) === parentSecurityPoolAddress) return [0n, 10n, [0n, zeroAddress, 0n, 0n, 0n, false, 0], 0n, 0n, 3n, 0n, 0n, 0n, 1n] - if (getAddress(contractAddress) === childSecurityPoolAddress) return [0n, 10n, [0n, zeroAddress, 0n, 0n, 0n, false, 0], 0n, 0n, 3n, 0n, 0n, 0n, 1n] + if (getAddress(contractAddress) === parentSecurityPoolAddress) return [0n, 10n, defaultForkData, 0n, 0n, 3n, 0n, 0n, 0n, 1n] + if (getAddress(contractAddress) === childSecurityPoolAddress) return [0n, 10n, defaultForkData, 0n, 0n, 3n, 0n, 0n, 0n, 1n] } if (getContractFunctionName(firstContract) === 'questions') return [questionTuple, 1n] throw new Error(`Unexpected multicall contract: ${getContractFunctionName(firstContract)}`) @@ -389,7 +430,7 @@ describe('contracts helpers', () => { multicall: async request => { const firstContract = request.contracts[0] if (getContractFunctionName(firstContract) === 'completeSetCollateralAmount') { - return [0n, 10n, [0n, zeroAddress, 0n, 0n, 0n, false, 0], 0n, 0n, 3n, 0n, 100n, 0n, 0n] + return [0n, 10n, defaultForkData, 0n, 0n, 3n, 0n, 100n, 0n, 0n] } if (getContractFunctionName(firstContract) === 'questions') return [questionTuple, 1n] throw new Error(`Unexpected multicall contract: ${getContractFunctionName(firstContract)}`) @@ -459,7 +500,7 @@ describe('contracts helpers', () => { const contracts = request.contracts const firstContract = contracts[0] if (getContractFunctionName(firstContract) === 'completeSetCollateralAmount') { - return [0n, 10n, [0n, zeroAddress, 0n, 0n, 0n, false, 0], 0n, 0n, 3n, 0n, 5n, 0n, 0n] + return [0n, 10n, defaultForkData, 0n, 0n, 3n, 0n, 5n, 0n, 0n] } if (getContractFunctionName(firstContract) === 'questions') return [questionTuple, 1n] if (getContractFunctionName(firstContract) === 'securityVaults') { @@ -1108,7 +1149,7 @@ describe('contracts helpers', () => { throw new Error(`Unexpected readContract function: ${request.functionName}`) }) - await expect(loadTruthAuctionTickPage(client, truthAuctionAddress, 0, 10)).rejects.toThrow('Unexpected truth auction tick page response') + await expect(loadTruthAuctionTickPage(client, truthAuctionAddress, 0, 10)).rejects.toThrow('Unexpected truth auction tick page submission count response') }) test('loadTruthAuctionActiveTickPage maps active ladder pages and converts page indexes to offsets', async () => { @@ -1178,7 +1219,7 @@ describe('contracts helpers', () => { throw new Error(`Unexpected readContract function: ${request.functionName}`) }) - await expect(loadTruthAuctionTickBidPage(client, truthAuctionAddress, 11n, 0, 10)).rejects.toThrow('Unexpected truth auction tick bid page response') + await expect(loadTruthAuctionTickBidPage(client, truthAuctionAddress, 11n, 0, 10)).rejects.toThrow('Unexpected truth auction tick bid page refunded flag response') }) test('loadTruthAuctionBidderBidPage rejects malformed bid pages instead of trusting ABI shapes', async () => { @@ -1188,7 +1229,7 @@ describe('contracts helpers', () => { throw new Error(`Unexpected readContract function: ${request.functionName}`) }) - await expect(loadTruthAuctionBidderBidPage(client, truthAuctionAddress, securityPoolAddress, 0, 10)).rejects.toThrow('Unexpected truth auction bidder bid page response') + await expect(loadTruthAuctionBidderBidPage(client, truthAuctionAddress, securityPoolAddress, 0, 10)).rejects.toThrow('Unexpected truth auction bidder bid page bidder response') }) test('loadTruthAuctionBidderBidPage maps bidder bid tuples and converts bidder pages to offsets', async () => { From eaf30da461baa8a34d2c96e9b1b935e9c0683a10 Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:13:32 +0000 Subject: [PATCH 4/4] Reuse fork data decoder in contract helpers --- ui/ts/contracts.ts | 8 +++---- ui/ts/contracts/forkData.ts | 37 ++++++++++++++++++++++++++++++++ ui/ts/contracts/securityPools.ts | 23 +------------------- ui/ts/tests/contracts.test.ts | 25 ++++++++++++++++++++- 4 files changed, 65 insertions(+), 28 deletions(-) create mode 100644 ui/ts/contracts/forkData.ts diff --git a/ui/ts/contracts.ts b/ui/ts/contracts.ts index 8a6d0779d..1ac0c1296 100644 --- a/ui/ts/contracts.ts +++ b/ui/ts/contracts.ts @@ -65,6 +65,7 @@ import { } from './contracts/helpers.js' import { type ContractRevertReasonParams, type WriteContractClient, readRequiredMulticall, writeContractAndWait, writeContractAndWaitForReceipt } from './contracts/core.js' import { getInfraContractAddresses, getOpenOracleAddress, getZoltarAddress } from './contracts/deploymentHelpers.js' +import { requireForkDataView } from './contracts/forkData.js' import { executeForkAuctionAction, readSecurityPoolUniverseId } from './contracts/securityPoolActions.js' export { getDeploymentSteps, loadDeploymentStatusOracleSnapshot, loadErc20Allowance, loadErc20Balance } from './contracts/deployment.js' import { getDeploymentSteps } from './contracts/deployment.js' @@ -82,7 +83,6 @@ const QUESTION_OUTCOME_ABI = [parseAbiItem('function getQuestionOutcome(address const UNRESOLVED_ESCALATION_MIGRATION_BATCH_LIMIT = 128 const OPEN_ORACLE_PRICE_UNITS = 30n type ReadWriteContractClient = TransactionReceipt> = Pick & WriteContractClient -type ForkDataTuple = readonly [bigint, Address, bigint, bigint, bigint, bigint, bigint, bigint, boolean, boolean, bigint] type AuctionClearingTuple = readonly [boolean, bigint, bigint, bigint] type LoadAllSecurityPoolsOptions = { accountAddress?: Address @@ -1049,8 +1049,7 @@ export async function loadForkAuctionDetails(client: ReadClient, securityPoolAdd ]) if (!hasTimestamp(block)) throw new Error('Unexpected block response') const marketDetails = await loadMarketDetails(client, questionId) - const forkDataTuple: ForkDataTuple = forkData - const [auctionableRepAtFork, , truthAuctionStartedAt, migratedRep, auctionedSecurityBondAllowance, , , , forkOwnSecurityPool, , forkOutcomeIndex] = forkDataTuple + const { auctionableRepAtFork, truthAuctionStartedAt, migratedRep, auctionedSecurityBondAllowance, forkOwnSecurityPool, forkOutcomeIndex } = requireForkDataView(forkData) const [ownForkMigrationOwnFork, ownForkMigrationAuctionableRepAtFork, vaultRepAtFork, unallocatedEscrowChildRep, escrowSourceRepAtFork] = ownForkMigrationStatusTuple const systemState = getSecurityPoolSystemState(systemStateValue) const forkOutcome = getForkOutcomeKey(forkOutcomeIndex, parentSecurityPoolAddress) @@ -1527,8 +1526,7 @@ export async function loadAllSecurityPools(client: ReadClient, options: LoadAllS }) : Promise.all([getSecurityPoolVaultCount(client, securityPoolAddress)]).then(([vaultCount]) => ({ hasLoadedVaults: vaultCount === 0n, vaultCount, vaults: [] })), ]) - const forkDataTuple: ForkDataTuple = forkData - const [, , truthAuctionStartedAt, migratedRep, , , , , forkOwnSecurityPool, , forkOutcomeIndex] = forkDataTuple + const { truthAuctionStartedAt, migratedRep, forkOwnSecurityPool, forkOutcomeIndex } = requireForkDataView(forkData) const forkOutcome = getForkOutcomeKey(forkOutcomeIndex, parent) const poolSystemState = getSecurityPoolSystemState(systemState) const hasForkActivity = deriveHasForkActivity({ diff --git a/ui/ts/contracts/forkData.ts b/ui/ts/contracts/forkData.ts new file mode 100644 index 000000000..b43074e5a --- /dev/null +++ b/ui/ts/contracts/forkData.ts @@ -0,0 +1,37 @@ +import type { Address } from 'viem' +import { requireAddressValue, requireBigintValue, requireBooleanValue, requireTupleValue } from './decoders.js' + +type ForkDataView = { + auctionableRepAtFork: bigint + truthAuctionAddress: Address + truthAuctionStartedAt: bigint + migratedRep: bigint + auctionedSecurityBondAllowance: bigint + escalationElapsedAtFork: bigint + escalationStartBondAtFork: bigint + escalationNonDecisionThresholdAtFork: bigint + forkOwnSecurityPool: boolean + unresolvedEscalationAtFork: boolean + forkOutcomeIndex: bigint +} + +export function requireForkDataView(value: unknown): ForkDataView { + const [auctionableRepAtFork, truthAuctionAddress, truthAuctionStartedAt, migratedRep, auctionedSecurityBondAllowance, escalationElapsedAtFork, escalationStartBondAtFork, escalationNonDecisionThresholdAtFork, forkOwnSecurityPool, unresolvedEscalationAtFork, forkOutcomeIndex] = requireTupleValue( + value, + 11, + 'security pool fork data', + ) + return { + auctionableRepAtFork: requireBigintValue(auctionableRepAtFork, 'security pool fork data auctionable REP at fork'), + truthAuctionAddress: requireAddressValue(truthAuctionAddress, 'security pool fork data truth auction address'), + truthAuctionStartedAt: requireBigintValue(truthAuctionStartedAt, 'security pool fork data truth auction start time'), + migratedRep: requireBigintValue(migratedRep, 'security pool fork data migrated REP'), + auctionedSecurityBondAllowance: requireBigintValue(auctionedSecurityBondAllowance, 'security pool fork data auctioned security bond allowance'), + escalationElapsedAtFork: requireBigintValue(escalationElapsedAtFork, 'security pool fork data escalation elapsed at fork'), + escalationStartBondAtFork: requireBigintValue(escalationStartBondAtFork, 'security pool fork data escalation start bond at fork'), + escalationNonDecisionThresholdAtFork: requireBigintValue(escalationNonDecisionThresholdAtFork, 'security pool fork data escalation non-decision threshold at fork'), + forkOwnSecurityPool: requireBooleanValue(forkOwnSecurityPool, 'security pool fork data own-pool flag'), + unresolvedEscalationAtFork: requireBooleanValue(unresolvedEscalationAtFork, 'security pool fork data unresolved escalation flag'), + forkOutcomeIndex: requireBigintValue(forkOutcomeIndex, 'security pool fork data fork outcome index'), + } +} diff --git a/ui/ts/contracts/securityPools.ts b/ui/ts/contracts/securityPools.ts index 4cafad691..56f41510d 100644 --- a/ui/ts/contracts/securityPools.ts +++ b/ui/ts/contracts/securityPools.ts @@ -13,7 +13,7 @@ import { deriveHasForkActivity } from '../lib/forkAuction.js' import { sameAddress } from '../lib/address.js' import type { ListedSecurityPool, SecurityPoolCreationResult, SecurityPoolPage, SecurityVaultDetails, WriteClient, ReadClient } from '../types/contracts.js' import { readRequiredMulticall, writeContractAndWaitForReceipt } from './core.js' -import { requireAddressValue, requireBigintValue, requireBooleanValue, requireTupleValue } from './decoders.js' +import { requireForkDataView } from './forkData.js' import { getForkOutcomeKey, getQuestionIdHex, getReportingOutcomeKey, getSecurityPoolSystemState, requireSecurityVaultTupleArray } from './helpers.js' import { getDeploymentSteps } from './deployment.js' import { getInfraContractAddresses, getZoltarAddress } from './deploymentHelpers.js' @@ -33,27 +33,6 @@ type SecurityPoolDeploymentQueryResult = { universeId: bigint } -function requireForkDataView(value: unknown) { - const [auctionableRepAtFork, truthAuctionAddress, truthAuctionStartedAt, migratedRep, auctionedSecurityBondAllowance, escalationElapsedAtFork, escalationStartBondAtFork, escalationNonDecisionThresholdAtFork, forkOwnSecurityPool, unresolvedEscalationAtFork, forkOutcomeIndex] = requireTupleValue( - value, - 11, - 'security pool fork data', - ) - return { - auctionableRepAtFork: requireBigintValue(auctionableRepAtFork, 'security pool fork data auctionable REP at fork'), - truthAuctionAddress: requireAddressValue(truthAuctionAddress, 'security pool fork data truth auction address'), - truthAuctionStartedAt: requireBigintValue(truthAuctionStartedAt, 'security pool fork data truth auction start time'), - migratedRep: requireBigintValue(migratedRep, 'security pool fork data migrated REP'), - auctionedSecurityBondAllowance: requireBigintValue(auctionedSecurityBondAllowance, 'security pool fork data auctioned security bond allowance'), - escalationElapsedAtFork: requireBigintValue(escalationElapsedAtFork, 'security pool fork data escalation elapsed at fork'), - escalationStartBondAtFork: requireBigintValue(escalationStartBondAtFork, 'security pool fork data escalation start bond at fork'), - escalationNonDecisionThresholdAtFork: requireBigintValue(escalationNonDecisionThresholdAtFork, 'security pool fork data escalation non-decision threshold at fork'), - forkOwnSecurityPool: requireBooleanValue(forkOwnSecurityPool, 'security pool fork data own-pool flag'), - unresolvedEscalationAtFork: requireBooleanValue(unresolvedEscalationAtFork, 'security pool fork data unresolved escalation flag'), - forkOutcomeIndex: requireBigintValue(forkOutcomeIndex, 'security pool fork data fork outcome index'), - } -} - function getDeploymentStepAddress(id: 'securityPoolFactory' | 'zoltarQuestionData') { const step = getDeploymentSteps().find(candidate => candidate.id === id) if (step === undefined) throw new Error(`Unknown deployment step: ${id}`) diff --git a/ui/ts/tests/contracts.test.ts b/ui/ts/tests/contracts.test.ts index ae5851dac..70eefb5c0 100644 --- a/ui/ts/tests/contracts.test.ts +++ b/ui/ts/tests/contracts.test.ts @@ -148,7 +148,7 @@ describe('contracts helpers', () => { const contracts = request.contracts const firstContract = contracts[0] if (getContractFunctionName(firstContract) === 'questionId') { - return [questionId, zeroAddress, 1n, 0n, zeroAddress, 0n, [0n, zeroAddress, 0n, 0n, 0n, 0n, 0n, 0n, false, false, 0n], 3n, [0n, 0n, 0n]] + return [questionId, zeroAddress, 1n, 0n, zeroAddress, 0n, defaultForkData, 3n, [0n, 0n, 0n]] } if (getContractFunctionName(firstContract) === 'getForkTime') return [0n] if (getContractFunctionName(firstContract) === 'questions') return [questionTuple, 1n] @@ -169,6 +169,29 @@ describe('contracts helpers', () => { expect(details.ownForkRepBuckets).toBeUndefined() }) + test('loadForkAuctionDetails rejects malformed fork data instead of casting tuple reads', async () => { + const questionId = 1n + const questionTuple = ['Question', 'Description', 1n, 2n, 2n, 0n, 100n, ''] as const + const client = createMockLoaderClient({ + getBlock: async () => createBlockWithTimestamp(5n), + multicall: async request => { + const firstContract = request.contracts[0] + if (getContractFunctionName(firstContract) === 'questionId') { + return [questionId, zeroAddress, 1n, 0n, zeroAddress, 0n, [0n, zeroAddress, 0n, 'bad-migrated-rep', 0n, 0n, 0n, 0n, false, false, 0n], 3n, [0n, 0n, 0n]] + } + if (getContractFunctionName(firstContract) === 'questions') return [questionTuple, 1n] + throw new Error(`Unexpected multicall contract: ${getContractFunctionName(firstContract)}`) + }, + readContract: async request => { + if (request.functionName === 'getOutcomeLabels') return ['Yes', 'No'] + if (request.functionName === 'getOwnForkMigrationStatus') return [false, 0n, 0n, 0n, 0n] + throw new Error(`Unexpected readContract function: ${request.functionName}`) + }, + }) + + await expect(loadForkAuctionDetails(client, securityPoolAddress)).rejects.toThrow('Unexpected security pool fork data migrated REP response') + }) + test('loadForkAuctionDetails preserves migration end time after truth auction has started', async () => { const questionId = 1n const forkTime = 1_000n