@layerzerolabs/rbac-solana-contracts
v1.2.94
Published
Readme
RBAC
Overview
Role-Based Access Control (RBAC) for Solana Anchor programs, aligned with OpenZeppelin's AccessControl pattern from EVM and adapted for Solana's PDA-based account model. See also docs/role-management/ for cross-VM role management documentation.
The #[rbac] attribute macro generates default Anchor instruction entrypoints and context structs for role management instructions, while allowing developers to override any instruction with custom handlers and context structs.
This crate is built on the generic anchor-trait framework. For the override model (Default / Override), resolution rules, and framework API, see anchor-trait/README.md.
Core Invariant: Role membership is determined solely by the existence of a
RoleMemberPDA. Granting a role = creating the PDA; revoking = closing it. Overridden instructions must preserve this invariant.
#[rbac] Attribute Macro
Syntax
#[rbac(
state = StateType,
role_type = RoleEnumType,
)]
pub mod my_program { ... }Parameters
| Parameter | Required | Description |
| ----------- | -------- | ------------------------------------------------------------------------------------ |
| state | Yes | Program state account type. Must implement DefaultAdmin. |
| role_type | Yes | Enum type implementing RoleType. Used for role PDA derivation and admin hierarchy. |
To override an instruction, annotate a function inside the module with #[rbac_instruction] — the function name determines which instruction is overridden. See anchor-trait/README.md for the override model.
What the Macro Generates
Prelude (emitted outside the module):
- Trait imports:
RoleType as _,DefaultAdmin as _ RoleMemberaccount struct (parameterised byrole_type)
Per instruction (subject to override level resolution — see anchor-trait spec):
- Default context struct (always emitted for provided instructions)
- Canonical entrypoint function (all levels)
RBAC Instructions
| Instruction | Params Type | Return Type | View | Has Default | Authority Check |
| ------------------------------- | ------------------------------ | ------------------ | ---- | ----------- | -------------------------- |
| grant_role | GrantRoleParams<RoleType> | () | No | Yes | Role admin PDA |
| revoke_role | RevokeRoleParams<RoleType> | () | No | Yes | Role admin PDA |
| renounce_role | RenounceRoleParams<RoleType> | () | No | Yes | Self (authority == member) |
| begin_default_admin_transfer | Pubkey | () | No | Yes | current_default_admin |
| accept_default_admin_transfer | None | () | No | Yes | pending_default_admin |
| get_default_admin | None | DefaultAdminInfo | Yes | Yes | None |
- All mutating instructions emit events via
emit_cpi!. grant_role,revoke_role, andrenounce_rolerejectDefaultAdminrole — admin transfer must go through the 2-stepbegin_default_admin_transfer/accept_default_admin_transferflow.
For default context structs and handler logic, see rbac-macros/src/instructions/ (InstructionSpec).
RoleMember Account
Generated by the #[rbac] macro prelude, parameterised by the program's role_type:
#[account]
#[derive(Default, InitSpace)]
pub struct RoleMember {
pub state: Pubkey, // MUST be first field — offset 8 enables RPC filtering
pub role: RoleType, // MUST be second field — offset 40
pub account: Pubkey,
pub bump: u8,
}PDA seeds: [ROLE_MEMBER_SEED, state.key(), role.seed(), member.key()]
ROLE_MEMBER_SEED=b"RoleMember"state.key()scopes roles per program instance (supports multi-instance programs)role.seed()is a single-byte derived from theRoleTypeenum discriminantmember.key()is the account holding the role
Programs must also embed current_default_admin: Pubkey and pending_default_admin: Pubkey in their root state account, accessed via the DefaultAdmin trait.
Attribute Macros
#[only_role]
Injects a role_member PDA constraint into an accounts struct to enforce that the authority has a specific role. The macro also enforces that authority is a signer.
#[only_role(role = <expr>, state = <ident>, authority = <ident>)]| Parameter | Required | Description |
| ----------- | -------- | ------------------------------------------------------------------- |
| role | Yes | Role expression. Static: RoleType::Admin. Dynamic: params.role. |
| state | Yes | Field name of the state account used as PDA scope. |
| authority | Yes | Field name of the signer to check. |
Generated field (always appended to the struct — do not declare role_member manually, or compilation will fail with a duplicate-field error):
#[account(
seeds = [ROLE_MEMBER_SEED, state.key(), &RoleType::seed(&role), authority.key()],
bump = role_member.bump,
constraint = authority.is_signer @ ::rbac::RbacError::Unauthorized,
constraint = role_member.role == role @ ::rbac::RbacError::Unauthorized,
)]
pub role_member: Account<'info, RoleMember>,#[init_default_admin]
Injects the initial admin_role_member PDA and #[event_cpi] into an initialization context struct.
#[init_default_admin(state = <ident>, role_type = <path>, payer = <ident>, admin = <expr>)]| Parameter | Required | Description |
| ----------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| state | Yes | Field name of the state account being initialized. |
| role_type | Yes | Path to the RoleType enum. |
| payer | Yes | Field that funds account creation. |
| admin | Yes | Expression evaluating to a Pubkey that receives the initial DefaultAdmin role (e.g., authority.key() or params.admin). Allows splitting payer and admin. |
The macro injects the admin_role_member account field and #[event_cpi] (for CPI event emission). To complete setup, call the setup_default_admin! proc macro in your initialize handler:
rbac::setup_default_admin!(ctx, state, admin, MyRoleType, payer);Function-like Macros
setup_default_admin!
Initializes DefaultAdmin state, creates the first RoleMember PDA, and emits a RoleGranted CPI event. Call in your initialize handler after the struct is annotated with #[init_default_admin].
rbac::setup_default_admin!(ctx, <state>, <admin_expr>, <RoleType>, <sender_field>);| Argument | Type | Description |
| ----------- | ----- | ---------------------------------------------------------------- |
| ctx | Ident | The handler's context variable. |
| state | Ident | Field name of the state account (PDA scope). |
| admin | Expr | Expression evaluating to the admin's Pubkey. |
| role_type | Path | The RoleType enum path. |
| sender | Ident | Signer account field used for the signer check and event sender. |
Derive Macros
#[derive(RoleType)]
Auto-implements RoleType for a #[repr(u8)] enum. The #[default] variant becomes default_admin_role().
#[derive(
Clone, Copy, PartialEq, Eq, AnchorSerialize, AnchorDeserialize,
InitSpace, Default, num_enum::TryFromPrimitive, num_enum::IntoPrimitive, RoleType,
)]
#[borsh(use_discriminant = true)]
#[repr(u8)]
pub enum RoleType {
#[default]
DefaultAdmin = 0,
FeeConfigManager = 1,
Pauser = 2,
Unpauser = 3,
}#[derive(DefaultAdmin)]
Auto-implements DefaultAdmin for a struct containing current_default_admin: Pubkey and pending_default_admin: Pubkey fields.
RPC Enumeration
One program can hold many states (one per OFTStore, TokenManager root or policy), and anyone
can create one and grant themselves roles in it. RoleMember therefore stores its state at
offset 8 (after the 8-byte Anchor discriminator) and its role at offset 40, so a single memcmp
on discriminator + state + role enumerates one role of one state via getProgramAccounts:
{
"method": "getProgramAccounts",
"params": [
"<PROGRAM_ID>",
{
"filters": [
{ "dataSize": 74 },
{ "memcmp": { "offset": 0, "bytes": "<DISCRIMINATOR_STATE_AND_ROLE_BASE58>" } }
]
}
]
}Drop the role byte from the prefix to list every member of the state. Always include the state: without it, a role filter returns members of every state in the program.
- EVM:
getRoleMember(role, index)— O(1) indexed lookup - Solana:
getProgramAccountswithmemcmp— returns all members at once
EVM Alignment Summary
| OpenZeppelin Concept | Solana RBAC Equivalent |
| ------------------------------------- | ----------------------------------------------------------------- |
| DEFAULT_ADMIN_ROLE | RoleType::default_admin_role() (#[default] variant) |
| hasRole(role, account) | PDA existence check for RoleMember |
| grantRole(role, account) | grant_role instruction |
| revokeRole(role, account) | revoke_role instruction |
| renounceRole(role) | renounce_role instruction |
| beginDefaultAdminTransfer(newAdmin) | begin_default_admin_transfer instruction |
| acceptDefaultAdminTransfer() | accept_default_admin_transfer instruction |
| owner() / defaultAdmin() | get_default_admin view instruction (DefaultAdminInfo.current) |
| pendingDefaultAdmin() | get_default_admin view instruction (DefaultAdminInfo.pending) |
| getRoleMember(role, index) | getProgramAccounts with memcmp filter |
| onlyRole(ROLE) modifier | #[only_role(role = ...)] attribute macro |
Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ rbac crate │
│ pub use rbac_macros::*; (re-exports all macros) │
├─────────────────────────────────────────────────────────────────────────────┤
│ src/ │
│ Traits: RoleType, DefaultAdmin │
│ Params: GrantRoleParams, RevokeRoleParams, RenounceRoleParams │
│ Return Types: DefaultAdminInfo │
│ Events: RoleGranted, RoleRevoked, DefaultAdminTransferStarted │
│ Errors: RbacError │
│ Constants: ROLE_MEMBER_SEED │
├─────────────────────────────────────────────────────────────────────────────┤
│ macros/ (rbac-macros proc-macro sub-crate) │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ #[rbac] attribute macro │ │
│ │ - Parses state, role_type │ │
│ │ - Delegates to anchor-trait framework: │ │
│ │ collect overrides → generate instructions → assemble output │ │
│ ├───────────────────────────────────────────────────────────────────────┤ │
│ │ #[only_role] — injects role_member PDA constraint │ │
│ │ #[init_default_admin] — injects admin_role_member + #[event_cpi] │ │
│ │ setup_default_admin! — inits admin state + emits RoleGranted CPI │ │
│ │ #[rbac_instruction] — override handler marker (no-op attribute) │ │
│ ├───────────────────────────────────────────────────────────────────────┤ │
│ │ #[derive(RoleType)] — validates enum, generates RoleType impl │ │
│ │ #[derive(DefaultAdmin)] — validates struct, generates impl │ │
│ ├───────────────────────────────────────────────────────────────────────┤ │
│ │ Per-instruction generators (instructions/) │ │
│ │ 6 instructions: grant_role, revoke_role, renounce_role, │ │
│ │ begin_default_admin_transfer, accept_default_admin_transfer, │ │
│ │ get_default_admin │ │
│ └──────────────────────────────┬────────────────────────────────────────┘ │
│ │ uses │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────────┐ │
│ │ anchor-trait crate (external dependency of macros/) │ │
│ │ Override model, InstructionOverrides, generate_instructions, │ │
│ │ Assembly (see anchor-trait/README.md) │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
Generated code emits ::rbac:: paths, resolved at macro expansion in user programs.