Skip to content

Commit 96efb2d

Browse files
authored
Reject scalar reserved-bit aliases across stack (#497)
- 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
1 parent dc10ae9 commit 96efb2d

7 files changed

Lines changed: 86 additions & 9 deletions

File tree

docs/mainnet-deployment-addresses.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
{
1414
"id": "deploymentStatusOracle",
1515
"label": "Deployment Status Oracle",
16-
"address": "0x9f60ca8EfCc044EB5CFF8f71d46fC292e4b1A886"
16+
"address": "0xD7549E27fB03aeD7dfb30082f6d8f342DfEAF604"
1717
},
1818
{
1919
"id": "multicall3",
@@ -43,44 +43,44 @@
4343
{
4444
"id": "zoltarQuestionData",
4545
"label": "ZoltarQuestionData",
46-
"address": "0x3fDE26F6C206DDc4991087FCeB5f13EC9f6F3E94"
46+
"address": "0x0B44b5428ed177B279455Ae6245C8aD46702725B"
4747
},
4848
{
4949
"id": "zoltar",
5050
"label": "Zoltar",
51-
"address": "0x99B04b933512F95711Db02A163742C8468C6D0b5"
51+
"address": "0x216326652dd28905917baC191A43a8f5237c16c6"
5252
},
5353
{
5454
"id": "shareTokenFactory",
5555
"label": "ShareTokenFactory",
56-
"address": "0x9e3adB2eFB22B4C8471B3A439127C9c1e42DA197"
56+
"address": "0x2255ae657cA45A75F9aa7dF453A901B1F1E7f9Ca"
5757
},
5858
{
5959
"id": "priceOracleManagerAndOperatorQueuerFactory",
6060
"label": "OpenOracle Price Coordinator Factory",
61-
"address": "0x4318D853ccFCa15ab973621ea4BBE1Aa6A628C93"
61+
"address": "0x20CeeD3987AEeaFB24B83C7281C1C52135BbA9BA"
6262
},
6363
{
6464
"id": "securityPoolForker",
6565
"label": "Security Pool Forker",
66-
"address": "0x12DDac4Eaa2854eBF38D5e6cd116538003D6f608"
66+
"address": "0x55AD363204C991d0bb37494372F3821f2B5681De"
6767
},
6868
{
6969
"id": "escalationGameFactory",
7070
"label": "Escalation Game Factory",
71-
"address": "0x0e15497C658F9530C85931A02e9E3f6047F9e62F"
71+
"address": "0x04AbAd5b45864673586C0b19c611D8e96B60266d"
7272
},
7373
{
7474
"id": "securityPoolFactory",
7575
"label": "Security Pool Factory",
76-
"address": "0x5b7F5A06B3eBf67eE8f5FF03718a05904F700052"
76+
"address": "0x7646559Cccbe18A819b0f6060890D292cD5359E8"
7777
}
7878
],
7979
"derivedContracts": [
8080
{
8181
"id": "escalationGameProofVerifier",
8282
"label": "Escalation Game Proof Verifier",
83-
"address": "0x77a2BfF40a775fA2d05a6574926c66E00a369E82"
83+
"address": "0x1A899c1D329ad210CC6DD88ED068410a007D51De"
8484
}
8585
]
8686
}

