Skip to content

Commit dea806c

Browse files
committed
fix(genesis): normalize hex identity comparisons
1 parent de76122 commit dea806c

6 files changed

Lines changed: 242 additions & 17 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Genesis identity normalization
2+
3+
## Finding
4+
5+
`schemaAddress` and `schemaHex` accept mixed-case hexadecimal identities, while several genesis validation checks historically compared the textual representation directly. Hexadecimal casing does not change the represented bytes, so two spellings of the same address or fixed-size public key can bypass string-based uniqueness or role-separation checks.
6+
7+
## Affected validation paths
8+
9+
- Validator public-key uniqueness
10+
- Validator-registerer uniqueness
11+
- Controller uniqueness across validators
12+
- Operator/role versus proxy-admin separation
13+
- ValidatorRegistry proxy-address comparison
14+
- NativeFiatToken minter uniqueness
15+
16+
## Impact
17+
18+
This is a genesis/configuration integrity issue rather than a standalone permissionless runtime exploit. A malformed configuration could represent the same underlying identity more than once where validation intends uniqueness or role separation.
19+
20+
For NativeFiatToken minters, address-keyed storage is derived from the decoded address value. Case variants therefore resolve to the same mapping slots; conflicting allowance entries can overwrite one another during genesis allocation construction instead of being rejected as duplicate minters.
21+
22+
## Fix
23+
24+
Normalize hexadecimal identities only at comparison boundaries with `toLowerCase()`. Keep the original input unchanged for serialization and diagnostics. The shared proxy-admin helper normalizes both operands, and NativeFiatToken minter uniqueness normalizes the address used as the `Set` key.
25+
26+
## Regression coverage
27+
28+
Regression tests cover mixed-case collisions for:
29+
30+
- Validator public keys
31+
- Validator-registerer addresses
32+
- Controllers
33+
- Operator/proxy-admin role separation
34+
- NativeFiatToken minters
35+
36+
The tests also retain positive cases for valid distinct/compatible configurations.
37+
38+
## Validation
39+
40+
Focused tests:
41+
42+
```bash
43+
npx hardhat test ./tests/unit/validator-manager-genesis-validation.test.ts ./tests/unit/native-fiat-token-genesis-validation.test.ts --no-compile
44+
```
45+
46+
Repository validation:
47+
48+
```bash
49+
make test-unit-hardhat
50+
make lint
51+
```
52+
53+
The repository workflow does not currently invoke `make test-unit-hardhat`, and its ESLint command excludes `scripts/` and `tests/`, so local execution of the focused suite remains an explicit validation requirement.
54+
55+
## Scope
56+
57+
EIP-55 checksum enforcement is intentionally not part of this change. It would be a broader input-validation policy change and should be reviewed independently.

‎scripts/genesis/NativeFiatToken.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,15 +78,16 @@ export const schemaNativeFiatToken = z
7878
})),
7979
])
8080

81-
const minterSet = new Set()
81+
const minterSet = new Set<string>()
8282
for (const minter of data.minters) {
83-
if (minterSet.has(minter.address)) {
83+
const normalized = minter.address.toLowerCase()
84+
if (minterSet.has(normalized)) {
8485
ctx.addIssue({
8586
code: z.ZodIssueCode.custom,
8687
message: `Minter ${minter.address} must be unique`,
8788
})
8889
}
89-
minterSet.add(minter.address)
90+
minterSet.add(normalized)
9091
}
9192
})
9293

