feat: add exhaustive event-based validation for non-enumerable OZ ACL#60
feat: add exhaustive event-based validation for non-enumerable OZ ACL#60
Conversation
Add optional exhaustive checking for non-enumerable OpenZeppelin AccessControl contracts by scanning RoleGranted/RoleRevoked events from contract deployment to detect unknown role holders. Features: - New `ozNonEnumerableAclOptions` config field with `exhaustive` and `eventBatchSize` options - Event scanning from contract deployment block to current block - Caching for contract creation blocks and event scan results - Incremental scanning support for subsequent runs - Detection of unexpected role holders not in configuration New files: - src/cache-provider.ts - Caching infrastructure - src/event-scanner.ts - Event scanning and parsing logic - src/__tests__/*.test.ts - Unit tests (33 tests) - vitest.config.mts - Test configuration Usage: ```yaml ozNonEnumerableAcl: *ROLE_ID: [*holder1, *holder2] ozNonEnumerableAclOptions: exhaustive: true eventBatchSize: 5000 # optional, default 5000 ``` Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
359a4d0 to
b15a3e4
Compare
Add options to disable caching and clear the cache directory for exhaustive ACL checks, making the cache easy to prune or disable. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Change caching from opt-out (--no-cache) to opt-in (--cache) so users must explicitly enable caching for exhaustive ACL checks. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Create a standalone `yarn clear-cache` command instead of embedding cache clearing in the main state-mate script. This provides better separation of concerns between validation and maintenance operations. Usage: yarn clear-cache path/to/config.yaml Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds optional exhaustive event-based validation for non-enumerable OpenZeppelin AccessControl contracts. The feature scans RoleGranted/RoleRevoked events from contract deployment to detect all current role holders, flagging any that aren't explicitly configured.
Changes:
- New event scanning infrastructure to build complete role holder maps from blockchain events
- Caching system for contract creation blocks and event scan results to improve performance
- Schema extension to support
ozNonEnumerableAclOptionswithexhaustiveandeventBatchSizefields
Reviewed changes
Copilot reviewed 16 out of 18 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| vitest.config.mts | Test configuration for new unit tests |
| src/typebox.ts | Added OzNonEnumerableAclOptions schema definition |
| src/state-mate.ts | Integrated cache initialization and explorer info passing |
| src/section-validators/oz-non-enumerable-acl.ts | Implemented exhaustive validation via event scanning |
| src/section-validators/contract.ts | Updated to pass explorer info to validator |
| src/explorers/etherscan.ts | Added contract creation block fetching from Etherscan API |
| src/explorer-provider.ts | Added getContractCreationBlock function with caching |
| src/event-scanner.ts | Core event scanning and parsing logic |
| src/cli-parser.ts | Added --cache CLI flag |
| src/clear-cache.ts | Utility script for clearing cache |
| src/cache-provider.ts | Complete caching infrastructure |
| src/tests/*.test.ts | Unit tests for schemas, event scanner, and cache provider |
| package.json | Added vitest dependency and test scripts |
| yarn.lock | Dependency updates for vitest and related packages |
| README.md | Documentation for new exhaustive checking feature |
| .gitignore | Exclude auto-generated schemas directory |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ### Exhaustive non-enumerable OZ ACL checks | ||
|
|
||
| For `ozNonEnumerableAcl`, you can opt into exhaustive checks that scan role events to detect every current role holder: | ||
|
|
||
| ```yaml | ||
| ozNonEnumerableAclOptions: | ||
| exhaustive: true | ||
| eventBatchSize: 5000 | ||
| ```` | ||
|
|
||
| When `exhaustive: true`, any role that appears in events but is **not** listed in `ozNonEnumerableAcl` is treated as an error. Make sure all roles that can exist on the contract are present in the config. | ||
|
|
||
| Use `--cache` to enable local caching for event scans, and adjust `eventBatchSize` if your RPC provider enforces stricter log limits. | ||
|
|
||
| ```` |
There was a problem hiding this comment.
The documentation for the exhaustive feature is incomplete. It doesn't explain when users should use exhaustive mode vs non-exhaustive mode, the performance implications, or the requirement that explorerHostname and chainId must be configured for exhaustive checks to work. Consider adding a note that exhaustive mode requires explorer configuration and explain that it scans from contract deployment to detect all role holders including unexpected ones.
| private async _validateExhaustive(contractEntry: ContractEntry) { | ||
| const { address } = contractEntry; | ||
| const eventAddress = address; // Events are emitted from proxy address where storage lives | ||
| let batchSize = contractEntry.ozNonEnumerableAclOptions?.eventBatchSize ?? DEFAULT_EVENT_BATCH_SIZE; | ||
|
|
||
| if (!Number.isInteger(batchSize) || batchSize <= 0) { | ||
| log(`${WARNING_MARK}: Invalid eventBatchSize "${batchSize}". Using default ${DEFAULT_EVENT_BATCH_SIZE} instead.`); | ||
| batchSize = DEFAULT_EVENT_BATCH_SIZE; | ||
| } | ||
|
|
||
| log(` Performing exhaustive ACL check via event scanning...`); | ||
|
|
||
| // Check if we have explorer info for fetching creation block | ||
| if (!this.explorerInfo?.hostname) { | ||
| log( | ||
| `${WARNING_MARK}: No explorer configured. Cannot perform exhaustive check. Falling back to non-exhaustive check.`, | ||
| ); | ||
| await this._validate(contractEntry); | ||
| return; | ||
| } | ||
|
|
||
| // Get contract creation block | ||
| const creationInfo = await getContractCreationBlock( | ||
| eventAddress, | ||
| this.explorerInfo.hostname, | ||
| this.provider, | ||
| this.explorerInfo.key, | ||
| this.explorerInfo.chainId, | ||
| ); | ||
|
|
||
| if (!creationInfo) { | ||
| log(`${WARNING_MARK}: Could not determine contract creation block. Falling back to non-exhaustive check.`); | ||
| await this._validate(contractEntry); | ||
| return; | ||
| } | ||
|
|
||
| log(` Contract deployed at block ${creationInfo.blockNumber}`); | ||
|
|
||
| // Get current block | ||
| const currentBlock = await this.provider.getBlockNumber(); | ||
|
|
||
| // Check cache and perform incremental scan if needed | ||
| let roleHolders: RoleHoldersMap; | ||
| const lastScannedBlock = getLastScannedBlock(eventAddress); | ||
| const cachedRoleHolders = getCachedRoleHolders(eventAddress); | ||
|
|
||
| if (cachedRoleHolders && lastScannedBlock !== null && lastScannedBlock >= currentBlock) { | ||
| log(` Using cached event scan results (up to block ${lastScannedBlock})`); | ||
| roleHolders = cachedRoleHolders; | ||
| } else if (cachedRoleHolders && lastScannedBlock !== null && lastScannedBlock >= creationInfo.blockNumber) { | ||
| // Incremental scan from last scanned block | ||
| log(` Performing incremental scan from block ${lastScannedBlock + 1} to ${currentBlock}...`); | ||
| const { events, toBlock } = await scanRoleEvents(this.provider, eventAddress, { | ||
| batchSize, | ||
| fromBlock: lastScannedBlock + 1, | ||
| toBlock: currentBlock, | ||
| }); | ||
| log(` Found ${events.length} new role events`); | ||
|
|
||
| roleHolders = mergeRoleHolders(cachedRoleHolders, events); | ||
| saveEventScanToCache(eventAddress, roleHolders, toBlock); | ||
| flushCacheUpdates(); | ||
| } else { | ||
| // Full scan from deployment | ||
| log(` Scanning events from block ${creationInfo.blockNumber} to ${currentBlock}...`); | ||
| const { events, toBlock } = await scanRoleEvents(this.provider, eventAddress, { | ||
| batchSize, | ||
| fromBlock: creationInfo.blockNumber, | ||
| toBlock: currentBlock, | ||
| }); | ||
| log(` Found ${events.length} role events`); | ||
|
|
||
| roleHolders = buildRoleHoldersFromEvents(events); | ||
| saveEventScanToCache(eventAddress, roleHolders, toBlock); | ||
| flushCacheUpdates(); | ||
| } | ||
|
|
||
| // Compare with config | ||
| await this._compareRoleHolders(contractEntry, roleHolders); | ||
| } |
There was a problem hiding this comment.
The new exhaustive validation feature in oz-non-enumerable-acl.ts lacks test coverage. The _validateExhaustive method, getContractCreationBlock function, and getContractCreation function have no unit tests. Given that these are critical functions that interact with external APIs and handle complex event scanning logic, they should have comprehensive test coverage similar to the other test files in this PR.
| export async function getContractCreation( | ||
| address: string, | ||
| explorerHostname: string, | ||
| explorerKey?: string, | ||
| chainId?: number | string, | ||
| ): Promise<ContractCreationResult | null> { | ||
| const url = buildContractCreationApiUrl(address, explorerHostname, explorerKey, chainId); | ||
|
|
||
| try { | ||
| let response = await httpGetAsync<ContractCreationResponse>(url); | ||
|
|
||
| // Handle rate limiting | ||
| if ( | ||
| response.status === "0" && | ||
| (response.message?.includes("rate limit") || response.result?.toString().includes("rate limit")) | ||
| ) { | ||
| log(` Reached rate limit, waiting ${RATE_LIMIT_TIMEOUT_MS}ms...`); | ||
| await sleep(RATE_LIMIT_TIMEOUT_MS); | ||
| response = await httpGetAsync<ContractCreationResponse>(url); | ||
| } | ||
|
|
||
| if (response.status !== "1" || !Array.isArray(response.result) || response.result.length === 0) { | ||
| logError(`Failed to get contract creation info: ${response.message || "Unknown error"}`); | ||
| return null; | ||
| } | ||
|
|
||
| const result = response.result[0]; | ||
| return { | ||
| txHash: result.txHash, | ||
| deployer: result.contractCreator, | ||
| }; | ||
| } catch (error) { | ||
| logError(`Error fetching contract creation info: ${(error as Error).message}`); | ||
| return null; | ||
| } | ||
| } |
There was a problem hiding this comment.
The getContractCreation function in explorers/etherscan.ts lacks test coverage. This function makes external API calls, handles rate limiting, and has multiple error paths that should be tested. Consider adding unit tests with mocked HTTP responses to cover success cases, rate limit handling, and error scenarios.
| // If batch is too large, try smaller batches | ||
| if (batchSize > 1000 && (error as Error).message?.includes("query returned more than")) { | ||
| log(` Batch too large, retrying with smaller batch size...`); | ||
| const smallerResult = await scanRoleEvents(provider, contractAddress, { | ||
| batchSize: Math.floor(batchSize / 2), | ||
| fromBlock: start, | ||
| toBlock: end, | ||
| }); | ||
| allEvents.push(...smallerResult.events); | ||
| } else { | ||
| throw error; | ||
| } | ||
| } |
There was a problem hiding this comment.
The recursive error handling in scanRoleEvents at line 142 could lead to excessive recursion if the RPC provider consistently returns errors about query size. Consider adding a depth limit or maximum retry counter to prevent stack overflow in worst-case scenarios. For example, add a maxRetries parameter that defaults to 3 and decrements on each recursive call.
Summary
Add optional exhaustive checking for non-enumerable OpenZeppelin AccessControl contracts by scanning
RoleGranted/RoleRevokedevents from contract deployment to detect unknown role holders that cannot be discovered via view functions alone.Features
ozNonEnumerableAclOptionsconfig field withexhaustiveandeventBatchSizeoptionscache/directory)New files
src/cache-provider.ts- Caching infrastructure for creation blocks and event scanssrc/event-scanner.ts- Event scanning and parsing logic for RoleGranted/RoleRevokedsrc/__tests__/*.test.ts- Unit tests (33 tests)vitest.config.mts- Vitest test configurationschemas/*.json- Updated JSON schemas with new options fieldUsage
How it works
RoleGranted/RoleRevokedevents from deployment to current blockTest plan
yarn build- TypeScript compiles without errorsyarn test:unit- All 33 unit tests passyarn lint- No linting errorsyarn format- Code properly formatted🤖 Generated with Claude Code