Guardian's review of Solana OApp for LayerZero, published July 2026. The report records 54 findings across 4 review rounds, including 1 medium and 9 low.
- Published
- Rounds
- Round 1 - Main Review, Round 2 - Main Review, Round 3 - Remediation Review, Round 4 - Remediation Review
- Language
- Rust
- Chains
- Solana
- Sector
- Cross-chain
- 0 Critical
- 0 High
- 1 Medium
- 9 Low
- 44 Informational
Findings 54
Round 1 - Main Review
38 findings-
R1-I-02 Informational Override can desync OAppInfo and SDK discovery Validation L O C A T I O N contracts/common/utils/solana/rbac/macros/src/lib.rs#L56-L59 Resolved
Description
The #
[oapp_instruction]/ #[rbac_instruction]override model accepts arbitraryContext<T>, butOAppInfo.instructions.*stays at the default standard version (for example1) unless the developer explicitly marks that instruction as custom duringinit_oapp_info!.In
oapp_info, instruction version bytes are SDK discovery signals:0means custom discovery (SDK falls back toExtraAccountMetaList), while>0means standard discovery at that version (SDK uses predefinedStd*account templates).This creates a silent app-to-SDK contract mismatch:
On-chain: instruction account layout has changed (custom
Context<T>).Off-chain: SDK still reads version
1and builds the predefinedStd*account template.As a result, callers may see either:
functional breakage (account deserialization/position mismatch and revert), or
unexpected success with unintended semantics when extra accounts are ignored.
The framework does not enforce or warn that custom overrides must be paired with
init_oapp_info!(...,[InstructionName])(version0, custom discovery).Recommendation
Consider adding an explicit consistency guard between override collection and codegen; when an instruction is overridden with non-
Std*accounts, require it to be marked custom ininit_oapp_info!(or fail/warn at compile time).Resolution
LayerZero Team - Resolved.
-
R1-L-01 Low Payer-funder mismatch in enforced options Logical Error Resolved
Description
The
payerinStdSetEnforcedOptions(set_enforced_options.rs:44) is an unconstrainedSigner<'info>, independent of the account that originally funded theEnforcedOptionsPDA ininit_enforced_options. Both instructions declarepayeras a separate field fromauthority, with no constraint tying them together or tracking who originally funded the PDA. When the options buffer shrinks during aset_enforced_optionscall, Anchor'srealloc::payer = payerdirective routes the reclaimed rent lamports to the current transaction'spayer- not the account that originally paid the rent ininit_enforced_options. This creates a funder/refund-recipient mismatch: account A funds the PDA at initialization, but account B receives the refund when the PDA shrinks.Recommendation
Consider separating the
payer(who funds growth) from arefunder(who receives lamports on shrink) and handling the refunds manually. Alternatively, consider documenting this behavior explicitly so integrators are aware that rent refunds go to the currentpayer, not the original funder.Resolution
LayerZero Team - Resolved.
-
R1-L-02 Low Admin transfer reverts if OApp not registered Validation Resolved
Description
The #
[oapp]macro unconditionally injects aset_delegateCPI call into theaccept_default_admin_transferhandler (accept_default_admin_transfer.rs). This CPI targets the endpoint'sSetDelegateinstruction, which requires theOAppRegistryPDA (seeds[OAPP_SEED, oapp.key]) to exist and be initialized. TheOAppRegistryis only created when the developer callsendpoint_cpi::register_oappduring their custominitializeinstruction; a step the #[oapp]macro neither generates nor enforces.If a developer deploys an OApp using the #
[oapp]macro but omits theregister_oappCPI in their initialization handler, the RBAC system works normally;setup_default_admin!sets the admin, roles can be granted, andbegin_default_admin_transfersucceeds since it only modifies program state. However, when the pending admin callsaccept_default_admin_transfer ,theRBA Cp ortion(StdAcceptDefaultAdminTransfer::apply)co mpletesbutthesubsequent
set_delegateCPI fails because theOAppRegistryPDA does not exist.An OApp deployed without calling
register_oappduring initialization has a permanently bricked admin transfer flow.Recommendation
Consider adding a guard in the injected
accept_default_admin_transferoverride that checks whether theOAppRegistryPDA exists before attempting theset_delegateCPI. If the registry does not exist, either skip the CPI (with a warning event) or return a descriptive error. Alternatively, document thatregister_oappis a mandatory step during initialization.Resolution
LayerZero Team - Resolved.
-
R1-L-03 Low Admin transfer uses unvalidated accounts Validation Declined
Description
Every endpoint CPI in the OApp framework uses static, program-controlled accounts. During
initialize, the developer hardcodes the endpoint accounts forregister_oappandset_delegatedirectly in their Anchor accounts struct; the caller cannot influence which accounts reach the CPI. The same applies tosend,clear,send_compose, andclear_compose: the OApp's instruction context declares all required endpoint accounts as named fields, so account ordering and correctness are enforced by Anchor constraints at deserialization time.The
accept_default_admin_transferoverride breaks this pattern. The RBAC-generatedStdAcceptDefaultAdminTransferaccounts struct contains only RBAC-related fields (authority, state, role PDAs) and has no knowledge of endpoint accounts. The OApp-injected handler passesctx.remaining_accounts; an unconstrained, caller-supplied slice directly toendpoint_cpi::set_delegate() :::oapp::endpoint_cpi::set_delegate( ::oapp::endpoint::ID, ctx.accounts.default_admin.key(), &ctx.remaining_accounts, // caller-supplied, no Anchor validation &seeds, SetDelegateParams { delegate: ctx.accounts.authority.key() }, )?;Recommendation
Consider extending
StdAcceptDefaultAdminTransfer(or useoapp_extend_accounts!) to include the endpoint accounts as named fields, consistent with how every other endpoint CPI in the framework declares its accounts.Resolution
LayerZero Team - Declined.
accept_default_admin_transferfollows the same validatedremaining_accountspattern as other Endpoint CPI paths; the reported inconsistency was determined not to exist. -
R1-I-01 Informational EnforcedOptions space vs field order mismatch Informational L O C A T I O N apps/oapp-app/contracts/solana/macros/src/state_types.rs#L35 Resolved
Description
In
state_types.rs, theEnforcedOptionsstruct is declared with field orderoptions: Vec<u8>thenbump: u8. Borsh serializes in declaration order, producing the on-disk layout:discriminator(8) + options_len(4) + options_data(N) +bump(1). However, thespace()function's doc comment statesLayout: discriminator (8) + bump (1) +vec_len_prefix (4) + options_data, placingbumpbeforeoptions; the opposite of the actual Borsh layout.Recommendation
Consider correcting the doc comment to reflect the actual Borsh layout: //
/ Layout: discriminator (8) + vec_len_prefix(4) + options_data + bump (1).Resolution
LayerZero Team - Resolved.
-
R1-L-04 Low Missing #[repr(u8)] enable role seed collision Validation L O C A T I O N contracts/common/utils/solana/rbac/macros/src/role_type.rs#L10-L32 Resolved
Description
The
role_type_derive_implfunction inrbac/macros/src/role_type.rsvalidates only that the enum is non-empty and all variants are unit variants. It does not validate that the enum carries #[repr(u8)]. TheRoleType::seed()method inrbac/src/traits.rsconverts the role tou8viaInto<u8>and uses that single byte as the PDA seed component:[ROLE_MEMBER_SEED, state.key(), seed_byte, member.key()] .The #[only_role] constraintvalidatesauthorizationpurely by checking PDA existence at these seeds.
Without #
[repr(u8)]enforcement, two collision paths exist:- No #
[repr]+ manualInto<u8>collision: A developer omits #[repr(u8)]and writes a manualInto<u8>impl that maps two distinct roles to the same byte value. - #
[repr(u16)]+ truncation: A developer uses #[repr(u16)]with a variant value likeSpecial = 256, then implementsInto<u8>withself as u8; truncating256to0, which collides withDefaultAdmin.
Recommendation
Consider adding validation in
role_type_derive_implto verify the enum carries #[repr(u8)]usinginput.attrsinspection. Emit a compile-time error if the attribute is missing.Resolution
LayerZero Team - Resolved.
- No #
-
R1-L-05 Low only_role Does Not Enforce Signer Validation L O C A T I O N contracts/common/utils/solana/rbac/macros/src/only_role.rs#L34-L44 Resolved
Description
The #
[only_role]macro currently enforces role membership by checking for a valid RoleMember PDA derived from(state,role, authority.key()), but it does not enforce thatauthorityis a signer. This proves that the providedauthoritypublic key has the role, but it does not by itself prove that the caller controls that key (i.e., that it signed the transaction). This means the authorization guarantee depends on integrators correctly declaringauthorityasSigner<'info>(or adding an equivalent signer constraint). If they instead use a non-signer account type, the role check can be satisfied using a user-supplied pubkey.In that case, an attacker can pass a privileged account's public key and matching role PDA without possessing its private key, resulting in unauthorized execution of role-protected instructions in downstream programs.
While all
authorityfields in this repository's OApp module are currently typed asSigner<'info>, from an RBAC framework perspective #[only_role]creates a security gap for external integrators because it does not enforcesignerstatus on its own.Recommendation
Update #
[only_role]to enforce signer proof by default (for example via a generatedauthority.to_account_info().is_signerconstraint, or a compile-time requirement that authority isSigner<'info>).Resolution
LayerZero Team - Resolved.
-
R1-I-03 Informational Misleading set_delegate mention in macro doc Informational L O C A T I O N apps/oapp-app/contracts/solana/macros/src/lib.rs#L12 Resolved
Description
The doc comment on the #
[oapp]proc-macro inapps/oapp-app/contracts/solana/macros/src/lib.rsstates/// Uses the `anchor-trait` collect -> generate -> assemble pipeline to emit all /// standard OApp instructions. Instructions with a framework-provided default /// (e.g. `set_peer`, `set_delegate`) are generated automatically; instructions /// without a default (e.g. `lz_receive`) must be supplied by the implementor /// via `#[oapp_instruction]`.set_delegateis not an auto-generated default OApp instruction. TheOAppInstructionSetdeclared inapps/oapp-app/contracts/solana/macros/src/instructions/oapp/mod.rscontains 10 entries:SetPeer,GetPeer,InitEnforcedOptions , SetEnforcedOptions, GetEnforcedOptions , NextNonce ,IsComposeMsgSender , LzReceiveTypesInfo , LzReceiveTypesV2, LzReceive ;and no SetDelegate .The endpoint_cpi::set_delegatewrapper is only ever invoked from the unconditional
accept_default_admin_transferoverride.Recommendation
Consider replacing
set_delegatein the example list with an actual auto-generated default such asget_peerorinit_enforced_options. Optionally consider adding a sentence noting that endpoint delegate rotation is intentionally bound to theaccept_default_admin_transferflow, mirroringOAppCoreRBACUpgradeable.Resolution
LayerZero Team - Resolved.
-
R1-I-04 Informational lz_receive_types_info accounts undocumented Best Practices Resolved
Description
The off-chain LayerZero Executor invokes
lz_receive_types_infowith a fixed 2-account list, in this order:oapp_account, then thelz_receive_types_accountsPDA at[LZ_RECEIVE_TYPES_SEED, oapp_account.key()]. This is documented on the LZ docs site https://docs.layerzero.network/v2/developers/solana/oapp/overview#how-lz\_receive\_types\_v2-works, but is not surfaced anywhere in the framework.The framework's spec at
apps/oapp-app/contracts/solana/macros/src/instructions/oapp/lz_receive_types_info.rsis a required override with no compile-time check on the accounts shape, so a developer can declare a context struct of any size and the framework will compile it. Mismatch with the Executor's 2-account call is a runtime failure, discovered only after deployment.The same exists for
lz_compose_types_info.,Recommendation
Consider promoting
lz_receive_types_info/lz_compose_types_infofrom a required override to a Provided default with the canonical 2-account context and handler. Devs can still override via #[oapp_instruction]when needed, but get the correct shape for free by default.Resolution
LayerZero Team - Resolved.
-
R1-I-05 Informational Unused error variant OAppError::InvalidPeer Informational L O C A T I O N apps/oapp-app/contracts/solana/src/errors.rs#L13 Resolved
Description
OAppError::InvalidPeer isdeclaredat apps/oapp-app/contracts/solana/src/errors.rs b utisneverused anywhere inthe repo. It is surfaced to clients via the IDL but cannot be emitted on-chain.
Recommendation
Consider removing the variant, or adding the intended
set_peervalidation that was meant to raise it.Resolution
LayerZero Team - Resolved.
-
R1-I-06 Informational RBAC role-admin mapping is fixed at compile time Informational L O C A T I O N contracts/common/utils/solana/rbac/macros/src/instructions/grant_role.rs#L32 Acknowledged
Description
The Solana RBAC supports hierarchical role admins via
RoleType::role_admin()(contracts/common/utils/solana/rbac/src/traits.rs), andgrant_role/revoke_roleuse it when deriving the admin PDA// contracts/common/utils/solana/rbac/macros/src/instructions/grant_role.rs let role_admin_seed = quote!(&::rbac::traits::RoleType::seed(&#role_type::role_admin(¶ms.role)));But
role_admin()is resolved at macro-expansion time; the role->admin mapping is fixed in source. There is no on-chain instruction analogous to OpenZeppelin's_setRoleAdmin(role, adminRole), which EVM contracts (includingAccessControlDefaultAdminRulesUpgradeable ,thebase of OAppCoreRBACUpgradeable )canexpo setochang erolegraphsat runtime. Changing a role's admin on Solana requires a full program upgrade. Additionally,
role_admin(&self)-> Self receives no context/accounts, so even overriding the trait can't read on-chain state to implement runtime mutability.Recommendation
Either consider adding an on-chain
set_role_admininstruction (full EVM parity) or documenting the divergence explicitly; role hierarchy is static and changing it requires a program upgrade.Resolution
LayerZero Team - Acknowledged. The compile-time role-admin mapping was intentionally retained.
-
R1-L-06 Low Fully-qualified attribute paths not matched Validation Resolved
Description
Two path predicates in
anchor-traitusesyn::Path::is_ident, which is strict-1-segment. Qualified forms of the same attribute slip through. 1. #[program]dedup (assembly.rs)let mod_attrs: Vec<_> = mod_attrs.iter().filter(|a| !a.path().is_ident("program")).collect();#
[anchor_lang::prelude::program]or #[::anchor_lang::prelude::program]is not filtered, so the generated module carries two#[program]attributes and Anchor's program macro runs twice. 1.#[<domain>_instruction]override recognition (overrides.rs)/// Returns `true` if `attr`'s last path segment matches the configured attribute name. fn is_override_attr(attr: &Attribute, cfg: OverrideConfig) -> bool { attr.path().is_ident(cfg.attr_name) }Doc says last-segment; code uses
is_ident. #[crate::oapp_instruction]bypasses recognition - if the handler name doesn't collide with a managed instruction, it passes through as a regular item and the framework silently emits the default.Recommendation
Consider an exact-match allowlist of Anchor's canonical paths for #
[program]#[program] #[anchor_lang::program] #[::anchor_lang::program] #[anchor_lang::prelude::program] #[::anchor_lang::prelude::program]For the override marker recognition in
is_override_attr, compare only the final path segment:// overrides.rs attr.path().segments.last().is_some_and(|seg| seg.ident == cfg.attr_name)Resolution
LayerZero Team - Resolved.
-
R1-I-07 Informational Marker attribute macro bodies are unreachable Informational Resolved
Description
The framework declares three #
[proc_macro_attribute]stubs that users apply to override handlers:oapp_instruction()at apps/oapp-app/contracts/solana/macros/src/lib.rs#L29,composer_instruction()at apps/oapp-app/contracts/solana/macros/src/lib.rs#L56, andrbac_instruction()at contracts/common/utils/solana/rbac/macros/src/lib.rs#L56. Each body today is a trivial identity pass-through (item).Despite being declared as proc-macro attributes, these functions never execute in normal usage. Attribute expansion is outer-first: the enclosing #
[oapp::oapp], #[oapp::composer], or #[::rbac::rbac]attribute consumes the whole module as aTokenStream and stripsinner#[oapp_instruction] / #[composer_instruction] / #[rbac_instruction] markersviaInstructionOverrides::wrap_handler()before Rust's attribute resolver sees them. The markers are recognised purely by string match onOverrideConfig::attr_nameinanchor-trait'soverrides.rs- the stubs themselves are never invoked as macros.This is safe today because each body is identity. However, if a future maintainer adds logic to any of these bodies (e.g. validation, rewriting, diagnostics), that logic will silently not run in the normal flow. Tests applied via the enclosing domain macro will not exercise the stub.
Recommendation
Add a comment to each stub documenting that the body is unreachable under normal usage, and that logic intended to affect real expansions must be placed in the enclosing domain macro (
oapp::expand(),composer::expand(),rbac::expand()) or in the sharedInstructionOverridespipeline - not in the stub.Resolution
LayerZero Team - Resolved.
-
R1-I-08 Informational Duplicate use super::* in generated module Informational L O C A T I O N contracts/common/framework/solana/anchor-trait/src/assembly.rs#L90 Acknowledged
Description
Assembly::assemble()always injectsuse super::*;as the first item inside the generated program module. When two domain macros are stacked - e.g. #[oapp::oapp]followed by #[::rbac::rbac]- each macro runs its ownassemble()pass. The second pass re-emits the module with a freshuse super::*;while preserving the prior pass'suse super::*;as part offiltered_items(). As a result, the final expanded module contains two identicaluse super::*;imports.Concretely, after #
[oapp::oapp]expands inapps/oapp-app/contracts/solana/macros/src/oapp.rs, its output contains#[::rbac::rbac(...)] mod counter { use super::*;... }. When the RBAC attribute is then expanded and itsAssembly::assemble()re-wraps the module, the preserved body (passed throughInstructionOverrides::filtered_items()) still carries the originaluse super::*;, and a second one is prepended by the new emission. The duplication is harmless to compilation becauseusedeclarations are idempotent, but it is unnecessary noise in the expanded output (visible viacargo expand) and grows linearly with the number of stacked domain macros (e.g. #[oapp]+ #[composer]+ #[rbac]would produce three copies).Recommendation
Either fix or acknowledge.
Resolution
LayerZero Team - Acknowledged. Duplicate imports were accepted as harmless.
-
R1-M-01 Medium OApp macro rejects module inner attributes Logical Error L O C A T I O N contracts/common/framework/solana/anchor-trait/src/assembly.rs#L86 R E P O Round 1 - Main Review Resolved
Description
The
anchor-trait Assembly::assemble()incontracts/common/framework/solana/anchor-trait/src/assembly.rsre-emits the user-authored module's attributes (ItemMod.attrs) without distinguishing outer style ( #[...]) from inner style ( #![...]). Both are dumped uniformly via #(#mod_attrs)*at a position outside the generated program modulequote! { #prelude #wrapper_defs #default_impls #extend_macro #(#mod_attrs)* // <-- inner attrs (#![...]) land here, outside the mod #extra_mod_attrs #[program] #mod_vis mod #mod_ident { use super::*; #filtered_items #entrypoints } }quote!preservesAttrStyleon round-trip, so a developer-written #![X]is re-emitted as #![X]in the new position. Because#prelude,#wrapper_defs,#default_impls, and#extend_macroalways emit tokens before this point, the inner attribute is never in a legal position (first tokens of an enclosing item).rustcrejects it with a hard compile error:error: an inner attribute is not permitted in this context --> src/lib.rs | | #[oapp::oapp(state = CounterState, role_type = CounterRoleType)] | ---------------------------------------------------------------- the inner attribute doesn't annotate this module | pub mod counter { | #![deny(clippy::arithmetic_side_effects)] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = note: inner attributes, like `#![no_std]`, annotate the item enclosing themThis blocks developers from using idiomatic module-scoped Rust smart-contract hardening pragmas inside the #
[oapp]module.Recommendation
Consider partitioning
mod_attrsbysyn::AttrStyleand route each style to the position whererustcaccepts it: outer attrs stay outside the generated module (they annotate the module from outside); inner attrs move to the first tokens inside the generated module body (they annotate the module from inside). Both land onmod #mod_identsemantically - the!is a positional marker, not a functional distinction.// contracts/common/framework/solana/anchor-trait/src/assembly.rs let (inner_attrs, outer_attrs): (Vec<_>, Vec<_>) = mod_attrs .into_iter() .partition(|a| matches!(a.style, syn::AttrStyle::Inner(_))); quote! { #prelude #wrapper_defs #default_impls #extend_macro #(#outer_attrs)* #extra_mod_attrs #[program] #mod_vis mod #mod_ident { #(#inner_attrs)* use super::*; #filtered_items #entrypoints } }Resolution
LayerZero Team - Resolved.
-
R1-I-09 Informational endpoint_cpi Helpers Can Panic on Short Slices Validation L O C A T I O N apps/oapp-app/contracts/solana/src/endpoint_cpi.rs#L40 Resolved
Description
Several CPI helpers in
oapp::endpoint_cpiindex into the caller-supplied accounts slice before verifying its length. As a result, undersuppliedremaining_accountsmay trigger uncontrolled aborts (e.g.,ProgramFailedToComplete) instead of returning a structured error such asAccountNotEnoughKeys.While malformed input still results in transaction failure, this unclean failure mode degrades debuggability and integration ergonomics. The pattern appears to be consistent across multiple helpers, including
register_oapp,set_delegate,send,clear , send_compose,an d clear_compose .Recommendation
Consider adding explicit slice-length checks before any positional
accounts[...]access in allendpoint_cpihelpers, and return a standard error on undersupplied input.Resolution
LayerZero Team - Resolved.
-
R1-L-07 Low Override Handler Cannot Access Nested Module Helpers Logical Error L O C A T I O N contracts/common/framework/solana/anchor-trait/src/overrides.rs#L119-L133 R E P O Round 1 - Main Review Resolved
Description
anchor-traitrelocates override handlers into generated sibling wrapper modules, which changes their lexical scope relative to the user-authored #[oapp]/#[rbac]module. As a result, an overridden instruction such aslz_receivecannot reliably access a nested helper module defined inside the same macro-managed module. For example:#[oapp::oapp(...)] pub mod my_oapp { mod utils { pub fn helper() { ... } } #[oapp_instruction] pub fn lz_receive(...) -> Result<()> { utils::helper(); Ok(()) } }The code above cannot compile because
utilsis insidemy_oapp, but the relocated override handler will be placed in a sibling module rather than remaining insidemy_oapp, roughly like belowmod __oapp_instruction__lz_receive_handler { use super::*; pub fn lz_receive(...) -> Result<()> { utils::helper(); // now looked up from this sibling wrapper scope Ok(()) } } #[program] pub mod my_oapp { use super::*; mod utils { pub fn helper() { ... } } pub fn lz_receive(...) -> Result<()> { __oapp_instruction__lz_receive_handler::lz_receive(...) } }For a workaround, developers who need nested helper modules for overridden handlers must either inline the logic inside the handler itself or move the helper module outside the #
[oapp]module into outer file scope or another utility module that remains visible to the generated wrapper modules.Recommendation
One option is to avoid moving override handlers into isolated wrapper modules, which would preserve the user module's lexical scope and allow overridden handlers to access nested same-module helper modules naturally. Alternatively, document this behavior clearly and warn developers that helper modules used by overridden handlers should be placed outside the macro-managed module, such as in outer scope or a separate utility module.
Resolution
LayerZero Team - Resolved.
-
R1-I-10 Informational Events omit the state instance pubkey Events L O C A T I O N contracts/common/utils/solana/rbac/src/events.rs#L5-L28 Resolved
Description
The rbac framework documents multi-instance programs as a supported feature - the
RoleMemberPDA is seeded onstate.key()so different state instances get isolated role namespaces. However, none of the emitted events include thestate.key()(nor any other instance identifier), so off-chain consumers cannot tell which instance an event refers to.The affected event types are:
RoleGranted { role, account, sender } - emittedby grant_role() RoleRevoked { role, account, sender } - emittedby revoke_role() , renounce_role(),andaccept_default_admin_transfer()(for the outgoing admin)DefaultAdminTransferStarted { new_admin } - emittedby begin_default_admin_transfer()In a multi-instance program, two independent
grant_role()calls on two different state instances produce identicalRoleGrantedpayloads if they happen to grant the same role to the same account. An off-chain indexer, governance dashboard, or alerting system subscribing to these events has no reliable way to attribute each event to the instance it originated from. The same concern applies symmetrically to the OApp-side eventsPeerSetandEnforcedOptionsSet, which omit theoapp.key()they mutated.Recommendation
Add a
state: Pubkeyfield to each #[event]struct in the rbac crate and the oapp crate, and populate it fromctx.accounts.default_admin.key()(or the equivalent state field) at everyemit_cpi!site.Resolution
LayerZero Team - Resolved.
-
R1-I-11 Informational The default_admin_role should not be dynamic Warning L O C A T I O N contracts/common/utils/solana/rbac/src/traits.rs#L60-L62 Resolved
Description
The
rbaccode intentionally forbids granting or revoking/renouncing the default admin role. But ifRoleType::default_admin_rolereturns a different value because of override or upgrade, this invariant can be bypassed. For example, the newdefault_admin_rolecan be set as an already existing role. This will silently promote all the members of the previous role.The
RoleMemberfor the previous default admin will become unreachable. This will also create the following asymmetry:begin_default_admin_transfer- remains callable only by the real default_adminaccept/grant/revoke/renounce- can be called by any of the holders of the new roleRecommendation
Consider documenting that
default_admin_rolemust not change.Resolution
LayerZero Team - Resolved.
-
R1-L-08 Low Peer/options accounts cannot be closed to reclaim rent Best Practices Acknowledged
Description
The
set_peer()instruction generated by the #[oapp]macro creates anOAppPeerPDA per(oapp, eid)pair viainit_if_needed, paying rent from thepayersigner. The generatedStdSetPeeraccounts struct has noclose =...directive, and no separateclose_peer()/remove_peer()/disable_peer()instruction is emitted by the macro. TheOAppPeerstruct itself contains onlyaddress: [u8; 32]andbump: u8- there is no enabled/disabled flag.As a result, once an OApp admin enables a remote chain by calling
set_peer(), the only path to "disable" that peer on Solana is to callset_peer()again withpeer = [0u8; 32]. The consequences are:- The rent-paid PDA stays allocated forever; the SOL paid at initialization is unrecoverable.
- The emitted
PeerSet { eid, peer: [0; 32] }event is indistinguishable from "freshly initialized but not yet configured" by off-chain indexers.
This is the framework-level macro used by every OApp built on top of it, so the issue compounds across chains/EIDs and across deployments.
This is also true for the
EnforcedOptionsaccountsRecommendation
Add a
close_peer()(orremove_peer()) instruction to the #[oapp]macro's default impls thatRequires
default_admin_role(same RBAC asset_peer()).Uses Anchor's
close =<rent_destination>constraint on thepeer: Account<'info, OAppPeer>so that the lamports are returned to the admin / payer.Emits a distinct event (e.g.
PeerClosed { eid }) so off-chain indexers can differentiate "explicitly disabled" from "set to zero address" from "never initialized".Alternatively, change
SetPeerParams::peertoOption<[u8; 32]>and close the PDA whenNoneis supplied. Apply this change toEnforcedOptionsas wellResolution
LayerZero Team - Acknowledged. The current interface was intentionally retained and no close path was added.
-
R1-I-12 Informational RoleRevoked.sender doc copy-pasted from grant Informational L O C A T I O N contracts/common/utils/solana/rbac/src/events.rs#L19 Resolved
Description
The
senderfield on theRoleRevokedevent carries a doc comment that describes the field as if it belonged toRoleGranted. The comment refers to the operator who initiated a grant, even though the event is emitted on revocation.#[event] pub struct RoleGranted { pub role: u8, pub account: Pubkey, /// The operator who initiated the grant (named `sender` for EVM parity). pub sender: Pubkey, } #[event] pub struct RoleRevoked { pub role: u8, pub account: Pubkey, /// The operator who initiated the grant (named `sender` for EVM parity). pub sender: Pubkey, }Recommendation
Consider updating the doc comment on
RoleRevoked.senderto reflect the revoke semantic, for example/// The operator who initiated the revoke (named `sender` for EVM parity). pub sender: Pubkey,Resolution
LayerZero Team - Resolved.
-
R1-I-13 Informational Clarify endpoint program check in CPI wrappers Documentation L O C A T I O N Solana%20OApp%20Findings%20Table/I-13%2034f8bda5828c81738042d92907b638f8.html Resolved
Description
Each CPI wrapper in
endpoint_cpi.rs(register_oapp(),set_delegate(),send(),quote(),clear(),send_compose(),clear_compose()) carries a doc comment of the formaccounts[0]must be the Endpoint program (validated byconstruct_context).The wording could be read to imply that
construct_contextverifiesaccounts[0]against the canonical LayerZero Endpoint program. In practice, the check insideconstruct_context(generated bycpi_helper'sCpiContextderive - seeLayerZero-v2/packages/layerzero-v2/solana/programs/libs/cpi-helper/src/lib.rs#L51-L53 )is:if (program_id != accounts[0].key()) { return Err(InvalidProgramId.into()); }program_idis bound to theendpoint_program: Pubkeyparameter that the caller supplied. So the check confirms that the two caller-supplied values are consistent with each other, but it does not by itself establish that either one is the real Endpoint. The actual identity guarantee comes from wherever the outer OApp instruction sourcesendpoint_program- typically a hardcoded constant or a stored, RBAC-gated config field.This is not a vulnerability in the wrappers themselves; the design simply pushes the trust boundary one layer up to the caller. The current docstring may give readers (and future maintainers building OApps on top of these wrappers) a slightly stronger impression of where the guarantee lives than is accurate.
Recommendation
Consider rewording the docstrings so the trust boundary is explicit. For example
/// # Accounts validation /// - `accounts[0]` must equal the caller-supplied `endpoint_program` parameter.Resolution
LayerZero Team - Resolved.
-
R1-I-14 Informational Misleading Code Comment Documentation L O C A T I O N contracts/common/framework/solana/anchor-trait/src/lib.rs#L16 Resolved
Description
The comment in
anchor-trait/src/lib.rsstates, "Seespec.mdfor the full specification." However, the repository does not include aspec.mdfile, making the comment misleading.Recommendation
Update the comment to reflect the current repository structure, or add a
spec.mdfile.Resolution
LayerZero Team - Resolved.
-
R1-I-15 Informational Endpoint CPI wrappers rely on account ordering Suggestion L O C A T I O N apps/oapp-app/contracts/solana/src/endpoint_cpi.rs#L24-L187 R E P O Round 1 - Main Review Resolved
Description
The CPI wrappers in
endpoint_cpi.rs(register_oapp(),set_delegate(),send(),quote(),clear(),send_compose(),clear_compose()) all rely on the caller passing theaccountsslice in a specific positional orderaccounts[0]must be the endpoint program.accounts[1..N]must match the field declaration order of the correspondingcpi::accounts::*struct inendpoint-interface. This positional dependency comes from thecpi_helper::CpiContextderive macro atLayerZero-v2/packages/layerzero-v2/solana/programs/libs/cpi-helper/src/lib.rs#L22-L33, which generates aconstruct_context()implementation that pops accounts from the slice in field-declaration order and assigns each to the matching CPI accounts field by position. The wrappers inendpoint_cpi.rsfurther encode this dependency via hardcoded indices, e.g.:register_oapp()checksaccounts[2]==oappbecauseoappis the second field ofRegisterOApp(afterpayer).set_delegate() , send() , clear(), send_compose() ,and clear_compose() each check accounts[1] because theirrespectivesigner/identity field is declared first in the struct. If an OApp developer building on top of this framework is unaware of the strict positional contract, they could easily pass the
accountsslice in a wrong order. The wrapper's own check would only catch the mismatch at the single hardcoded index it inspects (e.g.,accounts[2]forregister_oapp()); other slots could silently land in the wrong field. The endpoint's downstream account validation would eventually reject the malformed CPI, but the resulting error (a seed-derivation or ownership failure) is far less obvious than a layout mismatch and complicates debugging. Theendpoint-interfacegit dependency inCargo.tomlis pinned by SHA, so the upstream struct order cannot drift unexpectedly. The remaining risk is purely a developer-awareness one: the wrappers' security guarantees depend on a layout contract that is implicit and undocumented at the call site.Recommendation
Consider adding a brief comment on each wrapper (or at the top of the module) documenting the positional dependency on the corresponding
cpi::accounts::*struct. For example/// NOTE: `accounts[2]` corresponds to the `oapp` field of `RegisterOApp` in /// `endpoint-interface`. The full slice must follow the struct's field order: /// [endpoint_program, payer, oapp, oapp_registry, system_program, ...remaining].This makes the implicit assumption explicit at the call site, helping OApp developers lay out their accounts correctly the first time.
Resolution
LayerZero Team - Resolved.
-
R1-I-16 Informational OApp initialize() is frontrun-hijackable Frontrunning L O C A T I O N apps/oapp-app/contracts/solana/macros/src/instructions/oapp/mod.rs#L27 Acknowledged
Description
The #
[oapp]macro framework generates a set of admin-gated instructions (set_peer(),init_enforced_options(),set_enforced_options(),next_nonce(), etc.), but does not generate or scaffold aninitialize()instruction. Each OApp implementer must hand-roll their own initialization handler that creates the OAppstatePDA, callsendpoint::register_oapp()via CPI, and seeds the initial RBAC default-admin role.Solana's runtime delays the visibility of newly-deployed programs by
DELAY_VISIBILITY_SLOT_OFFSET = 1slot, so every OApp deployment exposes an unavoidable window betweensolana program deployconfirmation and the deployer's owninitialize()tx landing in a subsequent block. Atomic deploy-plus-initialize is not achievable through any client-side mechanism (including Jito bundles, which execute within a single slot and therefore cannot invoke a program deployed in that same slot). During the window, any signer can submit a competinginitialize()and:- Become the OApp's
DefaultAdmin(typically via #[rbac::init_default_admin(... admin = payer.key())], which the framework's RBAC layer publishes as the recommended pattern). - Set themselves (or any address) as the LayerZero endpoint
delegatethrough the freely-chosenregister_oappparameter. - Permanently lock out the legitimate deployer - the
stateaccount'sinitconstraint succeeds exactly once.
After the race resolves in the attacker's favor, they control all admin-gated configuration:
set_peer(),set_send_library(),set_receive_library() , init_enforced_options(), grant_role() , begin_default_admin_transfer() ,etc.The framework provides no built-in mechanism to mitigate this. There is no macro-generated initializer, no attribute that binds initialization to the program's upgrade authority, and no mention of the hazard in the framework documentation. Protocol teams integrating the #
[oapp]macro must independently know about Solana's deploy-and-init race and harden their handler accordingly - a non-obvious requirement for teams whose primary background is EVM, where proxy constructors atomize the same operation.Recommendation
Either (a) document the hazard in the OApp framework guide and demonstrate the upgrade-authority constraint pattern, or (b) provide a framework-level attribute (e.g. #
[oapp::deployer_only_init]) that bindsinitialize()to the program's upgrade authority by adding aprogram_data: Account<'info, ProgramData>to the accounts struct with aprogram_data.upgrade_authority_address==Some(deployer.key())constraint. The constraint is robust regardless of race-window size because every non-deployerinitialize()reverts.Resolution
LayerZero Team - Acknowledged. The framework-level initialization risk remains; Console OFT handles it at the application level.
- Become the OApp's
-
R1-I-17 Informational RBAC docs reference non-existent #[grant_role] Documentation L O C A T I O N contracts/common/utils/solana/rbac/src/lib.rs#L42 R E P O Round 1 - Main Review Resolved
Description
The crate-level documentation in
lib.rsinstructs integrators to use macros that do not exist in the current API. Both the prose ("Quick Start") and the runnable-looking example show a per-instruction attribute macro #[grant_role(...)](and references #[revoke_role], #[renounce_role]) that is not exported byrbac_macros.//! ## Quick Start //! //! 1. Define your role enum with `#[derive(RoleType)]` //! 2. Define your `RoleMember` account with `bump`, `role`, and `account` fields //! 3. Use `#[grant_role]`, `#[revoke_role]`, `#[renounce_role]` macros on your instruction structs //! //! ## Example //! //! ```ignore //! ... //! // The macro auto-generates the state field and all RBAC accounts //! #[grant_role(program_state = Store, role_member = RoleMember, role_type = RoleType)] //! pub struct GrantRole<'info> {} //! ``` The actual API exported from `rbac_macros` (per `macros/src/lib.rs` and the README) is: - `#[rbac(state = ..., role_type = ...)]` - applied to the `#[program]` *module*, generates all six RBAC instructions - `#[only_role(...)]`, `#[init_default_admin(...)]` - accounts-struct attribute macros - `#[derive(RoleType)]`, `#[derive(DefaultAdmin)]` - `#[rbac_instruction]` - override marker There is no per-instruction `#[grant_role]`, `#[revoke_role]`, or `#[renounce_role]` attribute. The `grant_role`/`revoke_role`/`renounce_role` instructions are generated by the module-level `#[rbac]` macro, not by per-struct attributes. **Recommendation** Consider rewriting the Quick Start and Example sections in `lib.rs` to reflect the real API.Resolution
LayerZero Team - Resolved.
-
R1-I-18 Informational deserialize_alt Can Panic Via unwrap Informational L O C A T I O N apps/oapp-app/contracts/solana/src/common.rs#L135 Resolved
Description
deserialize_altcontains a panic-style code path when reading ALT account data. Incommon.rs, it invokesalt.try_borrow_data().unwrap()prior to deserializing the address lookup table; as a result, a borrow failure causes an uncontrolled abort rather than returning a structured program error.As a result, transaction failure occurs via a panic rather than returning
InvalidAddressLookupTable, leading to inconsistent error-handling behavior.Recommendation
Consider replacing the
unwrapwith proper error propagation, and returning a controlled error on borrow failure.Resolution
LayerZero Team - Resolved.
-
R1-I-19 Informational Hardcoded crate path in declare_instructions Compatibility L O C A T I O N contracts/common/framework/solana/anchor-trait/src/lib.rs#L98, #L121 Resolved
Description
declare_instructions!()expands generated code with explicit ::anchor_traitpaths for bothInstructionSetandInstructionSpec. This couples downstream users to one exact dependency name inCargo.toml. If an integrating macro crate renames the dependency key, such asanchor-traittomy-anchor-trait, local imports can be updated successfully but the macro expansion still fails because ::anchor_traitis no longer resolvable.Recommendation
Replace hardcoded :
:anchor_traitreferences indeclare_instructions!()with\$crate-qualified paths such as$crate::InstructionSetand$crate::InstructionSpec. This preserves correct resolution to the defining crate while remaining compatible with renamed dependencies in downstream crates.Resolution
LayerZero Team - Resolved.
-
R1-I-20 Informational Arbitrary marker accepted instead of (ctx) Compatibility L O C A T I O N contracts/common/framework/solana/anchor-trait/src/lib.rs#L60, #L85 Resolved
Description
declare_instructions!()documents(ctx)as the syntax that selects thespec(&ctx)dispatch path, but the matcher accepts any identifier in that position. As a result, inputs such as(banana)are treated the same as(ctx)and silently route to the ctx-passing branch. This makes the macro interface looser than its documented contract and can hide typos or misunderstandings in downstream macro crates.Recommendation
Restrict the ctx-passing branch to the literal
(ctx)marker and reject any other identifier with an explicit compile-time error. This keeps the accepted syntax aligned with the documented API and prevents accidental acceptance of mistyped markers.Resolution
LayerZero Team - Resolved.
-
R1-I-21 Informational Instruction aliases emit duplicate entrypoints Documentation L O C A T I O N contracts/common/framework/solana/anchor-trait/src/generation.rs#L188-L198 Resolved
Description
The framework derives each generated entrypoint name from the instruction's canonical name. If two instruction variants are intentionally aliased to the same module in
declare_instructions!(), they share the same canonical name and therefore generate the same public entrypoint function. This makes instruction aliases unsupported in practice, since code generation emits colliding entrypoints for both variants. In modules with default implementations, the samespec()and generator will also usually emit the same default accounts struct and handler impl twice. Even if an implementer forced different struct names through custom naming tricks, the generated entrypoints would still collide because the entrypoint name is tied to the shared canonical instruction name.Recommendation
Clearly document that each instruction variant must use a unique module identity.
Resolution
LayerZero Team - Resolved.
-
R1-I-22 Informational Docs understate default impl remains available Documentation L O C A T I O N contracts/common/framework/solana/anchor-trait/README.md#L36, #L207, #L220 Resolved
Description
The documentation says the default accounts struct is always emitted and describes account extension as reusing the base implementation, but it does not state clearly enough that the original default implementation remains available after extension as well. An integrator can reasonably read the extension flow as a replacement path and assume the default implementation is no longer reachable once an extended accounts type is introduced. In practice, the framework emits both the original default struct and handler impl and the extended variant, so extension is additive rather than exclusive.
Recommendation
Update the documentation to state explicitly that extending default accounts does not replace or disable the original default implementation. The
ProvidedandExtending Default Accountssections should emphasize that both the original default struct and its handler impl remain emitted and usable unless the integrator deliberately routes all usage through the extended type.Resolution
LayerZero Team - Resolved.
-
R1-I-23 Informational Default accounts generics are not supported Compatibility L O C A T I O N contracts/common/framework/solana/anchor-trait/src/generation.rs#L84, #L177 Resolved
Description
Generic parameters on default accounts types are not supported by the framework's code generation paths. In the extend-accounts path, the generated macro reconstructs the type as
pub struct $name<'info>andimpl $name<'_>, which drops any additional generic parameters from the original default definition. In the default entrypoint path, the generated context and dispatch use only the bare accounts ident viaContext<#accounts>and#accounts::#handler_fn, so a default accounts type such asStdShared<'info, T>cannot be instantiated correctly. As a result, default account definitions with extra generics may compile as standalone definitions but fail once the framework generates the corresponding entrypoint or extended variant.Recommendation
Choose one explicit behavior and document it. If generic default account types are intended to be supported, preserve full generic type information in both the default entrypoint and extend-accounts generation paths rather than reducing the type to a bare ident or hardcoded <
'info>. If they are not intended to be supported, validate and reject extra generics beyond the expected'infoform with a clear compile-time error and document that restriction.Resolution
LayerZero Team - Resolved.
-
R1-I-24 Informational Field-Injection Macros Always Append New Fields Documentation L O C A T I O N contracts/common/utils/solana/rbac/README.md#L103 Resolved
Description
The README states that #
[only_role]appendsrole_memberonly if that field does not already exist, but the implementation does not perform that check. In contracts/common/utils/solana/rbac/README.md#L103, the generated field is described as being appended conditionally; however, the macro inonly_role.rsunconditionally doesfields.named.push(new_field) .The same patternexistsininit_default_admin.rs for admin_role_member.If a developer manually defines one of these fields and also applies the macro, the expansion produces duplicate field names, causing the struct to fail compilation. Although this does not pose a runtime risk, it is inconsistent with the
READMEand may lead to confusing compile-time errorsRecommendation
Update the README to document this behavior.
Resolution
LayerZero Team - Resolved.
-
R1-I-25 Informational OAppInfo can be silently uninitialized Compatibility L O C A T I O N apps/oapp-app/contracts/solana/docs/oapp-info.md#L283 Resolved
Description
OAppInfois part of the shared OApp SDK integration contract, but its initialization is only an implicit expectation placed on application developers. The framework documents that developers should create theOAppInfoPDA and callinit_oapp_info!()inside their owninitializeinstruction, but #[oapp]does not enforce this and does not generate a default initialization path. As a result, an OApp can be deployed in a functionally working on-chain state while still lacking the metadata required by the standardized SDK. If a developer is unaware of this requirement and later revokes upgrade authority, the deployment can become permanently incompatible with the shared SDK with no straightforward remediation path.Recommendation
Consider adding an
initializemacro which requiresOAppInfoPDA to exist by default and provide an explicit opt-out for programs that intentionally do not want SDK compatibility. This approach protects devs from unknowingly creating their OApp withoutOAppInfoPDA, while still keeping maximum flexibilityResolution
LayerZero Team - Resolved.
-
R1-I-26 Informational Generated Code Is Susceptible To Name Shadowing Compatibility Resolved
Description
The code generated by
anchor-trait(including RBAC/OApp macros) relies on unqualified symbols such asResult,Context, and other Anchor types/macros resolved from the caller's scope. While RBAC documentation notes thatanchor_lang::prelude::*is expected to be in scope, this does not prevent standard Rust name shadowing from affecting macro expansion.In particular, generated entrypoints use bare
Result<T>rather than a fully qualified path (e.g.,anchor_lang::Result<T>). Additionally, the assembled program module imports all parent-scope items via usesuper::*, allowing any user-defined aliases (e.g.,type Result<T> = std::result::Result<T, ()>;) to propagate into the generated module.As a result, if a developer defines a conflicting alias such as
Result, the generated code may resolve to the shadowed type instead of the intended Anchor type. This can lead to compile-time failures, even when user-authored handlers explicitly returnanchor_lang::Result. The issue therefore stems from a macro hygiene limitation, where generated code depends on caller-scope name resolution rather than fully qualified paths.Recommendation
Use fully qualified paths (e.g.,
anchor_lang::Result,anchor_lang::Context) in generated code to avoid reliance on caller-scope resolution and reduce the risk of name shadowing. Alternatively, explicitly document that shadowing core types (such asResult) in the parent scope is unsupported when using these macros.Resolution
LayerZero Team - Resolved.
-
R2-I-02 Informational clear wrapper docs imply OApp-only signer Validation L O C A T I O N apps/oapp-app/contracts/solana/src/endpoint_cpi.rs#L283 R E P O Round 2 - Main Review Resolved
Description
The Endpoint program's
Clearaccounts struct authorizes thesigneraccount through a disjunctionconstraint = signer.key() == params.receiver || signer.key() == oapp_registry.delegate @LayerZeroError::UnauthorizedThe endpoint deliberately allows either the OApp PDA itself OR the OApp's registered delegate to clear an inbound payload. The framework's CPI wrapper, however, presents an API that strongly implies only the OApp PDA can be the signer:
/// # Accounts validation /// - `accounts[0]` must equal the caller-supplied `endpoint_program` parameter. /// - `accounts[1]` must match the provided `receiver` key (the OApp PDA). pub fn clear( endpoint_program: Pubkey, receiver: Pubkey, accounts: &[AccountInfo], seeds: &[&[u8]], params: ClearParams, ) -> Result<[u8; 32]> { validate(accounts, endpoint_program, CLEAR_MIN_ACCOUNTS_LEN)?; require_keys_eq!(accounts[1].key(), receiver, ErrorCode::ConstraintAddress); ... }The parameter is named
receiverand the docstring labels it "(the OApp PDA)". A delegate-clears flow IS still reachable through this wrapper because the caller controls what they pass as thereceiverargument (the wrapper only checks thataccounts[1]matches whatever the caller claims, not that the value actually equals the OApp address), but a developer reading the wrapper signature and docstring has no way to know that.Recommendation
Consider updating the wrapper's docstring to make the underlying endpoint behavior explicit, note that the endpoint accepts either the OApp PDA or the registered delegate as the signer, that
accounts[1]must be whichever signer the caller intends to use, and thatparams.receivermust always be the OApp address regardless of who signs.Resolution
LayerZero Team - Resolved.
-
R2-I-07 Informational Private repo link in source comment Documentation L O C A T I O N apps/oapp-app/contracts/solana/src/lib.rs#L32 R E P O Round 2 - Main Review Resolved
Description
The comment immediately before
declare_program!()links tohttps://github.com/LayerZero-Labs/monorepo-internal/..., which appears to point to a private repository or PR discussion. This does not affect protocol behavior, but external reviewers may be unable to inspect the linked context for whyidls/endpoint.jsonis mirrored in this package.Recommendation
Be aware that the source includes a reference to private development context. If this source is intended for broader public consumption, consider replacing the private URL with a short public note explaining why
idls/endpoint.jsonis mirrored beforedeclare_program!().Resolution
LayerZero Team - Resolved.
-
R4-I-01 Informational Stale OAppBase doc snippet in oapp-info Documentation L O C A T I O N apps/oapp-app/contracts/solana/src/oapp_info.rs R E P O Round 4 - Remediation Review Acknowledged
Description
The embedded
OAppBase/IdlVersioncode snippet indocs/oapp-info.mddoes not match the actual struct insrc/oapp_info.rs. The doc was not synced when the struct was written, so a reader copying or trusting the snippet sees the wrong definition. Two mismatches: 1. Theschema_versiondoc comment carries an extra sentence the real code does not have. Doc (docs/oapp-info.md):/// Borsh schema version of this entry. Bumps when fields are added, /// removed, reordered, or change type. The SDK reads this first to /// select the correct parser for the remaining bytes. pub schema_version: u8,Real code (
src/oapp_info.rs):/// Borsh schema version of this entry. The SDK reads this first to select the correct parser /// for the remaining bytes. pub schema_version: u8,- The
IdlVersionderive list is missingDebug.
Doc (
docs/oapp-info.md):#[derive(Clone, Copy, AnchorSerialize, AnchorDeserialize, InitSpace, PartialEq, Eq)] pub struct IdlVersion { pub major: u8, pub minor: u8, }Real code (
src/oapp_info.rs):#[derive(Clone, Copy, AnchorSerialize, AnchorDeserialize, InitSpace, PartialEq, Eq, Debug)] pub struct IdlVersion { pub major: u8, pub minor: u8, }Recommendation
For #1, consider updating the code to reflect the docs. For #2, update the docs and add the missing
Debug.Resolution
LayerZero Team - Acknowledged.
- The
Round 2 - Main Review
11 findings-
R2-I-01 Informational Unconstrained sender in setup_default_admin! Events L O C A T I O N contracts/common/utils/solana/rbac/macros/src/setup_default_admin.rs#L102 Resolved
Description
setup_default_admin!(ctx, state, admin, role_type, sender) parses sender asan arbitraryExpr andforwardsitunmodified into the
RoleGrantedeventemit_cpi!(::rbac::events::RoleGranted { state: #ctx.accounts.#state.key(), role: <#role_type as ::rbac::traits::RoleType>::default_admin_role().into(), account: admin_pubkey, sender: #sender, // arbitrary developer expression });Nothing constrains
senderto aSigner-typed field, the payer, or any account related to the grant. A caller can passPubkey::default(),system_program.key(), or any pubkey, and the resulting event is well-formed and indexable. This is the onlyRoleGrantedemission site wheresenderis not hard-coded toctx.accounts.authority.key(), breaking the convention used bygrant_roleandaccept_default_admin_transfer.Recommendation
Consider enforcing that the passed sender is a signer.
Resolution
LayerZero Team - Resolved.
-
R2-I-03 Informational Stale OAppInfo Documentation After TLV Migration Documentation Resolved
Description
The repository's OAppInfo documentation is inconsistent with the current implementation. While the implementation has moved to a raw SPL TLV-backed OAppInfo account with OAppBase as the framework entry and app-specific metadata appended as sibling TLV entries, older documentation still describes OAppInfo as a monolithic versioned PDA containing
InstructionVersionsplus anextra_info: Vec<u8>extension blob. This older model is still described in apps/oapp-app/contracts/solana/README.md#L96-L97.The #
[oapp]macro documentation is stale in the same area. apps/oapp-app/contracts/solana/docs/oapp-macro.md#L105 still states that the macro prelude auto-generates an OAppInfo account struct, but the current macro emits onlyOAppPeerandEnforcedOptionsin apps/oapp-app/contracts/solana/macros/src/oapp.rs#L67-L69. In the current design,oapp_infomust be declared manually asUncheckedAccount<'info>, sized explicitly for TLV entries, and initialized withoapp::init_oapp_base!plus any app-specificinit_tlv_entry(...)calls. Some internal and example comments still reinforce the old model as well.Recommendation
Update all stale
OAppInforeferences to consistently describe the current manualUncheckedAccount<'info>plus TLV-entry model, and remove outdated references to auto-generatedOAppInfo,extra_info,init_oapp_info!, andOAppInfoEntryResolution
LayerZero Team - Resolved.
-
R2-I-04 Informational anchor-trait README stale on wrapper_defs Documentation L O C A T I O N contracts/common/framework/solana/anchor-trait/README.md#L206 Resolved
Description
The
assembly.rschange in the latest sync movedwrapper_defsinside the generated module and updated the inline ASCII diagram in the source doc-comment to reflect the new layout. The "Assembly output structure" diagram inanchor-trait/README.mdwas not updated and still showswrapper_defsas a top-level emission alongsidepreludeanddefault_impls, which contradicts the current code.The README still says:
<prelude> <wrapper_defs> ← shown outside the module <default_impls>4...5 [vis] mod <name> { ... }The actual layout, per
assembly.rs:prelude default_impls3...mod name { filtered_items wrapper_defs ← now emitted inside, so handlers can reach // the user's nested same-module helpers entrypoints }Recommendation
Mirror the updated diagram from the
assembly.rsdoc-comment into the README so the two stay aligned.Resolution
LayerZero Team - Resolved.
-
R2-I-05 Informational Consider Size Check In update_tlv_entry Validation L O C A T I O N apps/oapp-app/contracts/solana/src/oapp_info.rs#L189-L200 Resolved
Description
update_tlv_entryis only safe when the new value has the same serialized length as the existing TLV entry. The code comments document this and recommend using it for fixed-size entries, but the function itself does not enforce that invariant.Testing confirmed that misuse has non-obvious failure modes. If a shorter variable-length value is written, the update can succeed while leaving stale trailing bytes in the slot and preserving the original TLV length. If a longer value is written, the update fails, but only after partially overwriting the existing bytes, which can leave the TLV entry corrupted and no longer decodable.
This does not appear to impact the current in-scope metadata structs, which are effectively fixed-size, but it remains an integration footgun for downstream developers.
Recommendation
Consider rejecting updates when the new serialized length differs from the existing allocated length.
Resolution
LayerZero Team - Resolved.
-
R2-L-01 Low OAppInfo Can Reference Missing TLV Entry Validation L O C A T I O N apps/oapp-app/contracts/solana/src/oapp_info.rs#L246 Acknowledged
Description
The TLV-based OAppInfo model relies on discriminator fields to describe which sibling TLV entry should exist next, but that relationship is not validated during initialization. In particular,
init_oapp_base!writesOAppBase.app_discriminator, while the actual sibling entry is written separately throughinit_tlv_entry. Nothing in this flow requires the referenced TLV entry to be present before initialization succeeds.As a result, a malformed or incomplete initializer can leave
OAppInfoin an internally inconsistent state where the base entry advertises an app-specific discriminator, but no matching sibling TLV entry exists in the account. This does not appear to create a direct authorization or fund-loss issue, but it can break SDK discovery, off-chain parsing, and transaction construction for application-specific flows that rely on the discriminator chain being valid.Recommendation
Add a validation or higher-level initialization helper that ensures any non-zero
app_discriminatorin OAppBase corresponds to a sibling TLV entry before initialization succeeds.Resolution
LayerZero Team - Acknowledged. The generic primitive was kept unchanged, with applications responsible for initializing the full TLV chain.
-
R2-I-06 Informational Incorrect composer docs link Documentation Resolved
Description
The composer macro generator for
lz_compose_types_info()links to the Solana OApp receive-types documentation athttps://docs.layerzero.network/v2/developers/solana/oapp/overview#how-lz_receive_types_v2-works .Thisfiledocuments composer-specific discovery using
LzComposeParams,LzComposeTypesV2Accounts, andLZ_COMPOSE_TYPES_SEED, so the current link sends implementers to the unrelatedlz_receive_types_v2()flow. This is a documentation-only issue and does not affect generated code or runtime behavior.Recommendation
Replace the
Seelink with the composer-specific documentation URL:https://docs.layerzero.network/v2/developers/solana/composer/overview#lz_compose_types_info.
Resolution
LayerZero Team - Resolved.
-
R2-I-08 Informational Incomplete package files list Configuration L O C A T I O N apps/oapp-app/contracts/solana/package.json#L7-L14 Resolved
Description
The npm package whitelist in
package.jsonincludes the entiremacrosdirectory while omittingidls. Includingmacrospublishes themacros/examplestree even though consumers only needmacros/srcandmacros/Cargo.toml. The examples are also large, which can materially increase package size. More importantly, the package now usesdeclare_program!(endpoint), which readsidls/endpoint.jsonat compile time. If the package is installed from the published artifact withoutidls, consumers may fail to compile with a missingidlsdirectory or accidentally resolve an unrelated ancestoridls/endpoint.json.Recommendation
Keep the package whitelist limited to compile-time inputs and runtime source
"files": [ "src", "idls", "macros/src", "macros/Cargo.toml", "Cargo.toml", "Anchor.toml", "rust-toolchain.toml", "rustfmt.toml" ]Do not include
macroswholesale or build artifacts such asmacros/target; includemacros/examplesonly if examples are intentionally shipped.Resolution
LayerZero Team - Resolved.
-
R2-I-09 Informational register_oapp Prerequisite Documentation Gap Documentation Resolved
Description
The #
[oapp]macro unconditionally injects anaccept_default_admin_transferoverride that callsset_delegate, which requires the OAppRegistry PDA to have been created viaregister_oapp. This prerequisite is documented in the macro implementation comment inaccept_default_admin_transfer.rs//! **`register_oapp` is a mandatory call in the OApp's `initialize` handler.** //! Omitting it means `accept_default_admin_transfer` will always fail once a //! default-admin transfer is initiated, permanently bricking the admin transfer //! flow.However, this prerequisite is not surfaced alongside the override description in the main user-facing docs in
READMEandoapp-macro.md. The bundled counter example also omitsregister_oappincounter/src/lib.rs, which can reinforce the gap for integrators reading the example first.Recommendation
Document the
register_oapprequirement in the main #[oapp]docs and examples wherever the generatedaccept_default_admin_transferoverride is described.Resolution
LayerZero Team - Resolved.
-
R2-I-10 Informational OAppInfo init accepts nonzero TLV tail Validation L O C A T I O N apps/oapp-app/contracts/solana/src/oapp_info.rs#L183-L185, #L254 Resolved
Description
init_oapp_base!()initializes the frameworkOAppBaseTLV entry throughinit_tlv_entry(), which directly callsTlvStateMut::unpack()and thenalloc_and_pack_variable_len_entry(). The SPL TLV parser treats the first uninitialized discriminator as the end of initialized TLV data, so nonzero bytes after that point are not rejected before the new entry is written.If
init_oapp_base!()is called on anOAppInfoaccount whose data is not freshly zero-filled, only the byte range occupied by the newOAppBaserecord is overwritten. Any remaining nonzero tail bytes are preserved. Those bytes may later cause parsing failures or, if they happen to form valid TLV records, be interpreted as unintended sibling entries by SDKs or other readers.This is not expected in the normal
initflow because newly allocated Solana account data is zero-filled, but the framework helper itself does not document or validate this assumption.Recommendation
Either acknowledge and document that
init_oapp_base!()must only be called on a freshly initialized, zero-filledOAppInfoaccount, or consider adding additional validation beforeOAppBaseinitialization to reject nonzero bytes after the first uninitialized TLV slot.Resolution
LayerZero Team - Resolved.
-
R2-I-11 Informational Program attr filtering should be documented Documentation L O C A T I O N contracts/common/framework/solana/anchor-trait/src/assembly.rs#L81-L82 Resolved
Description
Assembly::assemble()removes any module attribute whose final path segment isprogrambefore partitioning attributes into inner and outer attributes. This supports re-emitting a single managed Anchor #[program]attribute, including deduplicating qualified forms such as #[anchor_lang::prelude::program].This behavior can also remove other attributes whose path ends in
program, even if they are not Anchor's #[program]attribute. Because filtering happens before theAttrStyle::Innerpartition, it also applies to inner attributes. Anchor's#[program]attribute is an outer procedural macro on the program module, while #![program]as an inner macro attribute is not a stable/usable Anchor program form.This is not necessarily incorrect if the framework intentionally reserves any attribute path ending in
program, but that convention is not currently explicit to consumers.Recommendation
Document that
anchor-traittreats outer module attributes ending inprogramas reserved for the managed Anchor#[program]emission and may remove them during assembly. Also consider applying theprogramfiltering only after partitioning attributes, so inner attributes are preserved while outer Anchor #[program]attributes are still deduplicated.Resolution
LayerZero Team - Resolved.
-
R2-I-12 Informational RoleType derive can use nonzero default Validation L O C A T I O N contracts/common/utils/solana/rbac/macros/src/role_type.rs#L30-L46 Resolved
Description
role_type_derive_impl()only emits the discriminant-zero assertion when it finds a #[default]variant. If no #[default]variant exists,default_assertis empty and the macro still emitsimpl::rbac::traits::RoleType for #name {}. SinceRoleTyperequiresDefaultanddefault_admin_role()returnsSelf::default(), a consumer can manually implementDefaultso that #[derive(RoleType)]uses a nonzero default-admin discriminant. In that case,seed()derives the default-admin role byte from the nonzero discriminant, while the docs and SDK compatibility guidance expect the default-admin PDA seed to be[0].Recommendation
Either be aware of and document that #
[derive(RoleType)]only enforces the zero-discriminant invariant when a#[default]variant is present, or changerole_type_derive_impl()to require a #[default]variant and reject derives where it is missing.contracts/common/utils/solana/rbac/src/traits.rs#L65-L67explains how devs may still want to use different default discriminant than 0, but it says
implement this method yourselfwhich points todefault_admin_role. Adjust the comment If the intention is to let the dev have different discriminant than 0 by using theimpl Default for...syntax.Resolution
LayerZero Team - Resolved.
Round 3 - Remediation Review
4 findings-
R3-I-01 Informational Composer codegen doc points to the wrong file Documentation Acknowledged
Description
The doc comment on the composer macro generator at
apps/oapp-app/contracts/solana/macros/src/instructions/composer/lz_compose_types_info.rspoints to the wrong documentation file//! This follows the same V2 account-discovery model as `lz_receive_types_info`. //! See `docs/lz-receive-types.md#lzcomposetypes-v2-differences` for the //! compose-specific differences.The comment promises "the compose-specific differences" but links to
docs/lz-receive-types.md, which contains no such section. Those differences live indocs/lz-compose-types.mdunder the heading ### Differences from LzReceiveTypes. This is a documentation-only issue and does not affect generated code or runtime behavior.Recommendation
Repoint the reference to the correct file
1 //! See `docs/lz-compose-types.md` for the compose-specific differences.Resolution
LayerZero Team - Acknowledged.
-
R3-I-02 Informational Typo in CallerNotPendingAdmin doc comment Documentation L O C A T I O N contracts/common/utils/solana/rbac/src/errors.rs#L7 Acknowledged
Description
The doc comment on the
CallerNotPendingAdminerror variant atcontracts/common/utils/solana/rbac/src/errors.rscontains a typo and incorrect terminology/// The caller to accept the default admin transfer neds to be the pending default owner #[msg("Caller is not the pending admin")] CallerNotPendingAdmin,nedsshould beneeds. Additionally, the comment refers to the actor as the pending default "owner", but the RBAC module models access control in terms of admins, not owners, the variant isCallerNotPendingAdmin, its message is"Caller isnot the pending admin", and the staged value lives inpending_default_admin. This is a documentation-only issue and does not affect generated code or runtime behavior.Recommendation
Fix the typo and align the wording with the rest of the module
1 /// The caller of accept_default_admin_transfer must be the pending default admin.Resolution
LayerZero Team - Acknowledged.
-
R3-I-03 Informational Broken intra-doc link to CodegenContext Best Practices L O C A T I O N contracts/common/framework/solana/anchor-trait/src/lib.rs#L63 Acknowledged
Description
The doc comment on the
declare_instructions!macro atcontracts/common/framework/solana/anchor-trait/src/lib.rs:63uses rustdoc intra-doc-link syntax (square brackets) forCodegenContext/// Appending `(ctx)` after `mod_name` routes to `mod_name::spec(&ctx)` /// with the [`CodegenContext`] reference; omitting it calls `spec()` withThe
[...]form instructs rustdoc to resolveCodegenContextto a real item and link to it. No such item exists inside theanchor-traitcrate,CodegenContextis only a placeholder name that downstream callers choose for their own context struct (the macro's example passes it viacontext = CodegenContext;, and the associated type is referenced elsewhere as the inert code-spanCodegenContextat line 42). As a resultcargo docemits arustdoc::broken_intra_doc_linksdiagnostic:warning: unresolved link to `CodegenContext` --> src/lib.rs:63:16 | | /// with the [`CodegenContext`] reference; omitting it calls `spec()` with | ^^^^^^^^^^^^^^ no item named `CodegenContext` in scopeBy default this is a warning, but it becomes a hard
error: could not document anchor-trait(non-zero exit) under any deny escalation.Recommendation
Drop the brackets so the name is inert text, matching the existing inert usage at line 42
1 /// with the `CodegenContext` reference; omitting it calls `spec()` withResolution
LayerZero Team - Acknowledged.
-
R3-I-04 Informational Misleading clear documentation Documentation L O C A T I O N apps/oapp-app/contracts/solana/src/endpoint_cpi.rs#L271-L274 Acknowledged
Description
The following documentation was added to
endpoint_cpi::clear()in response to I-02/// - `accounts[1]` is the `signer` - pinned to `receiver` (the OApp PDA), which must also sign via /// `seeds`. The Endpoint itself also accepts the registered delegate as `signer`; that path is /// intentionally not exposed through this wrapper. To clear as the delegate, CPI to /// `endpoint::cpi::clear` directly.It says the wrapper doesn't expose the path that allows using delegate as a signer, but that is not true. There are actually 2 receivers. The first is the parameter the
clearis accepting - the one that's validated to be equal toaccounts[1]and is passed as signer. And the second isparams.receiver. This second receiver will be theOApp PDA, while the first one can be either the PDA or the delegate. If the user passes the delegate asreceiver, the endpoint would accept it.Recommendation
Either change the documentation to make it clear both paths are accessible or compare
receiveragainstparams.receiverif the intention is to truly disable the delegate path.Resolution
LayerZero Team - Acknowledged.
Round 4 - Remediation Review
1 finding-
R4-I-02 Informational OAppInfo v1 Can Allow Misleading Discovery Documentation L O C A T I O N apps/oapp-app/contracts/solana/docs/oapp-info.md Resolved
Description
OAppInfo v1 defines a fixed-template discovery model for provided standard OApp/RBAC instructions. SDKs derive required accounts from the published
idl_versionand predefined instruction templates, and schema v1 does not provide a per-instruction override mechanism for those templates.At the same time, the macro/framework layer continues to support extending or fully overriding provided standard instructions with custom account contexts. This creates a mismatch at the integration boundary: a program can publish OAppInfo v1 while changing the actual account layout of an instruction such as
set_peerin a way that the v1 metadata format cannot express. A generic SDK or off-chain integrator relying on the advertised standard template may then construct an invalid transaction even though the program behaves as implemented.The current documentation describes the individual pieces but does not make this incompatibility explicit enough.
oapp-info.mdstates v1 has no override path for standard instruction templates, whileoapp-macro.mdand anchor-trait documentation separately describe extending or overriding provided instructions. There is no clear statement that programs publishing OAppInfo v1 for standardized discovery must not extend or fully override the account contexts of SDK-discoverable provided standard instructions.Recommendation
Explicitly document that programs publishing OAppInfo v1 must not extend or fully override the accounts of standard provided instructions.
Resolution
LayerZero Team - Resolved.
No findings match.
More from LayerZero
All 7 reports-
Canton VER Updates
169 findings2 critical · 12 high 169 findings: 2 critical, 12 high, 36 medium, 54 low, 65 informational -
Console EVM Updates
4 findings 4 findings: 1 low, 3 informational -
Solana Console
42 findings 42 findings: 6 low, 36 informational -
OneSig on Stellar
7 findings 7 findings: 4 low, 3 informational
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.