diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b655f8fa..cd66d4d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -232,6 +232,15 @@ jobs: - name: Init public submodules run: git submodule update --init contracts/lib/forge-std contracts/lib/openzeppelin-contracts contracts/lib/openzeppelin-contracts-upgradeable + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + cache: npm + + - name: Install Node dependencies + run: npm ci + - name: Read Foundry version id: foundry-version run: echo "version=$(cat .foundry-version)" >> $GITHUB_OUTPUT @@ -244,6 +253,9 @@ jobs: - name: Run Foundry tests run: forge test -vvv --gas-report + - name: Run Hardhat unit tests + run: make test-unit-hardhat + # --------------------------------------------------------------------------- # Docker Images # --------------------------------------------------------------------------- diff --git a/scripts/genesis/genesis.ts b/scripts/genesis/genesis.ts index d5444923..2c81f052 100644 --- a/scripts/genesis/genesis.ts +++ b/scripts/genesis/genesis.ts @@ -176,11 +176,14 @@ export const buildGenesis = async (ctx: BuilderContext, config: GenesisConfig) = } = parsed const allocs: Record = {} + const allocatedAddresses = new Set() const insert = ([account, alloc]: [string, GenesisAccountAlloc]) => { - if (account in allocs) { + const normalizedAddress = account.toLowerCase() + if (allocatedAddresses.has(normalizedAddress)) { throw new Error(`Duplicate account: ${account}`) } allocs[schemaAddress.parse(account)] = alloc + allocatedAddresses.add(normalizedAddress) } Object.entries(await buildNativeFiatTokenGenesisAllocs(ctx, nativeFiatToken)).forEach(insert) diff --git a/tests/unit/genesis-account-collisions.test.ts b/tests/unit/genesis-account-collisions.test.ts new file mode 100644 index 00000000..8bfb198a --- /dev/null +++ b/tests/unit/genesis-account-collisions.test.ts @@ -0,0 +1,136 @@ +// Copyright 2026 Circle Internet Group, Inc. All rights reserved. +// +// SPDX-License-Identifier: Apache-2.0 + +import { strict as assert } from 'node:assert' +import { Address, fromHex, getAddress, toHex } from 'viem' +import { nativeCoinAutorityAddress } from '../../scripts/genesis/addresses' +import { BuilderContext, ContractLoader } from '../../scripts/genesis/context' +import { buildGenesis, externalContracts, GenesisConfig } from '../../scripts/genesis/genesis' +import { slotIndex } from '../../scripts/genesis/types' + +const admin = '0x1111111111111111111111111111111111111111' +const operator = '0x2222222222222222222222222222222222222222' +const account = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' +const checksumAccount = getAddress(account) +const otherAccount = '0xabcdefabcdefabcdefabcdefabcdefabcdefabce' +const implementationAddress = '0xabcdef0000000000000000000000000000000001' + +function context(): BuilderContext { + return { + network: 'localdev', + chainId: 1337, + projectRoot: process.cwd(), + // Only artifact loading is stubbed; all genesis allocation builders run normally. + contractLoader: new ContractLoader( + { + SignatureChecker: { address: toHex(0x1001n, { size: 20 }), code: '0x00' }, + NativeFiatTokenV2_2: { address: implementationAddress, code: '0x00' }, + FiatTokenProxy: { address: toHex(0x1003n, { size: 20 }), code: '0x00' }, + ProtocolConfig: { address: toHex(0x1004n, { size: 20 }), code: '0x00' }, + ValidatorRegistry: { address: toHex(0x1005n, { size: 20 }), code: '0x00' }, + PermissionedValidatorManager: { address: toHex(0x1006n, { size: 20 }), code: '0x00' }, + AdminUpgradeableProxy: { address: toHex(0x1007n, { size: 20 }), code: '0x00' }, + }, + {}, + ), + } +} + +function config(prefund: Array<{ address: Address; balance: bigint }>): GenesisConfig { + return { + timestamp: 0n, + coinbase: operator, + prefund, + NativeFiatToken: { + proxy: { admin }, + owner: operator, + pauser: operator, + blacklister: operator, + masterMinter: operator, + rescuer: operator, + minters: [], + }, + ProtocolConfig: { + proxy: { admin }, + owner: operator, + controller: operator, + pauser: operator, + feeParams: { + alpha: 1n, + kRate: 1n, + inverseElasticityMultiplier: 1n, + minBaseFee: 0n, + maxBaseFee: 1_000_000n, + blockGasLimit: 30_000_000n, + }, + }, + ValidatorManager: { + proxy: { admin }, + validators: [ + { + publicKey: `0x${'11'.repeat(32)}`, + votingPower: 20n, + controllers: [{ address: operator, votingPowerLimit: 100n }], + }, + ], + PermissionedValidatorManager: { + proxy: { admin }, + owner: operator, + pauser: operator, + validatorRegisterers: [operator], + }, + }, + ...Object.fromEntries(externalContracts.map((name) => [name, false])), + } +} + +describe('genesis account collisions', () => { + for (const addresses of [ + [account, checksumAccount], + [checksumAccount, account], + [account, account], + ] as const) { + it(`rejects duplicate prefunds ${addresses.join(' / ')}`, async () => { + await assert.rejects(buildGenesis(context(), config(addresses.map((address) => ({ address, balance: 10n })))), { + message: `Duplicate account: ${addresses[1]}`, + }) + }) + } + + it('rejects a prefund alias of a contract implementation', async () => { + const address = getAddress(implementationAddress) + assert.notEqual(address, implementationAddress) + await assert.rejects(buildGenesis(context(), config([{ address, balance: 10n }])), { + message: `Duplicate account: ${address}`, + }) + }) + + it('rejects a prefund alias of a reserved precompile', async () => { + const address = '0x180000000000000000000000000000000000000A' + await assert.rejects(buildGenesis(context(), config([{ address, balance: 10n }])), { + message: `Duplicate account: ${address}`, + }) + }) + + it('preserves distinct accounts, their casing, contract code, and total supply', async () => { + const genesis = await buildGenesis( + context(), + config([ + { address: checksumAccount, balance: 10n }, + { address: otherAccount, balance: 20n }, + ]), + ) + + assert.equal(genesis.alloc[checksumAccount].balance, '0xa') + assert.equal(genesis.alloc[otherAccount].balance, '0x14') + assert.equal(genesis.alloc[account], undefined) + assert.equal(genesis.alloc[implementationAddress].code, '0x00') + assert.equal(genesis.alloc[implementationAddress].nonce, '0x1') + const supply = genesis.alloc[nativeCoinAutorityAddress].storage?.[slotIndex(2n)] + assert.ok(supply) + assert.equal(fromHex(supply, 'bigint'), 30n) + const addresses = Object.keys(genesis.alloc).map((address) => address.toLowerCase()) + assert.equal(new Set(addresses).size, addresses.length) + }) +})