From c7865e1b372d3c9a13fa5a90f235fe8ee0524168 Mon Sep 17 00:00:00 2001 From: KillariDev <13102010+KillariDev@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:53:42 +0000 Subject: [PATCH] Reject scalar reserved-bit aliases across stack - Treat non-zero reserved scalar bits as malformed in contract and UI - Add parity tests for aliased scalar answers and child deployment guards - Update mainnet deployment addresses --- docs/mainnet-deployment-addresses.json | 18 +++++----- .../ts/testing/scalarOutcomeParityFixtures.ts | 2 ++ solidity/contracts/ZoltarQuestionData.sol | 8 +++++ solidity/ts/tests/questionData.test.ts | 34 +++++++++++++++++++ solidity/ts/tests/zoltar.test.ts | 15 ++++++++ ui/ts/lib/scalarOutcome.ts | 2 ++ ui/ts/tests/scalarOutcome.test.ts | 16 +++++++++ 7 files changed, 86 insertions(+), 9 deletions(-) diff --git a/docs/mainnet-deployment-addresses.json b/docs/mainnet-deployment-addresses.json index 863a9fac..27d3db52 100644 --- a/docs/mainnet-deployment-addresses.json +++ b/docs/mainnet-deployment-addresses.json @@ -13,7 +13,7 @@ { "id": "deploymentStatusOracle", "label": "Deployment Status Oracle", - "address": "0xFc6aE690a62398A427b05C929c718d7E3702F070" + "address": "0x820169b5769F942E8042d8c5640e8e3318dd1cf6" }, { "id": "multicall3", @@ -43,44 +43,44 @@ { "id": "zoltarQuestionData", "label": "ZoltarQuestionData", - "address": "0x3fDE26F6C206DDc4991087FCeB5f13EC9f6F3E94" + "address": "0x0B44b5428ed177B279455Ae6245C8aD46702725B" }, { "id": "zoltar", "label": "Zoltar", - "address": "0x5749644a78046f8F30a269c82Af1C9f113C4243B" + "address": "0x5df17977f061C8CaE956562035A415cD6c5D4334" }, { "id": "shareTokenFactory", "label": "ShareTokenFactory", - "address": "0x6bcf32294406c63E336fC1Ef4BdbF4c4777F784B" + "address": "0xb12687F0eD311c636f140796a2A398eE09491ca1" }, { "id": "priceOracleManagerAndOperatorQueuerFactory", "label": "OpenOracle Price Coordinator Factory", - "address": "0xea6Ac29A30275c3749d3a46441B71d72Fad74a29" + "address": "0x7e620619C4cDA92d13749dE2945b0E8195Fe74e3" }, { "id": "securityPoolForker", "label": "Security Pool Forker", - "address": "0x8dc24BeeD1Db5630b0d833a9DE272cc6113E75B0" + "address": "0xA878aFAdDF11AAeD81AF370466EcE93Fc7B3Be13" }, { "id": "escalationGameFactory", "label": "Escalation Game Factory", - "address": "0x41E4D99059218dF6943777416Da49cccD462e8A1" + "address": "0x47b5b3038e7777CD97207958A27724b18B8FA9dF" }, { "id": "securityPoolFactory", "label": "Security Pool Factory", - "address": "0xE4232859250cB576F8C8955466798c922D359C66" + "address": "0xd73CDE1BCBFB71FEbF0514Bc171ea1900671d0aA" } ], "derivedContracts": [ { "id": "escalationGameProofVerifier", "label": "Escalation Game Proof Verifier", - "address": "0x99A16fbFe5c06ABB813565e0600355474141Dbad" + "address": "0x9C1b6A301973C1ffb5acf920825235E6bfff4430" } ] } diff --git a/shared/ts/testing/scalarOutcomeParityFixtures.ts b/shared/ts/testing/scalarOutcomeParityFixtures.ts index fc034f29..c7ab0de1 100644 --- a/shared/ts/testing/scalarOutcomeParityFixtures.ts +++ b/shared/ts/testing/scalarOutcomeParityFixtures.ts @@ -40,6 +40,7 @@ export const SCALAR_PARITY_DECIMAL_BASE = 10n ** SCALAR_PARITY_DECIMALS export const SCALAR_PARITY_PART_BIT_LENGTH = 120n export const SCALAR_PARITY_TOTAL_BITS = 256n export const SCALAR_PARITY_PART_MASK = (1n << SCALAR_PARITY_PART_BIT_LENGTH) - 1n +export const SCALAR_PARITY_RESERVED_BITS_MASK = ((1n << 15n) - 1n) << 240n export const SCALAR_PARITY_UINT120_MAX = SCALAR_PARITY_PART_MASK export const SCALAR_PARITY_INT256_MIN = -(1n << 255n) export const SCALAR_PARITY_INT256_MAX = (1n << 255n) - 1n @@ -214,6 +215,7 @@ export function formatScalarParityLabel(question: ScalarParityQuestion, tickInde export function describeScalarParityOutcomeIndex(question: ScalarParityQuestion, outcomeIndex: bigint): ScalarParityOutcomeIndexDescriptor { if (question.numTicks <= 0n) return { kind: 'malformed' } + if ((outcomeIndex & SCALAR_PARITY_RESERVED_BITS_MASK) !== 0n) return { kind: 'malformed' } const { invalid, firstPart, secondPart } = splitScalarParityOutcomeIndex(outcomeIndex) if (invalid) return firstPart === 0n && secondPart === 0n ? { kind: 'invalid' } : { kind: 'malformed' } if (firstPart + secondPart !== question.numTicks) return { kind: 'malformed' } diff --git a/solidity/contracts/ZoltarQuestionData.sol b/solidity/contracts/ZoltarQuestionData.sol index c4f57ce1..bc3692a0 100644 --- a/solidity/contracts/ZoltarQuestionData.sol +++ b/solidity/contracts/ZoltarQuestionData.sol @@ -4,6 +4,8 @@ pragma solidity 0.8.35; import { ScalarOutcomes } from './ScalarOutcomes.sol'; contract ZoltarQuestionData { + uint256 private constant SCALAR_RESERVED_BITS_MASK = ((uint256(1) << 15) - 1) << 240; + struct QuestionData { string title; string description; @@ -98,6 +100,10 @@ contract ZoltarQuestionData { secondPart = uint120(value & ((1 << 120) - 1)); } + function hasNonZeroScalarReservedBits(uint256 answer) public pure returns (bool) { + return answer & SCALAR_RESERVED_BITS_MASK != 0; + } + function getOutcomeLabels( uint256 questionId, uint256 startIndex, @@ -121,6 +127,7 @@ contract ZoltarQuestionData { function isMalformedAnswerOption(uint256 questionId, uint256 answer) external view returns (bool) { if (outcomeLabels[questionId].length == 0) { // scalar + if (hasNonZeroScalarReservedBits(answer)) return true; (bool invalid, uint120 firstPart, uint120 secondPart) = splitUint256IntoTwoWithInvalid(answer); if (invalid) { if (firstPart == 0 && secondPart == 0) return false; @@ -141,6 +148,7 @@ contract ZoltarQuestionData { function getAnswerOptionName(uint256 questionId, uint256 answer) external view returns (string memory) { if (outcomeLabels[questionId].length == 0) { // scalar + if (hasNonZeroScalarReservedBits(answer)) return 'Malformed'; (bool invalid, uint120 firstPart, uint120 secondPart) = splitUint256IntoTwoWithInvalid(answer); if (invalid) { if (firstPart == 0 && secondPart == 0) return 'Invalid'; diff --git a/solidity/ts/tests/questionData.test.ts b/solidity/ts/tests/questionData.test.ts index b2cbd547..65ab1ba9 100644 --- a/solidity/ts/tests/questionData.test.ts +++ b/solidity/ts/tests/questionData.test.ts @@ -30,6 +30,7 @@ import type { ScalarParityQuestion } from '@zoltar/shared/testing/scalarOutcomeP const MAX_UINT256 = 2n ** 256n - 1n const SCALAR_ENCODING_FUZZ_SAMPLE_COUNT = 12 const SCALAR_ENCODING_FUZZ_STATE_MASK = (1n << 128n) - 1n +const SCALAR_RESERVED_BITS_MASK = ((1n << 15n) - 1n) << 240n type ScalarEncodingFuzzSample = { firstPart: bigint @@ -63,6 +64,10 @@ function getScalarEncodingFuzzSamples(question: ScalarParityQuestion, seed: bigi return samples } +function withScalarReservedBits(answer: bigint, reservedBits = 1n) { + return answer | ((reservedBits << 240n) & SCALAR_RESERVED_BITS_MASK) +} + setDefaultTimeout(TEST_TIMEOUT_MS) describe('Question Data', () => { @@ -304,6 +309,35 @@ describe('Question Data', () => { } }) + test('scalar answers with non-zero reserved bits are malformed even when the payload is otherwise canonical', async () => { + const testScalarQuestion = { + title: 'scalar reserved bits', + description: 'scalar reserved bits', + startTime: (await mockWindow.getTime()) + 100000n, + endTime: (await mockWindow.getTime()) + 200000n, + numTicks: 1000n, + displayValueMin: 0n, + displayValueMax: 1000n, + answerUnit: 'unit', + } + await createQuestion(client, testScalarQuestion, []) + const questionId = getQuestionId(testScalarQuestion, []) + + const canonicalScalarAnswer = combineUint256FromTwoWithInvalid(false, 600n, 400n) + const aliasedScalarAnswer = withScalarReservedBits(canonicalScalarAnswer, 0x1234n) + const canonicalInvalidAnswer = combineUint256FromTwoWithInvalid(true, 0n, 0n) + const aliasedInvalidAnswer = withScalarReservedBits(canonicalInvalidAnswer, 0x7fffn) + const canonicalScalarLabel = await getAnswerOptionName(client, questionId, canonicalScalarAnswer) + + assert.strictEqual(await isMalformedAnswerOption(client, questionId, canonicalScalarAnswer), false, 'canonical scalar answer should remain valid') + assert.notStrictEqual(canonicalScalarLabel, 'Malformed', 'canonical scalar answer should keep a non-malformed label') + assert.strictEqual(await isMalformedAnswerOption(client, questionId, aliasedScalarAnswer), true, 'scalar alias with reserved bits should be malformed') + assert.strictEqual(await getAnswerOptionName(client, questionId, aliasedScalarAnswer), 'Malformed', 'scalar alias with reserved bits should render as malformed') + assert.strictEqual(await isMalformedAnswerOption(client, questionId, aliasedInvalidAnswer), true, 'invalid alias with reserved bits should be malformed') + assert.strictEqual(await getAnswerOptionName(client, questionId, aliasedInvalidAnswer), 'Malformed', 'invalid alias with reserved bits should render as malformed') + assert.strictEqual(await getAnswerOptionName(client, questionId, canonicalInvalidAnswer), 'Invalid', 'canonical invalid answer should remain available') + }) + test('accepts scalar questions at the uint120 encoding boundary', async () => { const maxScalarNumTicks = (1n << 120n) - 1n const testScalarQuestion = { diff --git a/solidity/ts/tests/zoltar.test.ts b/solidity/ts/tests/zoltar.test.ts index 9f9c69c7..a9f85667 100644 --- a/solidity/ts/tests/zoltar.test.ts +++ b/solidity/ts/tests/zoltar.test.ts @@ -34,6 +34,11 @@ import { formatScalarOutcomeLabel, getScalarOutcomeIndex } from '../testsuite/si // Forker deposit fractions: deposit is 5% of total supply (1/20), and 20% of that deposit is burned (1/5 of deposit) const FORKER_DEPOSIT_FRACTION = 20n const MAX_UINT256 = 2n ** 256n - 1n +const SCALAR_RESERVED_BITS_MASK = ((1n << 15n) - 1n) << 240n + +function withScalarReservedBits(answer: bigint, reservedBits = 1n) { + return answer | ((reservedBits << 240n) & SCALAR_RESERVED_BITS_MASK) +} function formatStorageSlot(slot: bigint) { return `0x${slot.toString(16).padStart(64, '0')}` @@ -518,6 +523,16 @@ describe('Contract Test Suite', () => { const malformedChildUniverseId = getChildUniverseId(genesisUniverse, malformedScalarOutcomeIndex) await assert.rejects(deployChild(client, genesisUniverse, malformedScalarOutcomeIndex), /Malformed/) assert.ok(!(await contractExists(client, getRepTokenAddress(malformedChildUniverseId))), 'malformed scalar child universe should not be deployed') + + const canonicalScalarOutcomeIndex = getScalarOutcomeIndex(scalarQuestionData, 5n) + const aliasedScalarOutcomeIndex = withScalarReservedBits(canonicalScalarOutcomeIndex, 0x4567n) + const aliasedChildUniverseId = getChildUniverseId(genesisUniverse, aliasedScalarOutcomeIndex) + assert.ok(aliasedChildUniverseId !== getChildUniverseId(genesisUniverse, canonicalScalarOutcomeIndex), 'reserved-bit alias should still hash to a distinct child id before validation') + await assert.rejects(deployChild(client, genesisUniverse, aliasedScalarOutcomeIndex), /Malformed/) + assert.ok(!(await contractExists(client, getRepTokenAddress(aliasedChildUniverseId))), 'reserved-bit scalar alias should not deploy a child universe') + + const migrationBalance = await getMigrationRepBalance(client, genesisUniverse, client.account.address) + await assert.rejects(splitMigrationRep(client, genesisUniverse, migrationBalance, [aliasedScalarOutcomeIndex]), /Malformed/) }) test('getDeployedChildUniverses pages deployed child universes', async () => { diff --git a/ui/ts/lib/scalarOutcome.ts b/ui/ts/lib/scalarOutcome.ts index b53bfe96..e3427314 100644 --- a/ui/ts/lib/scalarOutcome.ts +++ b/ui/ts/lib/scalarOutcome.ts @@ -19,6 +19,7 @@ const SCALAR_DECIMAL_BASE = 10n ** SCALAR_DECIMALS const SCALAR_PART_BIT_LENGTH = 120n const SCALAR_TOTAL_BITS = 256n const SCALAR_PART_MASK = (1n << SCALAR_PART_BIT_LENGTH) - 1n +const SCALAR_RESERVED_BITS_MASK = ((1n << 15n) - 1n) << 240n type ScalarOutcomeIndexDescriptor = | { @@ -115,6 +116,7 @@ export function formatScalarOutcomeLabel(question: ScalarQuestionDetails, tickIn export function getScalarOutcomeIndexDescriptor(question: ScalarQuestionDetails, outcomeIndex: bigint): ScalarOutcomeIndexDescriptor { if (question.numTicks <= 0n) return { kind: 'malformed' } + if ((outcomeIndex & SCALAR_RESERVED_BITS_MASK) !== 0n) return { kind: 'malformed' } const { invalid, firstPart, secondPart } = splitScalarOutcomeIndex(outcomeIndex) if (invalid) return firstPart === 0n && secondPart === 0n ? { kind: 'invalid' } : { kind: 'malformed' } diff --git a/ui/ts/tests/scalarOutcome.test.ts b/ui/ts/tests/scalarOutcome.test.ts index e3f26adf..3b3afe3d 100644 --- a/ui/ts/tests/scalarOutcome.test.ts +++ b/ui/ts/tests/scalarOutcome.test.ts @@ -10,6 +10,11 @@ const scalarQuestion = { displayValueMin: 0n, numTicks: 10n, } +const SCALAR_RESERVED_BITS_MASK = ((1n << 15n) - 1n) << 240n + +function withScalarReservedBits(answer: bigint, reservedBits = 1n) { + return answer | ((reservedBits << 240n) & SCALAR_RESERVED_BITS_MASK) +} void describe('scalar outcome helpers', () => { void test('rejects zero tick scalar questions', () => { @@ -53,6 +58,17 @@ void describe('scalar outcome helpers', () => { expect(() => formatScalarOutcomeIndexLabel(scalarQuestion, 5n)).toThrow('Scalar outcome index is malformed') }) + void test('treats reserved-bit scalar aliases as malformed', () => { + const canonicalOutcomeIndex = getScalarOutcomeIndex(scalarQuestion, 4n) + const aliasedOutcomeIndex = withScalarReservedBits(canonicalOutcomeIndex, 0x1234n) + const aliasedInvalidOutcomeIndex = withScalarReservedBits(0n, 0x7fffn) + + expect(getScalarOutcomeIndexDescriptor(scalarQuestion, aliasedOutcomeIndex)).toEqual({ kind: 'malformed' }) + expect(getScalarOutcomeIndexDescriptor(scalarQuestion, aliasedInvalidOutcomeIndex)).toEqual({ kind: 'malformed' }) + expect(isValidScalarOutcomeIndex(scalarQuestion, aliasedOutcomeIndex)).toBe(false) + expect(() => formatScalarOutcomeIndexLabel(scalarQuestion, aliasedOutcomeIndex)).toThrow('Scalar outcome index is malformed') + }) + void test('rejects scalar inputs that do not divide into whole ticks', () => { expect(() => parseScalarFormInputs({ scalarMin: '1', scalarMax: '10', scalarIncrement: '0.4' })).toThrow('Scalar min, max, and increment do not produce a whole number of ticks') })