‎scripts/genesis/ValidatorManager.ts‎

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,18 @@ export const schemaValidatorManager = z
107107
message: 'At least one validator must have positive voting power',
108108
})
109109
}
110-
// Verify the public keys are unique.
111-
const publicKeySet = new Set()
110+
111+
// Verify the public keys are unique by their byte identity, not hex casing.
112+
const publicKeySet = new Set<string>()
112113
for (const validator of data.validators) {
113-
if (publicKeySet.has(validator.publicKey)) {
114+
const normalizedPublicKey = validator.publicKey.toLowerCase()
115+
if (publicKeySet.has(normalizedPublicKey)) {
114116
ctx.addIssue({
115117
code: z.ZodIssueCode.custom,
116118
message: `Public key ${validator.publicKey} must be unique`,
117119
})
118120
}
119-
publicKeySet.add(validator.publicKey)
121+
publicKeySet.add(normalizedPublicKey)
120122
}
121123

122124
const permissionedManager = data.PermissionedValidatorManager
@@ -138,29 +140,35 @@ export const schemaValidatorManager = z
138140
...flattenedControllers.map(({ key, address }) => ({ key, value: address })),
139141
])
140142

141-
// Verify addresses are unique for different roles.
142-
const validatorRegistererSet = new Set()
143+
// Verify addresses are unique for different roles by their byte identity.
144+
const validatorRegistererSet = new Set<string>()
143145
for (const validatorRegisterer of permissionedManager.validatorRegisterers) {
144-
if (validatorRegistererSet.has(validatorRegisterer)) {
146+
const normalizedValidatorRegisterer = validatorRegisterer.toLowerCase()
147+
if (validatorRegistererSet.has(normalizedValidatorRegisterer)) {
145148
ctx.addIssue({
146149
code: z.ZodIssueCode.custom,
147150
message: `ValidatorRegisterer ${validatorRegisterer} must be unique`,
148151
})
149152
}
150-
validatorRegistererSet.add(validatorRegisterer)
153+
validatorRegistererSet.add(normalizedValidatorRegisterer)
151154
}
152-
const controllerSet = new Set<Address>()
155+
156+
const controllerSet = new Set<string>()
153157
for (const { address, key } of flattenedControllers) {
154-
if (controllerSet.has(address)) {
158+
const normalizedAddress = address.toLowerCase()
159+
if (controllerSet.has(normalizedAddress)) {
155160
ctx.addIssue({
156161
code: z.ZodIssueCode.custom,
157162
message: `Controller ${address} (${key}) must be unique across all validators`,
158163
})
159164
}
160-
controllerSet.add(address)
165+
controllerSet.add(normalizedAddress)
161166
}
162167

163-
if (data.proxy.address != null && data.proxy.address !== DEFAULT_VALIDATOR_REGISTRY_PROXY_ADDRESS) {
168+
if (
169+
data.proxy.address != null &&
170+
data.proxy.address.toLowerCase() !== DEFAULT_VALIDATOR_REGISTRY_PROXY_ADDRESS.toLowerCase()
171+
) {
164172
// the ValidatorRegistry address is hardcoded in the PermissionedValidatorManager.
165173
ctx.addIssue({
166174
code: z.ZodIssueCode.custom,

‎scripts/genesis/types.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,9 @@ export const enforceOperatorsNotProxyAdmin = (
159159
proxyAdmin: Address,
160160
operators: ReadonlyArray<{ key: string; value: Address }>,
161161
) => {
162+
const normalizedProxyAdmin = proxyAdmin.toLowerCase()
162163
for (const { key, value } of operators) {
163-
if (value === proxyAdmin) {
164+
if (value.toLowerCase() === normalizedProxyAdmin) {
164165
ctx.addIssue({
165166
code: z.ZodIssueCode.custom,
166167
message: `Operator ${key} cannot be the same as the proxy admin of ${contractName}`,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright 2026 Circle Internet Group, Inc. All rights reserved.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
5+
import { expect } from 'chai'
6+
import { schemaNativeFiatToken } from '../../scripts/genesis/NativeFiatToken'
7+
8+
const PROXY_ADMIN = '0x1111111111111111111111111111111111111111'
9+
const OWNER = '0x2222222222222222222222222222222222222222'
10+
const PAUSER = '0x3333333333333333333333333333333333333333'
11+
const BLACKLISTER = '0x4444444444444444444444444444444444444444'
12+
const MASTER_MINTER = '0x5555555555555555555555555555555555555555'
13+
const RESCUER = '0x6666666666666666666666666666666666666666'
14+
15+
const configWithMinters = (minters: Array<{ address: string; allowance: bigint }>) => ({
16+
proxy: { admin: PROXY_ADMIN },
17+
owner: OWNER,
18+
pauser: PAUSER,
19+
blacklister: BLACKLISTER,
20+
masterMinter: MASTER_MINTER,
21+
rescuer: RESCUER,
22+
minters,
23+
})
24+
25+
describe('NativeFiatToken genesis validation', () => {
26+
it('rejects duplicate minters with identical casing', () => {
27+
const minter = '0xAb00000000000000000000000000000000000001'
28+
const result = schemaNativeFiatToken.safeParse(
29+
configWithMinters([
30+
{ address: minter, allowance: 100n },
31+
{ address: minter, allowance: 200n },
32+
]),
33+
)
34+
35+
expect(result.success).to.be.false
36+
})
37+
38+
it('rejects duplicate minters when address casing differs', () => {
39+
const minter = '0xAb00000000000000000000000000000000000001'
40+
const result = schemaNativeFiatToken.safeParse(
41+
configWithMinters([
42+
{ address: minter, allowance: 100n },
43+
{ address: minter.toLowerCase(), allowance: 200n },
44+
]),
45+
)
46+
47+
expect(result.success).to.be.false
48+
})
49+
50+
it('accepts distinct minter addresses', () => {
51+
const result = schemaNativeFiatToken.safeParse(
52+
configWithMinters([
53+
{ address: '0xAb00000000000000000000000000000000000001', allowance: 100n },
54+
{ address: '0xAb00000000000000000000000000000000000002', allowance: 200n },
55+
]),
56+
)
57+
58+
expect(result.success).to.be.true
59+
})
60+
61+
it('rejects a role colliding with the proxy admin when address casing differs', () => {
62+
const proxyAdmin = '0xAb00000000000000000000000000000000000003'
63+
const result = schemaNativeFiatToken.safeParse({
64+
...configWithMinters([]),
65+
proxy: { admin: proxyAdmin },
66+
owner: proxyAdmin.toLowerCase(),
67+
})
68+
69+
expect(result.success).to.be.false
70+
})
71+
})

‎tests/unit/validator-manager-genesis-validation.test.ts‎

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//
33
// SPDX-License-Identifier: Apache-2.0
44

5+
import { z } from 'zod'
56
import { expect } from 'chai'
67
import { schemaValidatorManager } from '../../scripts/genesis/ValidatorManager'
78

@@ -16,6 +17,8 @@ const CONTROLLER_B = '0x7777777777777777777777777777777777777777'
1617
const PUBLIC_KEY_A = `0x${'11'.repeat(32)}`
1718
const PUBLIC_KEY_B = `0x${'22'.repeat(32)}`
1819

20+
type ValidatorManagerConfig = z.infer<typeof schemaValidatorManager>
21+
1922
const validator = (publicKey: string, controller: string, votingPower: bigint) => ({
2023
publicKey,
2124
votingPower,
@@ -27,7 +30,10 @@ const validator = (publicKey: string, controller: string, votingPower: bigint) =
2730
],
2831
})
2932

30-
const configWithValidators = (validators: ReturnType<typeof validator>[]) => ({
33+
const configWithValidators = (
34+
validators: ReturnType<typeof validator>[],
35+
overrides: Partial<ValidatorManagerConfig> = {},
36+
) => ({
3137
proxy: {
3238
admin: REGISTRY_ADMIN,
3339
},
@@ -40,6 +46,7 @@ const configWithValidators = (validators: ReturnType<typeof validator>[]) => ({
4046
pauser: PAUSER,
4147
validatorRegisterers: [REGISTERER],
4248
},
49+
...overrides,
4350
})
4451

4552
describe('ValidatorManager genesis validator-set validation', () => {
@@ -70,4 +77,84 @@ describe('ValidatorManager genesis validator-set validation', () => {
7077

7178
expect(result.success).to.be.true
7279
})
80+
81+
it('rejects duplicate public keys when hexadecimal casing differs', () => {
82+
const publicKey = `0x${'Ab'.repeat(32)}`
83+
const result = schemaValidatorManager.safeParse(
84+
configWithValidators([validator(publicKey, CONTROLLER_A, 20n), validator(publicKey.toLowerCase(), CONTROLLER_B, 10n)]),
85+
)
86+
87+
expect(result.success).to.be.false
88+
})
89+
90+
it('rejects duplicate controllers when address casing differs', () => {
91+
const controller = '0xAb00000000000000000000000000000000000001'
92+
const result = schemaValidatorManager.safeParse(
93+
configWithValidators([validator(PUBLIC_KEY_A, controller, 20n), validator(PUBLIC_KEY_B, controller.toLowerCase(), 10n)]),
94+
)
95+
96+
expect(result.success).to.be.false
97+
})
98+
99+
it('rejects duplicate validator registerers when address casing differs', () => {
100+
const registerer = '0xAb00000000000000000000000000000000000002'
101+
const result = schemaValidatorManager.safeParse(
102+
configWithValidators([validator(PUBLIC_KEY_A, CONTROLLER_A, 20n)], {
103+
PermissionedValidatorManager: {
104+
proxy: { admin: PVM_ADMIN },
105+
owner: OWNER,
106+
pauser: PAUSER,
107+
validatorRegisterers: [registerer, registerer.toLowerCase()],
108+
},
109+
}),
110+
)
111+
112+
expect(result.success).to.be.false
113+
})
114+
115+
it('rejects an operator colliding with the proxy admin when address casing differs', () => {
116+
const proxyAdmin = '0xAb00000000000000000000000000000000000003'
117+
const result = schemaValidatorManager.safeParse(
118+
configWithValidators([validator(PUBLIC_KEY_A, CONTROLLER_A, 20n)], {
119+
PermissionedValidatorManager: {
120+
proxy: { admin: proxyAdmin },
121+
owner: proxyAdmin.toLowerCase(),
122+
pauser: PAUSER,
123+
validatorRegisterers: [REGISTERER],
124+
},
125+
}),
126+
)
127+
128+
expect(result.success).to.be.false
129+
})
130+
131+
it('rejects a controller colliding with the PVM proxy admin when address casing differs', () => {
132+
const proxyAdmin = '0xAb00000000000000000000000000000000000004'
133+
const result = schemaValidatorManager.safeParse(
134+
configWithValidators([validator(PUBLIC_KEY_A, proxyAdmin.toLowerCase(), 20n)], {
135+
PermissionedValidatorManager: {
136+
proxy: { admin: proxyAdmin },
137+
owner: OWNER,
138+
pauser: PAUSER,
139+
validatorRegisterers: [REGISTERER],
140+
},
141+
}),
142+
)
143+
144+
expect(result.success).to.be.false
145+
})
146+
147+
it('accepts a ValidatorRegistry proxy address when only hexadecimal casing differs', () => {
148+
const proxyAddress = '0x3600000000000000000000000000000000000002'
149+
const result = schemaValidatorManager.safeParse(
150+
configWithValidators([validator(PUBLIC_KEY_A, CONTROLLER_A, 20n)], {
151+
proxy: {
152+
address: proxyAddress.toUpperCase().replace('0X', '0x'),
153+
admin: REGISTRY_ADMIN,
154+
},
155+
}),
156+
)
157+
158+
expect(result.success).to.be.true
159+
})
73160
})

0 commit comments

Comments
 (0)