Skip to content

feat: add exhaustive event-based validation for non-enumerable OZ ACL#60

Draft
TheDZhon wants to merge 5 commits intomainfrom
feat/non-enumerable-oz-proxy
Draft

feat: add exhaustive event-based validation for non-enumerable OZ ACL#60
TheDZhon wants to merge 5 commits intomainfrom
feat/non-enumerable-oz-proxy

Conversation

@TheDZhon
Copy link
Contributor

Summary

Add optional exhaustive checking for non-enumerable OpenZeppelin AccessControl contracts by scanning RoleGranted/RoleRevoked events from contract deployment to detect unknown role holders that cannot be discovered via view functions alone.

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 (stored in cache/ directory)
  • Incremental scanning support for subsequent runs
  • Detection of unexpected role holders not in configuration

New files

  • src/cache-provider.ts - Caching infrastructure for creation blocks and event scans
  • src/event-scanner.ts - Event scanning and parsing logic for RoleGranted/RoleRevoked
  • src/__tests__/*.test.ts - Unit tests (33 tests)
  • vitest.config.mts - Vitest test configuration
  • schemas/*.json - Updated JSON schemas with new options field

Usage

ozNonEnumerableAcl:
  *DEFAULT_ADMIN_ROLE: [*agent]
  *DEPOSITS_ENABLER_ROLE: [*agent]
ozNonEnumerableAclOptions:
  exhaustive: true          # Enable event scanning
  eventBatchSize: 3000      # Optional, default 5000

How it works

  1. Fetches contract creation block from Etherscan API (cached)
  2. Scans all RoleGranted/RoleRevoked events from deployment to current block
  3. Builds a complete map of current role holders from event history
  4. Compares against config - flags any unexpected holders as errors
  5. Falls back to non-exhaustive check if explorer info unavailable

Test plan

  • yarn build - TypeScript compiles without errors
  • yarn test:unit - All 33 unit tests pass
  • yarn lint - No linting errors
  • yarn format - Code properly formatted
  • JSON schemas generated with new fields
  • Manual test with exhaustive mode on real config

🤖 Generated with Claude Code

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>
@TheDZhon TheDZhon force-pushed the feat/non-enumerable-oz-proxy branch from 359a4d0 to b15a3e4 Compare January 16, 2026 23:25
TheDZhon and others added 4 commits January 16, 2026 23:31
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>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 ozNonEnumerableAclOptions with exhaustive and eventBatchSize fields

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.

Comment on lines +128 to +142
### 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.

````
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +211
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);
}
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +159
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;
}
}
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +139 to +151
// 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;
}
}
Copy link

Copilot AI Jan 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant