Skip to content

Add allowlist module for gating FCM contracts - #35

Merged
peterargue merged 28 commits into
mainfrom
claude/competent-feynman-005c18
May 26, 2026
Merged

Add allowlist module for gating FCM contracts#35
peterargue merged 28 commits into
mainfrom
claude/competent-feynman-005c18

Conversation

@peterargue

@peterargue peterargue commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Closes: #9

Summary

  • New IAllowlist interface (isAllowed(address) → bool + AddressAllowed/AddressDisallowed events) — intentionally narrow so any backing (mapping, merkle, signature, composite) can be swapped in by consumers.
  • New Allowlist contract: Ownable, mapping-backed, with allow / disallow / allowBatch / disallowBatch. Idempotent edits (no revert and no emit on no-op, matching OZ _grantRole semantics). ZeroAddress custom error guards the allow path.
  • 17 unit tests + 1 fuzz test (256 runs) covering state transitions, events, zero-address rejection, idempotency, batch behavior, owner gating on all four admin functions, and ownership transfer.
  • No vault wiring in this PRFCMVault.sol is untouched. Integration into the vault is a follow-up.

Integration

The intention is for a new Allowlist contract to be deployed for each unique list of address. So the vault could have shared/separate lists for deposits, share holders, etc. The vault can have modifiable IAllowlist implementation addresses, then set the address to address(0) to disable the list.

Test plan

  • make ci is green (fmt-check, build, test)
  • forge test passes 19/19 (1 existing FCMVaultTest + 18 new AllowlistTest)
  • forge fmt --check clean
  • No changes to FCMVault.sol

Comment thread solidity/src/access/Allowlist.sol Outdated
Comment on lines +7 to +11
/// @title Allowlist
/// @notice Mapping-backed allowlist of addresses, administered by a single owner.
/// @dev Idempotent edits: re-adding an existing entry (or removing an absent
/// one) does not revert and does not emit. Matches OpenZeppelin
/// `_grantRole` semantics.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

is there a convention we want to follow for contract/function docs?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks like this is the recommended format: https://docs.soliditylang.org/en/latest/natspec-format.html#natspec. Which I think you're more or less following already here.

Comment thread solidity/test/Allowlist.t.sol Outdated
Comment thread solidity/script/get-allowlist.sh Outdated
Comment on lines +38 to +40
get-allowlist.sh --address 0xABC... --rpc-url https://testnet.evm.nodes.onflow.org \\
--blockscout-url ""
EOF

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do you think this script would hit usage limits if the contract was deployed months in the past?

@peterargue peterargue May 20, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

maybe. I'm going to switch it over to an enumerable set. it roughly double write time gas without impacting reads, but allows us to drop the complex script for minimal extra solidity

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think a normal mapping would be sufficient here. We should never need a list of all members onchain.
Its unlikely we even need a list offchain, but if we do the typical way of doing it would be to reconstruct it using events.

Comment thread solidity/src/access/Allowlist.sol Outdated
Comment on lines +7 to +11
/// @title Allowlist
/// @notice Mapping-backed allowlist of addresses, administered by a single owner.
/// @dev Idempotent edits: re-adding an existing entry (or removing an absent
/// one) does not revert and does not emit. Matches OpenZeppelin
/// `_grantRole` semantics.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks like this is the recommended format: https://docs.soliditylang.org/en/latest/natspec-format.html#natspec. Which I think you're more or less following already here.

Comment thread solidity/src/access/Allowlist.sol Outdated
@holyfuchs

holyfuchs commented May 21, 2026

Copy link
Copy Markdown
Member

I think it might be simpler to use:
https://docs.openzeppelin.com/contracts/5.x/access-control#role-based-access-control
It should provide the same functionality but its less code we have to audit.