shared/ts/testing/scalarOutcomeParityFixtures.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export const SCALAR_PARITY_DECIMAL_BASE = 10n ** SCALAR_PARITY_DECIMALS
4040
export const SCALAR_PARITY_PART_BIT_LENGTH = 120n
4141
export const SCALAR_PARITY_TOTAL_BITS = 256n
4242
export const SCALAR_PARITY_PART_MASK = (1n << SCALAR_PARITY_PART_BIT_LENGTH) - 1n
43+
export const SCALAR_PARITY_RESERVED_BITS_MASK = ((1n << 15n) - 1n) << 240n
4344
export const SCALAR_PARITY_UINT120_MAX = SCALAR_PARITY_PART_MASK
4445
export const SCALAR_PARITY_INT256_MIN = -(1n << 255n)
4546
export const SCALAR_PARITY_INT256_MAX = (1n << 255n) - 1n
@@ -214,6 +215,7 @@ export function formatScalarParityLabel(question: ScalarParityQuestion, tickInde
214215

215216
export function describeScalarParityOutcomeIndex(question: ScalarParityQuestion, outcomeIndex: bigint): ScalarParityOutcomeIndexDescriptor {
216217
if (question.numTicks <= 0n) return { kind: 'malformed' }
218+
if ((outcomeIndex & SCALAR_PARITY_RESERVED_BITS_MASK) !== 0n) return { kind: 'malformed' }
217219
const { invalid, firstPart, secondPart } = splitScalarParityOutcomeIndex(outcomeIndex)
218220
if (invalid) return firstPart === 0n && secondPart === 0n ? { kind: 'invalid' } : { kind: 'malformed' }
219221
if (firstPart + secondPart !== question.numTicks) return { kind: 'malformed' }

solidity/contracts/ZoltarQuestionData.sol

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ pragma solidity 0.8.35;
44
import { ScalarOutcomes } from './ScalarOutcomes.sol';
55

66
contract ZoltarQuestionData {
7+
uint256 private constant SCALAR_RESERVED_BITS_MASK = ((uint256(1) << 15) - 1) << 240;
8+
79
struct QuestionData {
810
string title;
911
string description;
@@ -98,6 +100,10 @@ contract ZoltarQuestionData {
98100
secondPart = uint120(value & ((1 << 120) - 1));
99101
}
100102

103+
function hasNonZeroScalarReservedBits(uint256 answer) public pure returns (bool) {
104+
return answer & SCALAR_RESERVED_BITS_MASK != 0;
105+
}
106+
101107
function getOutcomeLabels(
102108
uint256 questionId,
103109
uint256 startIndex,
@@ -121,6 +127,7 @@ contract ZoltarQuestionData {
121127
function isMalformedAnswerOption(uint256 questionId, uint256 answer) external view returns (bool) {
122128
if (outcomeLabels[questionId].length == 0) {
123129
// scalar
130+
if (hasNonZeroScalarReservedBits(answer)) return true;
124131
(bool invalid, uint120 firstPart, uint120 secondPart) = splitUint256IntoTwoWithInvalid(answer);
125132
if (invalid) {
126133
if (firstPart == 0 && secondPart == 0) return false;
@@ -141,6 +148,7 @@ contract ZoltarQuestionData {
141148
function getAnswerOptionName(uint256 questionId, uint256 answer) external view returns (string memory) {
142149
if (outcomeLabels[questionId].length == 0) {
143150
// scalar
151+
if (hasNonZeroScalarReservedBits(answer)) return 'Malformed';
144152
(bool invalid, uint120 firstPart, uint120 secondPart) = splitUint256IntoTwoWithInvalid(answer);
145153
if (invalid) {
146154
if (firstPart == 0 && secondPart == 0) return 'Invalid';

solidity/ts/tests/questionData.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type { ScalarParityQuestion } from '@zoltar/shared/testing/scalarOutcomeP
3030
const MAX_UINT256 = 2n ** 256n - 1n
3131
const SCALAR_ENCODING_FUZZ_SAMPLE_COUNT = 12
3232
const SCALAR_ENCODING_FUZZ_STATE_MASK = (1n << 128n) - 1n
33+
const SCALAR_RESERVED_BITS_MASK = ((1n << 15n) - 1n) << 240n
3334

3435
type ScalarEncodingFuzzSample = {
3536
firstPart: bigint
@@ -63,6 +64,10 @@ function getScalarEncodingFuzzSamples(question: ScalarParityQuestion, seed: bigi
6364
return samples
6465
}
6566

67+
function withScalarReservedBits(answer: bigint, reservedBits = 1n) {
68+
return answer | ((reservedBits << 240n) & SCALAR_RESERVED_BITS_MASK)
69+
}
70+
6671
setDefaultTimeout(TEST_TIMEOUT_MS)
6772

6873
describe('Question Data', () => {
@@ -304,6 +309,35 @@ describe('Question Data', () => {
304309
}
305310
})
306311

312+
test('scalar answers with non-zero reserved bits are malformed even when the payload is otherwise canonical', async () => {
313+
const testScalarQuestion = {
314+
title: 'scalar reserved bits',
315+
description: 'scalar reserved bits',
316+
startTime: (await mockWindow.getTime()) + 100000n,
317+
endTime: (await mockWindow.getTime()) + 200000n,
318+
numTicks: 1000n,
319+
displayValueMin: 0n,
320+
displayValueMax: 1000n,
321+
answerUnit: 'unit',
322+
}
323+
await createQuestion(client, testScalarQuestion, [])
324+
const questionId = getQuestionId(testScalarQuestion, [])
325+
326+
const canonicalScalarAnswer = combineUint256FromTwoWithInvalid(false, 600n, 400n)
327+
const aliasedScalarAnswer = withScalarReservedBits(canonicalScalarAnswer, 0x1234n)
328+
const canonicalInvalidAnswer = combineUint256FromTwoWithInvalid(true, 0n, 0n)
329+
const aliasedInvalidAnswer = withScalarReservedBits(canonicalInvalidAnswer, 0x7fffn)
330+
const canonicalScalarLabel = await getAnswerOptionName(client, questionId, canonicalScalarAnswer)
331+
332+
assert.strictEqual(await isMalformedAnswerOption(client, questionId, canonicalScalarAnswer), false, 'canonical scalar answer should remain valid')
333+
assert.notStrictEqual(canonicalScalarLabel, 'Malformed', 'canonical scalar answer should keep a non-malformed label')
334+
assert.strictEqual(await isMalformedAnswerOption(client, questionId, aliasedScalarAnswer), true, 'scalar alias with reserved bits should be malformed')
335+
assert.strictEqual(await getAnswerOptionName(client, questionId, aliasedScalarAnswer), 'Malformed', 'scalar alias with reserved bits should render as malformed')
336+
assert.strictEqual(await isMalformedAnswerOption(client, questionId, aliasedInvalidAnswer), true, 'invalid alias with reserved bits should be malformed')
337+
assert.strictEqual(await getAnswerOptionName(client, questionId, aliasedInvalidAnswer), 'Malformed', 'invalid alias with reserved bits should render as malformed')
338+
assert.strictEqual(await getAnswerOptionName(client, questionId, canonicalInvalidAnswer), 'Invalid', 'canonical invalid answer should remain available')
339+
})
340+
307341
test('accepts scalar questions at the uint120 encoding boundary', async () => {
308342
const maxScalarNumTicks = (1n << 120n) - 1n
309343
const testScalarQuestion = {

solidity/ts/tests/zoltar.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ import { formatScalarOutcomeLabel, getScalarOutcomeIndex } from '../testsuite/si
3434
// Forker deposit fractions: deposit is 5% of total supply (1/20), and 20% of that deposit is burned (1/5 of deposit)
3535
const FORKER_DEPOSIT_FRACTION = 20n
3636
const MAX_UINT256 = 2n ** 256n - 1n
37+
const SCALAR_RESERVED_BITS_MASK = ((1n << 15n) - 1n) << 240n
38+
39+
function withScalarReservedBits(answer: bigint, reservedBits = 1n) {
40+
return answer | ((reservedBits << 240n) & SCALAR_RESERVED_BITS_MASK)
41+
}
3742

3843
function formatStorageSlot(slot: bigint) {
3944
return `0x${slot.toString(16).padStart(64, '0')}`
@@ -518,6 +523,16 @@ describe('Contract Test Suite', () => {
518523
const malformedChildUniverseId = getChildUniverseId(genesisUniverse, malformedScalarOutcomeIndex)
519524
await assert.rejects(deployChild(client, genesisUniverse, malformedScalarOutcomeIndex), /Malformed/)
520525
assert.ok(!(await contractExists(client, getRepTokenAddress(malformedChildUniverseId))), 'malformed scalar child universe should not be deployed')
526+
527+
const canonicalScalarOutcomeIndex = getScalarOutcomeIndex(scalarQuestionData, 5n)
528+
const aliasedScalarOutcomeIndex = withScalarReservedBits(canonicalScalarOutcomeIndex, 0x4567n)
529+
const aliasedChildUniverseId = getChildUniverseId(genesisUniverse, aliasedScalarOutcomeIndex)
530+
assert.ok(aliasedChildUniverseId !== getChildUniverseId(genesisUniverse, canonicalScalarOutcomeIndex), 'reserved-bit alias should still hash to a distinct child id before validation')
531+
await assert.rejects(deployChild(client, genesisUniverse, aliasedScalarOutcomeIndex), /Malformed/)
532+
assert.ok(!(await contractExists(client, getRepTokenAddress(aliasedChildUniverseId))), 'reserved-bit scalar alias should not deploy a child universe')
533+
534+
const migrationBalance = await getMigrationRepBalance(client, genesisUniverse, client.account.address)
535+
await assert.rejects(splitMigrationRep(client, genesisUniverse, migrationBalance, [aliasedScalarOutcomeIndex]), /Malformed/)
521536
})
522537

523538
test('getDeployedChildUniverses pages deployed child universes', async () => {

ui/ts/lib/scalarOutcome.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const SCALAR_DECIMAL_BASE = 10n ** SCALAR_DECIMALS
1919
const SCALAR_PART_BIT_LENGTH = 120n
2020
const SCALAR_TOTAL_BITS = 256n
2121
const SCALAR_PART_MASK = (1n << SCALAR_PART_BIT_LENGTH) - 1n
22+
const SCALAR_RESERVED_BITS_MASK = ((1n << 15n) - 1n) << 240n
2223

2324
type ScalarOutcomeIndexDescriptor =
2425
| {
@@ -115,6 +116,7 @@ export function formatScalarOutcomeLabel(question: ScalarQuestionDetails, tickIn
115116

116117
export function getScalarOutcomeIndexDescriptor(question: ScalarQuestionDetails, outcomeIndex: bigint): ScalarOutcomeIndexDescriptor {
117118
if (question.numTicks <= 0n) return { kind: 'malformed' }
119+
if ((outcomeIndex & SCALAR_RESERVED_BITS_MASK) !== 0n) return { kind: 'malformed' }
118120

119121
const { invalid, firstPart, secondPart } = splitScalarOutcomeIndex(outcomeIndex)
120122
if (invalid) return firstPart === 0n && secondPart === 0n ? { kind: 'invalid' } : { kind: 'malformed' }

ui/ts/tests/scalarOutcome.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ const scalarQuestion = {
1010
displayValueMin: 0n,
1111
numTicks: 10n,
1212
}
13+
const SCALAR_RESERVED_BITS_MASK = ((1n << 15n) - 1n) << 240n
14+
15+
function withScalarReservedBits(answer: bigint, reservedBits = 1n) {
16+
return answer | ((reservedBits << 240n) & SCALAR_RESERVED_BITS_MASK)
17+
}
1318

1419
void describe('scalar outcome helpers', () => {
1520
void test('rejects zero tick scalar questions', () => {
@@ -53,6 +58,17 @@ void describe('scalar outcome helpers', () => {
5358
expect(() => formatScalarOutcomeIndexLabel(scalarQuestion, 5n)).toThrow('Scalar outcome index is malformed')
5459
})
5560

61+
void test('treats reserved-bit scalar aliases as malformed', () => {
62+
const canonicalOutcomeIndex = getScalarOutcomeIndex(scalarQuestion, 4n)
63+
const aliasedOutcomeIndex = withScalarReservedBits(canonicalOutcomeIndex, 0x1234n)
64+
const aliasedInvalidOutcomeIndex = withScalarReservedBits(0n, 0x7fffn)
65+
66+
expect(getScalarOutcomeIndexDescriptor(scalarQuestion, aliasedOutcomeIndex)).toEqual({ kind: 'malformed' })
67+
expect(getScalarOutcomeIndexDescriptor(scalarQuestion, aliasedInvalidOutcomeIndex)).toEqual({ kind: 'malformed' })
68+
expect(isValidScalarOutcomeIndex(scalarQuestion, aliasedOutcomeIndex)).toBe(false)
69+
expect(() => formatScalarOutcomeIndexLabel(scalarQuestion, aliasedOutcomeIndex)).toThrow('Scalar outcome index is malformed')
70+
})
71+
5672
void test('rejects scalar inputs that do not divide into whole ticks', () => {
5773
expect(() => parseScalarFormInputs({ scalarMin: '1', scalarMax: '10', scalarIncrement: '0.4' })).toThrow('Scalar min, max, and increment do not produce a whole number of ticks')
5874
})

0 commit comments

Comments
 (0)