AccountManager
AccountManager.sol is the on-chain KYC / allowlist contract. It maintains a registry of approved accounts — both EOA wallets and multi-sig contracts — with lifecycle states (INACTIVE → ACTIVE → SUSPENDED / REMOVED). Other contracts (AssetRegistry, RWAToken) query it before allowing sensitive operations.
Source: contracts/core/AccountManager.sol.
Roles
| Role | keccak256 constant | Who holds it | What it allows |
|---|---|---|---|
DEFAULT_ADMIN_ROLE | (OZ default) | Deployer / admin multisig | Grant/revoke all roles |
ADMIN_ROLE | keccak256("ADMIN_ROLE") | Oracle / admin | removeAccount, grantAccountRole, revokeAccountRole, setMaxAccountsPerRole, upgrades |
ACCOUNT_MANAGER_ROLE | keccak256("ACCOUNT_MANAGER_ROLE") | Oracle / ops | addAccount, activateAccount, suspendAccount, updateAccountMetadata |
Account Types
enum AccountType {
NONE, // Not registered
EOA, // Externally Owned Account
MULTISIG // Multi-signature contract (Gnosis Safe, etc.)
}
When accountType == MULTISIG, addAccount enforces that the address has contract code (extcodesize > 0). EOA addresses must have no code.
Account Status Lifecycle
enum AccountStatus {
INACTIVE, // Registered but not yet activated
ACTIVE, // Operational — can use AssetRegistry / RWAToken
SUSPENDED, // Temporarily blocked — can be reactivated
REMOVED // Permanently removed — irreversible
}
addAccount()
│
▼
INACTIVE ──activateAccount()──► ACTIVE
│
suspendAccount()
│
▼
SUSPENDED ──activateAccount()──► ACTIVE
│
removeAccount() (ADMIN_ROLE only)
│
▼
REMOVED (terminal)
ACTIVE is the only status that passes the isAccountActive check used by AssetRegistry and RWAToken.
Account Struct
struct Account {
address accountAddress;
AccountType accountType;
AccountStatus status;
string name; // Human-readable label (registry name, org, etc.)
bytes32[] roles; // Application-level roles tracked by this contract
uint256 createdAt;
uint256 updatedAt;
address createdBy;
string metadata; // Optional JSON metadata
}
Key Functions
addAccount
function addAccount(
address _accountAddress,
AccountType _accountType,
string memory _name,
string memory _metadata
) external onlyRole(ACCOUNT_MANAGER_ROLE)
Registers a new account in INACTIVE status. Requirements:
- Address must not already be registered.
_accountTypemust beEOAorMULTISIG(notNONE)._namemust be non-empty.MULTISIGaccounts must have contract code at the address.
Emits AccountAdded.
activateAccount
function activateAccount(address _accountAddress)
external onlyRole(ACCOUNT_MANAGER_ROLE)
Transitions status to ACTIVE. Account must exist, must not be REMOVED, and must not already be ACTIVE. Emits AccountActivated.
suspendAccount
function suspendAccount(address _accountAddress, string memory _reason)
external onlyRole(ACCOUNT_MANAGER_ROLE)
Transitions an ACTIVE account to SUSPENDED. Account can be reactivated later. Emits AccountSuspended.
removeAccount
function removeAccount(address _accountAddress, string memory _reason)
external onlyRole(ADMIN_ROLE)
Permanently removes an account. Revokes all tracked roles and clears the roles array. Requires ADMIN_ROLE (stricter than suspend). Emits AccountRemoved.
grantAccountRole / revokeAccountRole
function grantAccountRole(address _accountAddress, bytes32 _role)
external onlyRole(ADMIN_ROLE)
function revokeAccountRole(address _accountAddress, bytes32 _role)
external onlyRole(ADMIN_ROLE)
Tracks application-level roles inside the Account.roles array and the roleAccounts mapping. The account must be ACTIVE to receive a role. Role count per role type is capped at maxAccountsPerRole (default: 100).
These roles are separate from the OpenZeppelin AccessControl roles — they are application metadata for querying "all accounts with role X".
Emits AccountRoleGranted / AccountRoleRevoked.
updateAccountMetadata
function updateAccountMetadata(address _accountAddress, string memory _metadata)
external onlyRole(ACCOUNT_MANAGER_ROLE)
Updates the free-form JSON metadata field. Account must exist. Emits AccountMetadataUpdated.
View Functions
| Function | Returns |
|---|---|
isAccountActive(address) | true if status == ACTIVE |
isAccountSuspended(address) | true if status == SUSPENDED |
isAccountRemoved(address) | true if status == REMOVED |
getAccount(address) | Full Account struct |
getAccountsByRole(bytes32) | All addresses (any status) tagged with a role |
getActiveAccountsByRole(bytes32) | Only ACTIVE addresses tagged with a role |
getAccountRoles(address) | bytes32[] of roles for an account |
hasAccountRole(address, bytes32) | Whether an account has a specific role |
getTotalAccounts() | Total number of registered accounts (includes removed) |
Integration: How Other Contracts Use AccountManager
AssetRegistry
When accountManager != address(0), the onlyActiveAccount modifier checks:
require(IAccountManager(accountManager).isAccountActive(msg.sender), "Account not active");
Applied to: submitCertificate, approveCertificate.
RWAToken (canTransact)
function canTransact(address account) public view returns (bool) {
if (accountManager == address(0)) return true; // open mode
return IAccountManager(accountManager).isAccountActive(account)
&& !IAccountManager(accountManager).isAccountSuspended(account);
}
Applied automatically to all ERC-20 transfers via _update. Suspended and removed accounts cannot send or receive tokens.
Open Mode
When accountManager == address(0) (set via setAccountManager(address(0))), both contracts bypass the allowlist. Used during testnet setup before account registration is complete.
Events
| Event | When emitted |
|---|---|
AccountAdded(accountAddress, accountType, name, createdBy) | addAccount |
AccountActivated(accountAddress, activatedBy) | activateAccount |
AccountSuspended(accountAddress, suspendedBy, reason) | suspendAccount |
AccountRemoved(accountAddress, removedBy, reason) | removeAccount |
AccountRoleGranted(accountAddress, role, grantedBy) | grantAccountRole |
AccountRoleRevoked(accountAddress, role, revokedBy) | revokeAccountRole |
AccountMetadataUpdated(accountAddress, metadata) | updateAccountMetadata |
Manage Accounts Script
For operational use, ncrb-contracts/scripts/manage-accounts.js wraps all lifecycle functions:
# Add a registry wallet
COMMAND=add ADDRESS=0x... NAME="Verra Registry" npx hardhat run scripts/manage-accounts.js --network sepolia
# Activate it
COMMAND=activate ADDRESS=0x... npx hardhat run scripts/manage-accounts.js --network sepolia
# List all accounts
COMMAND=list npx hardhat run scripts/manage-accounts.js --network sepolia
On Fuji and XRPL EVM,
addAccountmay fail with no revert reason during automated deployment (known RPC issue). Usemanage-accounts.jsto register these wallets manually after deployment.
UUPS Upgrade
_authorizeUpgrade requires ADMIN_ROLE. A 30-slot storage gap is not present in this contract — upgrades must be planned carefully to avoid storage collisions.