Skip to main content

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

Rolekeccak256 constantWho holds itWhat it allows
DEFAULT_ADMIN_ROLE(OZ default)Deployer / admin multisigGrant/revoke all roles
ADMIN_ROLEkeccak256("ADMIN_ROLE")Oracle / adminremoveAccount, grantAccountRole, revokeAccountRole, setMaxAccountsPerRole, upgrades
ACCOUNT_MANAGER_ROLEkeccak256("ACCOUNT_MANAGER_ROLE")Oracle / opsaddAccount, 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.
  • _accountType must be EOA or MULTISIG (not NONE).
  • _name must be non-empty.
  • MULTISIG accounts 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

FunctionReturns
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

EventWhen 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, addAccount may fail with no revert reason during automated deployment (known RPC issue). Use manage-accounts.js to 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.