diff --git a/scripts/genesis/ValidatorManager.ts b/scripts/genesis/ValidatorManager.ts index 2c35fac2..713546cd 100644 --- a/scripts/genesis/ValidatorManager.ts +++ b/scripts/genesis/ValidatorManager.ts @@ -100,6 +100,13 @@ export const schemaValidatorManager = z }) .strict() .superRefine((data, ctx) => { + if (!data.validators.some((validator) => validator.votingPower > 0n)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['validators'], + message: 'At least one validator must have positive voting power', + }) + } // Verify the public keys are unique. const publicKeySet = new Set() for (const validator of data.validators) { diff --git a/tests/unit/validator-manager-genesis-validation.test.ts b/tests/unit/validator-manager-genesis-validation.test.ts new file mode 100644 index 00000000..4ab6d5fd --- /dev/null +++ b/tests/unit/validator-manager-genesis-validation.test.ts @@ -0,0 +1,73 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +import { expect } from 'chai' +import { schemaValidatorManager } from '../../scripts/genesis/ValidatorManager' + +const REGISTRY_ADMIN = '0x1111111111111111111111111111111111111111' +const PVM_ADMIN = '0x2222222222222222222222222222222222222222' +const OWNER = '0x3333333333333333333333333333333333333333' +const PAUSER = '0x4444444444444444444444444444444444444444' +const REGISTERER = '0x5555555555555555555555555555555555555555' +const CONTROLLER_A = '0x6666666666666666666666666666666666666666' +const CONTROLLER_B = '0x7777777777777777777777777777777777777777' + +const PUBLIC_KEY_A = `0x${'11'.repeat(32)}` +const PUBLIC_KEY_B = `0x${'22'.repeat(32)}` + +const validator = (publicKey: string, controller: string, votingPower: bigint) => ({ + publicKey, + votingPower, + controllers: [ + { + address: controller, + votingPowerLimit: 100n, + }, + ], +}) + +const configWithValidators = (validators: ReturnType[]) => ({ + proxy: { + admin: REGISTRY_ADMIN, + }, + validators, + PermissionedValidatorManager: { + proxy: { + admin: PVM_ADMIN, + }, + owner: OWNER, + pauser: PAUSER, + validatorRegisterers: [REGISTERER], + }, +}) + +describe('ValidatorManager genesis validator-set validation', () => { + it('rejects an empty validator set', () => { + const result = schemaValidatorManager.safeParse(configWithValidators([])) + + expect(result.success).to.be.false + }) + + it('rejects a validator set with no positive voting power', () => { + const result = schemaValidatorManager.safeParse( + configWithValidators([validator(PUBLIC_KEY_A, CONTROLLER_A, 0n), validator(PUBLIC_KEY_B, CONTROLLER_B, 0n)]), + ) + + expect(result.success).to.be.false + }) + + it('accepts a validator set with positive voting power', () => { + const result = schemaValidatorManager.safeParse(configWithValidators([validator(PUBLIC_KEY_A, CONTROLLER_A, 20n)])) + + expect(result.success).to.be.true + }) + + it('accepts zero-power validators when another validator has positive power', () => { + const result = schemaValidatorManager.safeParse( + configWithValidators([validator(PUBLIC_KEY_A, CONTROLLER_A, 0n), validator(PUBLIC_KEY_B, CONTROLLER_B, 20n)]), + ) + + expect(result.success).to.be.true + }) +})