pragma solidity ^0.8.20;
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract AccessControlERC20Mint is ERC20, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
    constructor(address minter, address burner) ERC20("MyToken", "TKN") {
        _grantRole(MINTER_ROLE, minter);
        _grantRole(BURNER_ROLE, burner);
    }
    function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }
    function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) {
        _burn(from, amount);
    }
}

Comment thread solidity/src/access/Allowlist.sol Outdated
contract Allowlist is IAllowlistEnumerable, Ownable {
using EnumerableSet for EnumerableSet.AddressSet;

EnumerableSet.AddressSet private _allowed;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FWIW- Using EnumerableSet (instead of just a dictionary of bools) adds a tiny amount of storage overhead and code complexity while not being strictly necessary. I'm not opposed (especially since the code is already written!), but it would be less code and fewer tests without enumeration support... 🤔

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I originally implemented it this way. The reason I changed was because I think we will ultimately want a way to view the list of allowed addresses. If we use a dictionary, then we need to consume events to rebuild the list. this is much more complex than using EnumerableSet, so moving a small amount of complexity/storage into the contract temporarily seemed like the pragmatic choice.

Comment thread solidity/src/access/Allowlist.sol Outdated
/// @dev Idempotent edits: re-adding an existing entry (or removing an absent
/// one) does not revert and does not emit. Matches OpenZeppelin
/// `_grantRole` semantics.
contract Allowlist is IAllowlistEnumerable, Ownable {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Strikes me that the most common way for the allow list to be used is via a modifier like onlyAllowed or similar. (Just like onlyOwner from Ownable.) As such, defining that modifier in this contract would be pretty useful!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

good idea

@peterargue

Copy link
Copy Markdown
Collaborator Author

I think it might be simpler to use: docs.openzeppelin.com/contracts/5.x/access-control#role-based-access-control It should provide the same functionality but its less code we have to audit.

pragma solidity ^0.8.20;
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract AccessControlERC20Mint is ERC20, AccessControl {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
    constructor(address minter, address burner) ERC20("MyToken", "TKN") {
        _grantRole(MINTER_ROLE, minter);
        _grantRole(BURNER_ROLE, burner);
    }
    function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
        _mint(to, amount);
    }
    function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) {
        _burn(from, amount);
    }
}

That's fair. under the covers, they are doing pretty much the same thing. I think the main benefit of using a separate contract is that we could reuse it for multiple vaults or contracts without duplicating the list in multiple places. if we don't think that's going to be needed, AccessControl is simpler.

2 general question @holyfuchs :

  1. do you think we'll want to share the same list across multiple contracts?
  2. would we want to be able to disable the allowlist, or are we planning to just deprecate and redeploy the vault with the list removed?

@holyfuchs

Copy link
Copy Markdown
Member

I think the main benefit of using a separate contract is that we could reuse it for multiple vaults or contracts without duplicating the list in multiple places.

https://docs.openzeppelin.com/contracts/5.x/access-control#access-management
They have a contract for that as well :)

do you think we'll want to share the same list across multiple contracts?

Probably not for now. If we ever need it switching to the AccessManager should be easy.

would we want to be able to disable the allowlist, or are we planning to just deprecate and redeploy the vault with the list removed?

I would just deprecate and redeploy. We will most likely redeploy a lot of versions before we ever get to the point of removing the access control.

Comment thread solidity/src/FCMVault.sol Outdated
contract FCMVault is ERC4626, AccessControl {
/// @notice Members of this role may deposit assets, hold shares, and
/// transfer shares.
bytes32 public constant ALLOWED_ROLE = keccak256("ALLOWED_ROLE");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
bytes32 public constant ALLOWED_ROLE = keccak256("ALLOWED_ROLE");
bytes32 public constant EARLY_ACCESS_ROLE = keccak256("EARLY_ACCESS_ROLE");

@peterargue
peterargue merged commit 8a34fce into main May 26, 2026
2 checks passed
@peterargue
peterargue deleted the claude/competent-feynman-005c18 branch May 26, 2026 16:05
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.

Allowlist

4 participants