Guardian's review of Solana Console for LayerZero, published July 2026. The report records 42 findings across 3 review rounds, including 6 low and 36 informational.
- Published
- Rounds
- Round 1 - Main Review, Round 2 - Remediation Review, Round 3 - Remediation Review
- Language
- Rust
- Chains
- Solana
- Sector
- Cross-chain
- 0 Critical
- 0 High
- 0 Medium
- 6 Low
- 36 Informational
Findings 42
Round 1 - Main Review
18 findings-
I-01 Informational Allowlist Mode ABI Uses Raw u8 Compatibility Resolved
Description
The transfer-hook interface currently defines
set_allowlist_modewith a rawu8parameter, so the instruction ABI can carry any value between 0-255. However, the intended domain for allowlist mode is only three enum values:Open,Blacklist, andWhitelist. The framework includesTryFrom<u8>forAllowlistMode, which correctly constrainsu8to those three values, but because the ABI type is still u8, downstream projects must remember to callAllowlistMode::try_fromthemselves in each relevant handler (for example in initialize). If validation is omitted or implemented incorrectly, invalid mode values can reach business logic and cause unexpected behaviors that differs from the intended allowlist policy.Recommendation
Either explicitly document as a requirement that all implementations should validate mode inputs using
AllowlistMode::try_from, or consider changing the instruction parameter type fromu8toAllowlistModeso invalid values are rejected at the interface boundary by construction. -
I-02 Informational Parity doc misdescribes rate_limits return type Documentation Resolved
Description
The parity doc (
solana-evm-parity-analysis.md§4.1, §4.8) documentsrate_limitsas returning bareRateLimit, matching the EVMIRateLimiter.rateLimits(uint256)-> RateLimitsignature. The actual spec inoft-extended/macros/src/instructions/rate_limiter/rate_limits.rsreturnsOption<RateLimit>:return_type: quote!(Option<::oft_extended::types::RateLimit>), Option<RateLimit>is the correct Solana shape - per-ID rate-limit state lives in a PDA that may or may not exist (None= no PDA initialized for that EID,Some(v)= configured). Off-chain SDKs that bind from the EVM-parity doc rather than the IDL misdecode the response: theOptiontag byte (0x00/0x01) is read as the first field ofRateLimit(config.override_default_config: bool), producing a one-byte-misaligned decode for everyrate_limitsquery.In addition, on Solana the
RateLimitstruct fields are inverted - config is first, state is second. This is opposite on EVM.pubstruct RateLimit { pub config: RateLimitConfig, pub state: RateLimitState, }The docs also present theRateLimitstruct as if it's a flat struct, but in reality it's wrappingRateLimitConfigandRateLimitStateRecommendation
Update the doc (no code change). In §4.1 row 2, change the Solana
Returncolumn fromRateLimittoOption<RateLimit>. In §4.8, prepend a paragraph using the same template as §1.5/§3.3 ("Some(v)= per-ID PDA exists;None= no PDA, fall back to default"). Also update the docs to reflect the fact thatRateLimitis not flat. Consider if reversing the order is desired. -
I-03 Informational FeeDepositSet doc names nonexistent EVM event Documentation L O C A T I O N apps/oft-app/contracts/solana/oft-extended/src/events.rs#L100 Resolved
Description
The event doc in
oft-extended/src/events.rscites an EVM event namedFeeDepositUpdated, which does not exist in the EVM interfaces: /// Emitted when the fee deposit is updated./// EVM: event FeeDepositUpdated(address indexedfeeDeposit)#[event] pub struct FeeDepositSet { pub oft_store: Pubkey, pub fee_deposit: Pubkey, }The actual EVM event declared in https://github.com/LayerZero-Labs/monorepo-external/blob/main/contracts/common/utils/evm/non-upgradeable/contracts/interfaces/IFeeHandler.sol#L20 isevent FeeDepositSet(address indexed feeDeposit);. The Solana event name (FeeDepositSet) matches the EVM event name exactly; only the doc comment misnames the EVM equivalent.Recommendation
Update the doc comment to reference the actual EVM event:
-/// EVM: event FeeDepositUpdated(address indexedfeeDeposit) +/// EVM: event FeeDepositSet(address indexed feeDeposit) -
I-04 Informational Parity doc references stale scale_decimals field Documentation L O C A T I O N apps/oft-app/contracts/solana/oft-extended/src/types.rs#L58-L66 Resolved
Description
The parity doc (
solana-evm-parity-analysis.md§4.2, §5.2) documentsRateLimitGlobalConfigas{ use_global_state,is_globally_disabled, scale_decimals: u8 }and explicitly listsscale_decimalsas a "Solana-only addition" that is "runtime-configurable" (vs. the EVMSCALE_DECIMALSimmutable). The actual struct inoft-extended/src/types.rsships only the two bools:pub struct RateLimitGlobalConfig { pub use_global_state: bool, pub is_globally_disabled: bool,} SetRateLimitGlobalConfigParamsmatches the same two-field shape, and no separate setter forscale_decimalsexists among the 21 oft-extended instructions. Off-chain SDKs and integrators that bind from the EVM-parity doc misdecodeRateLimitGlobalConfig(extra byte read into a non-existent field).Recommendation
Consider deleting the
scale_decimalsreferences from the parity doc so it matches the code. -
I-05 Informational OftInfo.oft_type raw u8 bypasses enum guard Best Practices L O C A T I O N apps/oft-app/contracts/solana/oft/src/oft_info.rs#L44 Resolved
Description
OftType(oft/src/oft_info.rs:19) is a two-variant enum with #[borsh(use_discriminant = true)], which would reject any byte outside{0, 1}during deserialization. ButOftInfo(oft_info.rs) stores the custody model as a rawu8, not asOftType:pub struct OftInfo { pub version: u8, pub oft_type: u8,// raw byte, not OftType pubinstructions: OftInstructionVersions, pub project_discriminator: [u8; 8], }All fields arepub. The safe constructorOftInfo::new(oft_type: OftType, …)takes the enum and casts it (oft_type as u8), but a caller can also build the struct directly (OftInfo { oft_type: 99, … }) without a compile-time check or discriminant guard on read. Downstream consumers pull a rawu8and must validate themselves.Recommendation
Type the field as the enum so the Borsh guard applies on both write and read paths:
pub struct OftInfo { pub version:u8, pub oft_type: OftType, pub instructions: OftInstructionVersions, pub project_discriminator: [u8; 8], } -
I-06 Informational Undocumented RoleType requirement in #[oft] Documentation L O C A T I O N apps/oft-app/contracts/solana/oft/macros/src/oft.rs#L46 Resolved
Description
The #
[oft]macro injects #[::oapp::oapp(state = #oft_type, role_type = crate::RoleType)](oft/macros/src/oft.rs:46). Therole_typepath is hardcoded - consumers must declare aRoleTypeenum at crate root with that exact name. #[oft_extended]inherits this via its nested #[oft]injection. This implicit requirement isn't mentioned in any documentation. Consumers only discover it via compile errors.Recommendation
Add documentation stating that consumers must declare a
RoleTypeenum at crate root satisfying therbacderive contract. -
I-07 Informational Rate limiter doc names nonexistent EVM file Documentation L O C A T I O N apps/oft-app/contracts/solana/oft-extended/src/events.rs#L56 Resolved
Description
The events section in
oft-extended/src/events.rsclaims EVM alignment with a file that does not exist: //================================ Rate Limiter Events ================================ // EVM alignment:IRateLimiterCore.solNoIRateLimiterCore.solexists anywhere. The actual EVM interface declaring all four rate-limiter events in this section (RateLimitConfigUpdated,RateLimitGlobalConfigUpdated,RateLimitStateUpdated,RateLimitAddressExemptionUpdated) isIRateLimiter.sol.Recommendation
Update the comment to reference the actual EVM interface file.
-
I-08 Informational getRateLimitUsages doc misnames EVM params Documentation Resolved
Description
The macro doc in
get_rate_limit_usages.rscites the EVM return-parameter names incorrectly: //! EVM:IRateLimiter.getRateLimitUsages(uint256 _id) -> //! (uint256 outboundUsage, uint256 outboundAvailable,uint256 inboundUsage, uint256//! inboundAvailable)The actual EVM signature declared inIRateLimiter.solis:function getRateLimitUsages( uint256 _id ) external view returns ( uint256 outboundUsage, uint256 outboundAvailableAmount, uint256 inboundUsage, uint256 inboundAvailableAmount );Recommendation
Update the doc comment to reference the actual EVM return parameter names.
-
I-09 Informational Unqualified Pubkey Makes Expansion Import-Sensitive Compatibility Acknowledged
Description
Several OFT / OFT-extended instruction specs emit bare
Pubkeytypes in generated signatures instead of fully qualified paths. As a result, macro expansion depends on the downstream module already importingPubkeyinto scope, so otherwise correct implementations may fail to compile for reasons unrelated to their business logic. While this is a compile-time developer footgun rather than a runtime issue, it reduces the robustness and portability of these framework macros for third-party consumers.Recommendation
Emit fully qualified paths for generated public types, such as :
:anchor_lang::prelude::Pubkey, so macro expansion is independent of downstream imports.Resolution
The current design was retained because using the fully qualified type breaks Anchor IDL generation; consumers still need to import
Pubkey. -
I-10 Informational Inconsistent OFT type docs Documentation Resolved
Description
The //
/documentation forQuoteOFTResultstates that it provides the fee breakdown, limits, and receipt data, but both the struct field order and the documentedIOFT.quoteOFT()return tuple order areOFTLimit,OFTFeeDetail[], andOFTReceipt. The ///documentation forQuoteOFTParamsstates that it provides the fee breakdown and settings data for an OFT, butQuoteOFTParamsis the input parameter object used byquoteOFT(). The fee breakdown and receipt-related data are returned throughQuoteOFTResult, not provided by the params type.Recommendation
Update the
QuoteOFTResultdocumentation to list the returned data in the same order as the struct andIOFT.quoteOFT()return tuple: limits, fee breakdown, and receipt data. Make the documentation describe the struct as the parameters used to quote an OFT transfer, rather than as data that provides fee breakdown or settings information. -
I-11 Informational Misleading oft_app comment Documentation Resolved
Description
There are 2 comments about
OftInfowhich suggest it's possible for an SDK to jump directly to aTLVentry without scanning the buffer thanks to theOAppBase.app_discriminatorandOftInfo.project_discriminator. This is misleading, since the discriminators are used for type discovery, but they cannot serve the goal of direct jump, as there is no offset encoded in them.Recommendation
Clear up the comments.
-
I-12 Informational fee_amount_ld Uses Narrower Integer Range Informational L O C A T I O N apps/oft-app/contracts/solana/oft/src/types.rs#L34 Resolved
Description
OFTFeeDetail.fee_amount_ldis defined asi64, while the broader OFT amount model usesu64. As a result,fee_amount_ldsupports a narrower value range than other token amount fields in the interface. Although fee values are unlikely to approach these limits in typical Solana deployments, the reduced range may still be relevant for integrations or tooling that assume fee metadata can represent the full token amount domain.Recommendation
Consider using a signed type that fully covers the u64 amount domain, such as
i128, or explicitly documenting thatfee_amount_ldis intentionally narrower as part of the interface design. -
I-13 Informational Document write handler authorization Documentation L O C A T I O N oft, oft_extended R E V I E W Round 1 - Main Review Resolved
Description
The
oft_extendeddocumentation states that required instructions such asset_rate_limit_state()have no default implementation and must be implemented by concrete OFT programs, but it does not clearly state that write handlers must also implement authorization matching the EVM RBAC surface. This is easy to miss because the macro only enforces that a handler exists; it does not enforce that the handler includes #[rbac::only_role(...)]or another access-control check.Recommendation
Document that required OFT Extended write handlers must add authorization explicitly, because the macro does not provide default RBAC.
-
I-14 Informational OFT TLV Metadata Misreports Account Layouts Informational L O C A T I O N oft_info.rs R E V I E W Round 1 - Main Review Resolved
Description
OftInfo tells SDKs that all base OFT instructions use standard account layout version 1, but the OFT framework does not define or enforce any standard account layout for those instructions All five OFT instructions are required overrides. Each concrete program chooses its own Context<T> account layout, yet the shared metadata still advertises them as standard layouts This can cause generic SDKs or executors that trust OAppInfo / OftInfo to build the wrong account list, making core OFT actions like send, quote_send, and quote_oft fail for users OftInfo hardcodes every base OFT instruction version to 1 in OftInstructionVersions OftInfo::new() then publishes those versions during initialization But the OFT macro does not provide predefined account templates for these instructions, every OFT instruction has
default_impl: NoneSo the concrete program must implement the handler, and anchor-trait dispatches using the handler declared Context<T>
Example #
[oft_instruction] pub fn send( ctx:&mut Context<MyCustomSendAccounts>, params:&SendParams, )->Result<(MessagingReceipt, OFTReceipt)> { }The metadata says send = 1, but the real account layout is MyCustomSendAccounts In console_oft for example, the mismatch is real send requires pause, fee, rate-limit, exemption, token source, escrow, fee deposit, mint, token program, and Endpoint accounts Those are concrete program specific layouts, not framework generated OFT standard layouts A metadata driven SDK can readOftInfo.instructions.send = 1and take the standard discovery path, even though no standard send layout existsImpact is SDK built send / quote_send transactions can omit required concrete accounts Routes may become unusable for users relying on generic account discovery. Note: If a project has generated SDK from its IDL, that SDK knows the real custom accounts, and that is safe in that case But the on chain metadata still publishes OftInfo.instructions.send = 1. So any generic OFT / OApp metadata driven resolver that does not use the project / app IDL SDK, and instead trusts OAppInfo / OftInfo, can still build the wrong account list
Recommendation
Set OFT instruction versions to 0, 1 is only correct if the framework actually defines OFT base instruction account layout v1, and currently It does not
-
I-15 Informational Blacklist checks miss token account owners Logical Error L O C A T I O N Transfer-hook R E V I E W Round 1 - Main Review Acknowledged
Description
During Token-2022 transfer-hook execution, the concrete transfer checks are described as covering "source/destination token-account PDAs plus signer/delegate" That omits the most important identities for blacklist enforcement
source_token_account.owner destination_token_account.ownerIn SPL Token / Token-2022, the destination account passed to a transfer is a token account, not necessarily the user wallet. The wallet owner is stored inside the token account data. A user can create many token accounts, and those token account pubkeys will not equal the user wallet pubkey that was blacklisted Similarly, when a delegate transfers on behalf of a blacklisted user, the transfer authority / signing account is the delegate, not the token account owner. If only the delegate / signer is checked, a blacklisted owner can move funds through a clean delegateSo blacklist mode can be bypassed by any blacklisted user who creates new token accounts or uses a non blacklisted delegate, This defeats the stated policy that listed addresses are blocked
Recommendation
In the Token-2022 transfer_hook, deserialize the source and destination token accounts and enforce blacklist / whitelist policy against their owner fields. blacklist mode should reject if any of these identities are blacklisted
source_token_account.ownerdestination_token_account.owner transfer_authority_or_delegate.Resolution
The current token-account-based allowlist design was retained because wallet-owner checks would break account resolution for first-time recipient ATAs.
-
I-18 Informational Arbitrary compose plans expose Executor tokens Validation L O C A T I O N send_compose_if_needed() R E V I E W Round 2 - Remediation Review Acknowledged
Description
OFT sender can cause the console_oft receiver to create a compose job for an attacker controlled composer, and that composer V2 planner can request Executor signer placeholders in the final transaction On the compose side, the framework accepts arbitrary V2 planner output And each account can be a concrete pubkey, ALT entry, Executor payer, additional Executor signer, or context account There is no framework level rule that says: Payer may only be used in the known rent payer position, or Signer(_) may only be used for a specific predeclared account init pattern That means a malicious composer can return a plan that causes the Executor to place its own hot wallet / payer signer into attacker chosen instruction accounts Attack flow is 1 ) The attacker deploys a malicious Solana composer program with a composer account, for example, malicious_composer_store 2 ) The attacker sends OFT cross-chain with
send_to = malicious_composer_store compose_msg = attacker-controlled payload3 ) The honest console_oft::lz_receive validates the LayerZero peer and clears the verified payload 4 ) Because the payload contains compose data, console_oft calls Endpointsend_composewith to: malicious_composer_store 5 ) The Executor later discovers and executes the compose job. It asks the malicious composer for lz_compose_types_v2 6 ) The malicious composer returns a plan that includes the Executor payer or signer as a writable signer account. 7 ) The Executor signs and sends the real compose transaction 8 ) Inside lz_compose, the malicious composer uses the Executor payer signature to call Token-2022transfer_checked( from = executor token account, to = attackertoken account, authority = executor payer )9 ) Because the Executor payer really signed the transaction, Token-2022 accepts the transfer This is not a malicious OApp only scenario. The console_oft program is the one creating the compose job, and the attacker chooses the compose target through a normal user facing OFT message The next impact is mitigated: "the attacker can drain SOL up to the execution fee limit" The preExecute/postExecute checks cap the payer's balance loss, and the executor is already reimbursed for the amount spent through the fees paid upfront by the user, so this does not harm the executor The only imapct Is, Theft of non SOL assets if the Executor wallet also owns SPL token accounts The malicious lz_compose handler can then CPI into the SPL Token program and transfer tokens from the executor's token account we might need to downgrade this, If LayerZero executor wallets will always have SOL only but we didn't find this enforced anywhere in the code / sdk
Recommendation
Fix in the Executor side after ALT resolution, before building the final compose transaction. Apply a guard in compose-types-v2.ts, inside
buildLzComposeExecutionPlan, Create a helper function like assertSafeComposePlan(), and call itconst tables = awaitfetchAllAddressLookupTable({ rpc }, resp.alts) assertSafeComposePlan(executorProgram, tables, resp, payer) -
L-01 Low Missing RateLimit PDA mismatch Logical Error L O C A T I O N rate_limit.rs R E V I E W Round 3 - Remediation Review Resolved
Description
The
apply_rate_limit()function allows bothuse_global_state==falseand missing per eidRateLimitPDA, when the following conditions are true. // EVM parity: same early return gates as_applyRateLimit. if (!forward_enabled&&(!backward_enabled || !net_accounting_enabled)) || (address_exemption_enabled && is_address_exempt) { returnOk(()); }This can happen when: both directions are disabled current direction and net accounting are disabled the address is exempted In this case the send flow will be successfully executed.The third condition, the address exemption, is not present in
get_outbound_available_for_quote()andget_rate_limit_usages()because there is no user provided. Even though the documentation claimA missing per-EID PDA is allowed here only when the real send/outflow path would alsoreturn before touching state.is technically not violated, this creates a mismatch betweenquote_oft()/get_rate_limit_usages()andsend()where a user quoting would see failure, while he can successfully execute the send. The same issue is present on EVM, but there the quote will instead return stale value instead of failing.Recommendation
Either document that the functions are unreliable in these specific cases or consider making the two paths behave the same way, for example don't include the exemption check when deciding whether to allow empty eid
RateLimitPDA -
I-02 Informational Rate-limit PDA requirement is underdocumented Documentation L O C A T I O N README.md R E V I E W Round 3 - Remediation Review Resolved
Description
The documentation states that default-and-override behavior is the same on both platforms, but this omits an important Solana account-model requirement for
RateLimitstate. On EVM, an uninitializedrateLimits[id]mapping entry behaves like a zeroed per-ID state while falling back to the default config. So ifuseGlobalStateFlag==false&&isGloballyDisabledFlagand the rest of the preconditions about net accounting and rent exemption are not met, the code will use the empty state with the default config. On SVM, whenuse_global_stateisfalseand the per-EIDRateLimitPDA is missing, mutating paths such assend()andlz_receive()can returnRateLimitNotInitializedif state must be updated.Recommendation
Document this EVM/SVM difference
Round 2 - Remediation Review
22 findings-
L-01 Low Quote Can Fail When Global Limits Are Disabled DoS Resolved
Description
Solana's
get_rate_limit_usagesfollows the same high-level order as the EVM implementation: it resolves the effective rate-limit state/config, computes the current usage, and only then applies the global-disable override by returning maximum available capacity. On EVM, this ordering is safe becauserateLimits[id]is a mapping lookup and always returns a defaultRateLimitstruct even when the ID was never explicitly initialized.On Solana, the same ordering has different behavior because per-EID rate limits are optional PDAs. When
use_global_state =false,get_rate_limit_usagesmust load the per-EID PDA before it can compute usage. If that PDA is missing, the function returnsRateLimitNotInitializedbefore reaching the global-disable branch, even whenis_globally_disabled = true. Therefore, two implementations with the same logical ordering diverge because Solana has a missing-account failure mode that the EVM mapping model does not. This impacts more than the standalone rate-limit view. Solana'squote_oftusesget_rate_limit_usagesto computemax_amount_ld. Integrators that callquote_oftbeforesendto obtain OFT limits, fee breakdown, and the expected receipt can fail unexpectedly even thoughsendwould succeed, because the mutating rate-limit path returns early when global rate limiting is disabled.Recommendation
Make
get_rate_limit_usagesreturn maximum quote/view availability when global rate limiting is disabled before treating a missing optional per-EID PDA as an error. -
L-02 Low Rate-Limit No-Op Cases Still Require Per-EID PDA Unexpected Behavior Resolved
Description
Solana represents per-EID rate-limit state as an optional PDA, while EVM uses a mapping whose missing entries read as a zero-initialized struct. On Solana,
apply_rate_limitreturns early only whenis_globally_disabledis true; for every other case it callsget_effective_state_and_config_mut, which returnsRateLimitNotInitializedwhenuse_global_state =falseand the per-EID PDA is uninitialized. The two no-op gates the limiter actually honors, an effective direction disabled by the default config and an address-exempt user, are checked only after that resolution. So even when the config disables the relevant direction or the caller is exempt, asendorlz_receivefor an EID whose per-EIDRateLimitPDA was never created reverts withRateLimitNotInitializedbefore reaching the no-op gate. The read path has the same shape:get_rate_limit_usagesresolves state and config first and only then applies the disabled-direction availability override, soquote_oftreverts for the same EIDs even though the effective answer is unlimited.On EVM,
_getRateLimitStateAndConfigreadsrateLimits[id]directly from a mapping, so an uninitialized ID yields a zero state plus the default config and never reverts. The disabled-direction and address-exemption early returns are honored without any per-ID state ever being written. The Solana account model therefore introduces a missing-account failure mode for cases that EVM treats as unconditional no-ops. This is a Solana-only liveness footgun: the limiter is configured to allow the operation, but the call fails until the operator separately initializes per-EID state.Recommendation
Evaluate the no-op conditions (disabled direction, address exemption) against the default config before treating a missing optional per-EID PDA as an error, or have the resolver fall back to default config and zeroed state to match EVM mapping semantics.
-
L-03 Low BurnMint Whitelist- Mode Escrow Send DoS Compatibility Resolved
Description
On a BurnMint OFT
send,console_oftalways routes the full amount from the sender to the escrow before burning.lock_or_burnissuesinvoke_transfer_checked(token_source-> token_escrow, amount_sent_ld)and only then burns from the escrow. Thatuser-> escrowleg is a Token-2022transfer_checked, so it fires the transfer hook with source = sender ATA, destination = escrow ATA, and authority = sender signer, and the hook runs the full source plus destination plus authority allowlist check. In Whitelist modeAllowlistMode::allowsreturns the whitelist flag, so the escrow ATA, as the transfer destination, must itself be whitelisted or the hook returnsDestinationBlockedand the send reverts.The escrow source-bypass does not rescue this leg. The bypass marker is keyed off the transfer source, but on send the escrow is the destination, not the source, which the hook comment in
transfer_hook.rsconfirms. The net effect is that enabling Whitelist mode silently bricks all BurnMint outbound sends until an operator separately whitelists the escrow token account. The condition is operator-introduced and recoverable by whitelisting the escrow, but it is easy to overlook because the requirement is undocumented.This requirement has no EVM analogue. On EVM, BurnMint
_debitburns the amount directly from the user viaburn(_from,amountSentLD)with no intermediate vault, andERC20Plus.burnchecks onlyonlyAllowlisted(_from); the fee is minted tofeeDepositviamint(), which carries no allowlist modifier. EVM BurnMint therefore needs only the user whitelisted, there is no escrow or vault to whitelist. The Solana-only escrow-routing design introduces the extra destination gate. The EVM-parity documentation does not surface this and in fact contradicts it:transfer-hook-evm-parity-analysis.mdlabels the BurnMint send path "Equivalent for OFT-controlled burn path," describing it only as "checks source before burn" and omitting the destination and authority checks that the same hook performs. An operator porting a BurnMint deployment from EVM, or a reviewer relying on the parity table, would have no indication that the escrow must be whitelisted, so every outboundsendreverts withDestinationBlockeduntil the escrow is explicitly whitelisted.Recommendation
Auto-whitelist the escrow token account when Whitelist mode is enabled. At minimum, document the requirement and correct the
transfer-hook-evm-parity-analysis.mdrow so it no longer labels the BurnMint send path "Equivalent" without noting that the escrow destination check is a Solana-only gate absent from EVMburn. -
L-04 Low Missing Mint Authority Check During Initialize Validation Acknowledged
Description
init_console_oftallows an OFTStore to be initialized withoft_type = BurnMintwithout validating that the OFTStore can actually mint the underlying token. The inboundlz_receivepath later requires the token mint authority to be either theoft_storePDA itself or a compatible Token-2022 multisig with quorum 1 that includesoft_storeas a signer. If the mint authority is an admin wallet, an incompatible multisig, or another authority the OFTStore cannot satisfy, initialization still succeeds but inbound delivery fails when the program attempts to mint.This can leave a deployment in a difficult or permanent failure state. The setup is only recoverable while the current mint authority remains controllable and can be changed through the token program. If the mint authority is disabled or cannot be reassigned to a compatible authority, the BurnMint OFT instance remains unable to receive inbound messages.
Recommendation
During initialization of the BurnMint type, require proof that the mint authority is either
oft_storeor a compatible 1-of-n Token- 2022 multisig containingoft_store -
L-05 Low Enforced options msg_type mismatch Configuration Resolved
Description
The Solana OFT implementation defines
MsgType::Send = 0andMsgType::SendAndCall = 1, while LayerZero's public FAQ and EVM OFT implementation usemsgType = 1forSENDandmsgType = 2forSEND_AND_CALL. Thesend()path derives theEnforcedOptionsPDA from the localmsg_type()result. If an operator follows the documented1/2convention or uses shared tooling that assumes EVM constants, they can configuremsgType = 1for plain sends andmsgType = 2for compose sends. Plain Solana sends then look for the unconfiguredmsgType = 0PDA; when it is missing,try_load_account()returnsNone, andcombine_options()falls back to empty enforced options. As a result, the message can proceed using only caller-providedextra_options. Similarly, packets with compose messages will use theSENDoptions and theSEND_AND_CALLoptions will be unused.This misconfiguration is also easier to introduce because
SetEnforcedOptionsParams::msg_typeaccepts raw numeric values as its type isu16.Recommendation
Either make the SVM values
1/2as well, or explicitly document that on Solana0/1should be used and consider overridingset_enforced_optionswith additional validaiton. -
I-01 Informational Transfer Hook Index- 3 Authority Doc Mislabel Documentation Resolved
Description
The account-index documentation table in
init_transfer_hook.rslabels index 3 asauthority / owner. This is inaccurate: index 3 is the transfer authority that Token-2022 passes into theExecutehook, which may be the source owner, an approved delegate, or the mint's permanent delegate. Theownerqualifier captures only one of the three cases and is misleading for the delegate and permanent-delegate recovery paths. The authoritative description intransfer_hook.rscorrectly documents index 3 asauthority (source owner, approved delegate, or permanent delegate), so the two canonical index tables disagree with each other.The runtime constant
AUTHORITY_ACCOUNT_INDEXis correct; only the doc row is wrong, so there is no behavioral impact, but a reader relying on theinittable could misreason about the PD fund-recovery and delegated-transfer flows that key theauthority_allowlist_entryPDA off this account.Recommendation
Update the index-3 row in the
init_transfer_hook.rstable to matchtransfer_hook.rs. Use eitherauthorityalone or the fully-qualifiedauthority (owner / delegate / permanent delegate)so the two tables are consistent and the delegate / permanent-delegate cases are not obscured. -
I-02 Informational Document Solana Compose Codec Layout Documentation Resolved
Description
The Solana compose codec comment says it is aligned with
OFTComposeMsgCodec, but the implementation encodesamount_ldas au64occupying 8 bytes. Its compose header is therefore[nonce:8][src_eid:4][amount_ld:8], withcompose_fromat offset 20 and user compose payload at offset 52. The EVM OFT compose codec encodesamountLDasuint256inabi.encodePacked, so the header is[nonce:8][srcEid:4][amountLD:32], withcomposeFromat offset 44 and user compose payload at offset 76. If the compact 8-byte Solana layout is intentional, the issue is the comment/documentation rather than the codec behavior. The current wording can make readers assume the Solana payload is byte-compatible with the EVMOFTComposeMsgCodeclayout even though the offsets differ.Recommendation
Update the codec comment and related docs to state that the Solana compose payload uses a Solana-specific compact layout with an 8-byte
amount_ld,COMPOSE_FROM_OFFSET = 20, andCOMPOSE_MSG_OFFSET = 52. -
I-03 Informational Shared Program Upgrades Affect All OFT Instances Trust Assumptions Resolved
Description
Solana console_oft can host multiple OFTStore instances under one deployed program ID. Although each instance has separate state and admin configuration, all instances execute the same program binary. If that program remains upgradeable, a later upgrade changes behavior for existing and future OFTStore instances. This differs from EVM, where developers deploy separate OFT proxy instances from the LayerZero implementation contracts. Upgrading one EVM proxy does not automatically affect other OFTs, while upgrading a shared Solana program affects every OFTStore under that program ID. This is an implicit trust assumption for developers using a shared deployment.
Recommendation
Document the shared upgrade-authority trust model and advise production deployers to self-deploy, use governance-controlled upgrades, or freeze the program when appropriate.
-
I-04 Informational Dust Can Misalign Hook Accounts Documentation Resolved
Description
senddecides whether to run the escrow -> fee deposit transfer fromfee_ld = amount_sent_ld - amount_received_ld, not from the configured fee bps. Sinceamount_received_ldis rounded down byremove_dust,fee_ldincludes both configured OFT fee and the dust. A send with zero fee bps can therefore still entercollect_feewhenamount_ldis not aligned told2sd_rate.For Token-2022 mints with an active transfer hook,
collect_feeexpects a second hook-account prefix before the Endpoint accounts. Callers that assembleremaining_accountsfrom the configured OFT fee can omit this second hook prefix for dust-only fees, causing the program to parse the Endpoint account as the fee hook program and revert withInvalidTransferHookProgram. The repo's test helper also reflects this assumption by computinghasFeefromamount *bps / 10000, while the on-chain condition is the dust-inclusive receipt delta. `// Fee hook accounts are only included when fees will be collected (fee_ld > 0). // The on-chain program splits remaining_accounts as: // [transfer_hook..., fee_hook...(if fee>0), endpoint...] // so including fee hooks when fee=0 would misalign the endpoint accounts slice.// Must compute fee_ld using the same integer arithmetic as on-chain (amount * bps / 10000).`
Recommendation
Document that fee-hook accounts must be included whenever
amount_sent_ld - amount_received_ld > 0, including dust-only deltas, not only when configured fee bps produces a nonzero fee. -
I-05 Informational RoleType is always required Informational L O C A T I O N apps/oft-app/contracts/solana/oft/macros/src/oft.rs#L49 Resolved
Description
The [I-06] Undocumented
RoleTyperequirement in #[oft]was resolved with the following comment that says: #[oft] now [...] accepts an optional role_type = attribute, defaulting to crate::RoleType when omitted The actual implementation doesn't have such fallback logic, instead therole_typeis required.Recommendation
Confirm whether the field should be required or optional.
-
I-06 Informational Receive-Types Token Mint Comment Mismatch Informational Resolved
Description
The step-1 receive-types comment says
token_mintis included because V2 needs it to "derive token_program for ATA creation". The implementation uses the token program stored inoft_store.token_program, not a token program derived fromtoken_mint.lz_receive_types_infoderives the destination ATA withctx.accounts.oft_store.token_programand passes that stored token program as its own required account.lz_receive_types_v2then receivestoken_programas a separate account and uses it in the token-mint constraint and destination ATA derivation. Thetoken_mintaccount is still needed by V2, but for mint validation and mint-data reads such as mint authority, decimals, and transfer-hook extension metadata. The current comment points readers to the wrong dependency when reviewing receive-account planning.Recommendation
Update the comment to explain that
token_mintis included so V2 can validate and read the mint, including transfer-hook metadata, whiletoken_programcomes fromoft_store.token_program. -
I-07 Informational Mint Bypass Comment Names Wrong Modifier Informational Resolved
Description
The transfer-hook module comment says EVM
mint()has neitherwhenNotGloballyPausednoronlyAllowlistedmodifiers. In the scoped EVM implementation, the pause modifier used byERC20PlusiswhenNotPaused;mint()is gated only byonlyRole(MINTER_ROLE), whileburn,transfer, andtransferFromusewhenNotPausedand/or allowlist modifiers. The high-level statement thatmint()bypasses pause/list checks is correct, but the modifier name does not match the EVM contract. This makes the Solana comment harder to verify against the referenced EVM behavior.Recommendation
Change the comment to say that EVM
mint()has neitherwhenNotPausednoronlyAllowlistedmodifiers. -
I-08 Informational Fees Should Account For Executor Funded ATA Creation Documentation Resolved
Description
lz_receivecreates the recipient token account withinit_if_neededand uses the executor as payer for rent. The recipient address is taken from the delivered OFT message, so messages to first-time recipients can require the executor to fund a new ATA during destination execution.quote_sendquotes the LayerZero endpoint fee from the message and options, but the on-chain quote path does not itself check whether the destination ATA already exists or whether rent will be needed. Integrators should ensure that the source-chain quoted/executor fees and execution options are sufficient to cover destination-side ATA creation and rent when the recipient token account may not already exist.Recommendation
Document that first-time recipient ATA creation is funded during
lz_receiveby the executor payer, and that source-side fee quoting/options should account for this destination rent cost. -
I-09 Informational Document Escrow Entries In Whitelist Mode Documentation Resolved
Description
In whitelist mode, an OFT escrow needs both a whitelist entry and a source-bypass entry for the intended bridge flow. Outbound sends transfer from the user to the escrow, so the escrow is checked as the destination and must be whitelisted. Inbound receives transfer from the escrow to the recipient, so the escrow is the source and needs source-bypass to avoid requiring every recipient or authority to be whitelisted. The
PermanentDelegaterecovery path follows EVM parity and determines recovery eligibility from allowlist status, not source-bypass status. If the escrow is de-whitelisted while source-bypass remains enabled, thePermanentDelegatecan move funds from the escrow through the recovery path, even though source-bypass still applies to normal escrow-as-source transfers. OFT admins should be aware of the misconfiguration risk of source-bypassed but de-whitelisted escrows.Recommendation
Document that whitelist-mode OFT escrows require both whitelist and source-bypass entries, and the risk of de-whitelisting a source-bypassed escrow.
-
I-10 Informational QuoteOFT Max Understates Sendable Amount Unexpected Behavior Resolved
Description
quote_oftderivesoft_limit.max_amount_ldby taking the outbound rate-limit availability and passing it throughget_amount_before_fee: // quote_oft.rs if max_amount_ld!= 0&&max_amount_ld!= u64::MAX {max_amount_ld = fee_config::get_amount_before_fee( ctx.accounts.oft_store.default_fee_bps, fee_config.as_ref(), max_amount_ld, ); } let oft_limit = OFTLimit { min_amount_ld: 0, max_amount_ld };get_amount_before_feeinverts only the fee and does nothing about dust: `// fee_config_view.rs pub fn get_amount_before_fee(default_fee_bps: u16, cfg: Option<&FeeConfig>, amount_after_fee: u64) -> u64 { let bps = get_fee_bps(default_fee_bps, cfg) as u128; let denom = MAX_FEE_BASIS_POINTS as u128; if bps >= denom { return 0; } if bps == 0 { return amount_after_fee; } let amount_before_fee = (amount_after_fee as u128) * denom / (denom - bps); u64::try_from(amount_before_fee).unwrap_or(u64::MAX)}
The actual send path instead applies dust removal after the fee, and the rate limiter is charged againstthe dust-removed received amount:// send.rs (debit_view) let oft_fee = fee_config::get_oft_fee(oft_store.default_fee_bps, fee_config, amount_ld); let amount_received_ld = oft_store.remove_dust(amount_ld - oft_fee); // remove_dust(x) = x - x % ld2sd_rateBecauseremove_dustmaps a contiguous band of pre-fee amounts onto the same post-fee, dust-alignedreceived amount, several distinct pre-feeamount_ldvalues all produce the identicalamount_received_ld. Thefee-only inverse returns the smallest such pre-fee amount, somax_amount_ld`lands one dust plateau short of the true maximum that fits within capacity.The quote can therefore simultaneously report (a) that a specific requested send receives an amount within capacity and executes successfully, and (b) a
max_amount_ldlower than that same requested amount. The under-report is always in the conservative direction, since the quote never advertises a max larger than what the send path accepts, so no rate-limit ceiling can be bypassed through it. Clients that trustoft_limit.max_amount_ldas a hard cap can reject sends that the program would accept, degrading the quote and view API's usefulness at the dust margin near the rate-limit ceiling. This is also the behavior on EVM, so if the decision is to fix this, it should also be fixed on EVM,Recommendation
When converting post-fee capacity back to a max pre-fee send amount, account for the dust plateau by returning the largest pre-fee
amount_ldwhoseremove_dust(amount_ld - fee(amount_ld))still fits within capacity, rather than the fee-only inverse. Or clearly document this mismatch and acknowledge it as intended. -
I-11 Informational recoverFunds Parity Comment Uses Wrong Signature Documentation Resolved
Description
The transfer-hook doc comments cite the EVM parity as
recoverFunds(from, amount). The actual EVM signature isrecoverFunds(address _from, address _to, uint256 _amount)(ERC20Plus.sol#L144), the_torecipient argument is omitted.Recommendation
Update the comment to
recoverFunds(from, to, amount). -
I-12 Informational Fund-Recovery Authority Rotation Undocumented Documentation Resolved
Description
Blocked-source fund recovery is gated solely on the mint's Token-2022 PermanentDelegate (
get_permanent_delegate(&mint_data)!= Some(signer)intry_bypass_for_fund_recovery), decoupled from the hook's RBAC. The docs already disclose this and label it intentional ("authority-model adaptation", "not bugs") intransfer-hook-evm-parity-analysis.md,transfer-hook.md,AUDIT.md, and thetransfer_hook.rsmodule comment. Two consequences are NOT documented: (1) the PermanentDelegate rotates via a one-step SPLSetAuthoritycall with no pending/accept step, unlike EVM's two-stepDEFAULT_ADMIN_ROLEhandoff; (2) recovery keys off a principal distinct from the hook's DefaultAdmin, so it sits outside the governance protecting every other hook action. The capability is EVM-equivalent, so this is purely a documentation gap, not a privilege bug.Recommendation
Note in
transfer-hook-evm-parity-analysis.mdthat the recovery authority is the mint PermanentDelegate (a distinct principal from the hook DefaultAdmin) and rotates one-step viaSetAuthoritywith no accept step. -
I-13 Informational Rate Exemption Seed Name Typo Documentation Resolved
Description
The
RateLimitAddressExemptionPDA comment documents the seed asRATE_LIMITER_EXEMPTION_SEED. No constant with that name exists in the codebase. The actual seed constant isRATE_LIMIT_EXEMPTION_SEEDinseeds.rs, andRateLimitAddressExemption::seedsuses that constant. The seed bytes are correct at runtime, but the comment points integrators and reviewers to a non-existent symbol.Recommendation
Update the
RateLimitAddressExemptionPDA comment to referenceRATE_LIMIT_EXEMPTION_SEED. -
I-14 Informational Per-ID PDA Seed Width Docs Documentation Resolved
Description
The
RateLimit,PauseConfig, andFeeConfigPDA comments document the id seed as &eid.to_be_bytes(), which suggests the 4-byte big-endian EID encoding. The actual seed helpers useid.to_u128_be_bytes(), producing a 16-byte big-endianu128seed for rate-limit, pause, and fee PDAs. An SDK author or reviewer copying the comments literally would derive different non-resolving PDA addresses.Recommendation
Update the three PDA seed comments to state that the id component is the canonical 16-byte big-endian
u128encoding, matchingid.to_u128_be_bytes(). -
I-15 Informational Rate Limiter Docs Cite Wrong File Documentation Resolved
Description
Several rate-limiter comments cite
RateLimiterCoreUpgradeable.solas the EVM reference for_getRateLimitUsage,_getRateLimitUsages, and_getRateLimitStateAndConfig. That file does not exist in the scoped EVM sources. The referenced functions live inRateLimiterBaseUpgradeable.sol, which the same Solana file already cites correctly elsewhere.Recommendation
Replace the
RateLimiterCoreUpgradeable.solreferences withRateLimiterBaseUpgradeable.sol. -
I-16 Informational Pause Docs Cite Wrong EVM Contract Documentation Resolved
Description
The
console_oftper-ID pause instruction comments describe EVM alignment asPauseRBACUpgradeable.setPaused(...)andPauseRBACUpgradeable.setDefaultPaused(bool). Those functions are part of the per-ID pause implementation,PauseByIDRBACUpgradeable, whilePauseRBACUpgradeableexposes only the globalpause/unpauseflow. The comments therefore point reviewers to the wrong EVM contract for theIPauseByIDmapping. In addition, the README states that thelz_receivepath is performing a pause check, which is not true.Recommendation
Change the comments to reference
PauseByIDRBACUpgradeable.setPaused(...)andPauseByIDRBACUpgradeable.setDefaultPaused(bool). Also fix the readme. -
I-17 Informational Misleading fee inverse edge cases Documentation Resolved
Description
get_amount_before_fee()is documented as the inverse of the fee calculation: given a post-fee amount, it returns the pre-fee amount needed. This description is incomplete for edge cases where the inverse is not unique or cannot be represented exactly. Whenamount_after_fee==0, multiple pre-fee amounts can produce zero post-fee output depending on the configured fee and integer rounding. This is especially visible whenbps==MAX_FEE_BASIS_POINTS, where every pre-fee amount produces zero post-fee output, so returning0is only one conservative representative value rather than a uniquely inferred pre-fee amount.The helper also clamps results that exceed
u64::MAXthroughu64::try_from(amount_before_fee).unwrap_or(u64::MAX), which means the returned value may be a saturated cap rather than the exact mathematical pre-fee amount. These behaviors may be acceptable for quote-limit usage, but the current "inverse of fee" documentation does not describe them.Recommendation
Update the documentation for
get_amount_before_fee()to describe its edge-case behavior explicitly. State that0is returned as a conservative representative when the requested post-fee amount is zero or when a 100% fee makes every pre-fee amount produce zero post-fee output, and that values aboveu64::MAXare saturated tou64::MAX.
Round 3 - Remediation Review
2 findings-
I-01 Informational Informational Note Regarding Rate Limit Test Informational L O C A T I O N console_oft/src/state/rate_limit.rs#L760 Resolved
Description
test_missing_per_eid_state_quote_errors_when_net_accounting_needs_state isintendedtovalidatethequote-specificpath for missing per-EID rate-limit state when outbound is disabled but inbound net accounting still requires state. However, the test calls
get_rate_limit_usagesinstead ofget_outbound_available_for_quoteand does not cover intended quote functionality. Noting that this is only a test-suite issue and does not affect production behavior.Recommendation
Update the test to call
get_outbound_available_for_quotedirectly so the assertion covers the intended quote functionality. -
I-03 Informational Misleading Globally-Disabled Usage Doc Documentation Resolved
Description
The documentation on
get_rate_limit_usagesstates that when rate limiting is globally disabled, "both availabilities areu64::MAX, but usage values still reflect decayed on-chain state" (rate_limit.rs#L237-L238). This is asserted unconditionally, but the function has a branch that contradicts it. When the resolved state is absent,get_rate_limit_usagestakes the missing-state path (rate_limit.rs#L250-L257):let Some(state) = state else { require!( oft_store.is_globally_disabled||(config.outbound_skips_state() && config.inbound_skips_state()), OFTError::RateLimitNotInitialized ); returnOk(RateLimitUsages::unlimited()); }; RateLimitUsages::unlimited()hardcodesoutbound_usage:0andinbound_usage: 0(rate_limit.rs#L148-L155) and reads no on-chain state.As a result, for the same logical configuration (globally disabled), the reported usage differs depending on whether the optional per-EID rate-limit account exists: when it exists, usage is the decayed on-chain value the comment promises; when it is absent, usage is a hardcoded zero returned without touching state. The doc sentence describes only the first case. There is no behavioral defect: a missing per-EID account has never recorded any in-flight amount, so its correctly-decayed usage genuinely is zero, and returning zero is consistent with the EVM mapping model where an uninitialized
idreads as a zero-usage entry. The impact is confined to documentation precision. An integrator or reviewer relying on the comment could assume the globally-disabled usages view always performs a decay computation over stored state, when the missing-account branch instead returns fixed zeros.Recommendation
Update the docs to reflect the actual behavior, qualifying the sentence so it applies only when state exists.
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 OApp
54 findings 54 findings: 1 medium, 9 low, 44 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.