ANQ logo

AnQ Bridge & INR Stablecoin LayerZero OFT Security Review Report

January 2026

Overview

The analyzed resources were sent in an archive with the following SHA256 hash:

bfb23527ac7ffe5362c447fb5c29911276b0fe9aad0f23f9eef2c15506ba6ebf

Scope

The issues described in the report were fixed in the following version (SHA256 hash):

6654736bf4b5914857324225b1cbdcc60622125d35b437a93c05af50a0448a93

Summary

Total number of findings
9

Weaknesses

This section contains the list of discovered weaknesses.

ANQ1-7 | BRIDGE MESSAGE VALIDATION ASSUMES IDENTICAL TOKEN ADDRESSES ACROSS CHAINS

Severity:

Medium

Status:

Fixed

Path:

Contracts/AnqBridge/src/SpokeBridge.sol#L133, Contracts/AnqBridge/src/HubBridge.sol#L137

Description:

The bridge validates that the token field in incoming messages matches the local token address. HubBridge._processRelease() reverts if message.token != address(token), and SpokeBridge._processMint() reverts if message.token != address(bridgedToken).

Bridge messages are constructed with the local token address, so cross‑chain validation succeeds only if the token is deployed at the same address on every chain. The current deployment script uses standard deployments without deterministic address control; if it is used for the bridged token, addresses will differ across chains.

In that case, receiveBridge will revert on the destination chain. Hub‑to‑Spoke transfers would leave tokens locked in the hub, and Spoke‑to‑Hub transfers would burn tokens on the spoke without release on the hub. Hub‑locked funds can be recovered by an admin via rescueTokens(), but spoke‑side burns are not recoverable through the bridge.

function _processRelease(BridgeMessageLib.BridgeMessage calldata message) internal {
    if (message.amount == 0) revert InvalidAmount();
    if (message.sender == address(0)) revert InvalidSender();
    if (message.recipient == address(0)) revert InvalidRecipient();
    if (message.token != address(token)) revert InvalidTokenAddress();
    if (message.sourceChainId == 0 || message.sourceChainId == block.chainid) {
        revert InvalidChainId();
    }
    if (message.destinationChainId != block.chainid) {
        revert InvalidChainId();
    }
    if (!chainConfigs[message.sourceChainId].authorized) {
        revert ChainNotAuthorized();
    }

Remediation:

Enforce deterministic, same‑address deployments across all supported chains (e.g., a CREATE2‑based deployment flow), or update the bridge to validate against explicit per‑chain token address configuration instead of strict equality with the local token address.

ANQ1-8 | NO STORAGE GAP FOR UPGRADEABLE BRIDGE CONTRACT MAY LEAD TO STORAGE SLOT COLLISION

Severity:

Medium

Status:

Fixed

Path:

Contracts/AnqBridge/src/BaseBridge.sol

Description:

The BaseBridge contract is an upgradable contract containing multiple state variables. Both HubBridge and SpokeBridge inherit from BaseBridge and introduce their own additional state variables. However, BaseBridge lacks a storage gap, which is necessary to prevent storage collisions during upgrades.

Without a storage gap, if new variables are added to the base contract in a future version, they will overwrite the existing variables in the child contracts. This could lead to severe consequences for HubBridge and SpokeBridge, such as a complete contract malfunction or the loss of user funds.

For example, if a new state variable is added to BaseBridge's code, the storage layout shifts. The new variable would overwrite the token variable in HubBridge during the implementation upgrade, potentially leading to a freeze in the bridge's functionality.

Remediation:

Recommend adding storage gap at the end of upgradeable contracts (BaseBridge, HubBridge and SpokeBridge), such as the below:

uint256[50] private __gap;

ANQ1-4 | SINGLE DVN CONFIGURATION IN CROSS-CHAIN PATHWAY

Severity:

Medium

Status:

Fixed

Path:

LayerZero/oft-upgradeable/layerzero.config.ts#L35-L37

Description:

The LayerZero OFT integration in layerzero.config.ts configures only a single DVN for the cross-chain pathway. The current configuration specifies ['LayerZero Labs'] as the sole required DVN with no optional DVNs.

If carried over to production without modification, it introduces a single point of failure for message verification. Should the configured DVN experience downtime, become compromised, or behave maliciously, cross-chain messages could either fail to deliver or be incorrectly validated.

LayerZero's integration guidelines (LayerZero) list as a Don't "Configure only one DVN for a pathway and treat it as production-ready." Production deployments should use more than one DVN per pathway to ensure redundancy and independent verification.

const pathways: TwoWayConfig[] = [
    [polgyonAmoy, baseSepolia, [['LayerZero Labs'], []], [1, 1], [EVM_ENFORCED_OPTIONS, EVM_ENFORCED_OPTIONS]],
]

Remediation:

Update the pathway configuration to include at least two DVNs from independent operators before deploying to production. Configure both required and optional DVNs appropriately, and set the optional threshold to require verification from multiple DVNs before messages are considered valid. Verify that the selected DVN addresses match the official addresses published in LayerZero's documentation for the target networks.

ANQ1-1 | MISSING BLACKLIST CHECKS IN OFT CREDIT/DEBIT

Severity:

Medium

Status:

Fixed

Path:

LayerZero/oft-upgradeable/contracts/INRTokenOFT.sol#L179-L220

Description:

Blacklist validation is missing in the two critical OFT lifecycle functions:

  • _credit(): mints tokens on the destination chain
  • _debit(): burns tokens on the source chain While standard transfer paths enforce blacklist checks, the OFT bridge-specific logic does not, creating an inconsistent security boundary.
function _debit(
    address _from,
    uint256 _amountLD,
    uint256 _minAmountLD,
    uint32 _dstEid
) internal virtual override returns (uint256 amountSentLD, uint256 amountReceivedLD) {
    (amountSentLD, amountReceivedLD) = _debitView(_amountLD, _minAmountLD, _dstEid);
 
    // @dev In NON-default OFT, amountSentLD could be 100, with a 10% fee, the amountReceivedLD amount is 90,
    // therefore amountSentLD CAN differ from amountReceivedLD.
 
    // @dev Default OFT burns on src.
    _burn(_from, amountSentLD);
}
 
function _credit(
    address _to,
    uint256 _amountLD,
    uint32 /*_srcEid*/
) internal virtual override returns (uint256 amountReceivedLD) {
    if (_to == address(0x0)) _to = address(0xdead); // _mint(...) does not support address(0x0)
    // @dev Default OFT mints on dst.
    _mint(_to, _amountLD);
    // @dev In the case of NON-default OFT, the _amountLD MIGHT not be == amountReceivedLD.
    return _amountLD;
}

Remediation:

Consider enforcing blacklist checks consistently in both _credit and _debit functions.

ANQ1-3 | PAUSE DOES NOT PREVENT CROSS-CHAIN DEBIT/CREDIT

Severity:

Medium

Status:

Fixed

Path:

LayerZero/oft-upgradeable/contracts/INRTokenOFT.sol#L179-L220

Description:

In INRTokenOFT.sol, regular token transfers and mint/burn operations are gated by whenNotPaused, but the cross-chain paths _debit and _credit are not. As a result, a user can still initiate a LayerZero send() while the contract is paused, allowing burns and mints to occur through the omnichain flow even during an emergency pause.

function _debit(
    address _from,
    uint256 _amountLD,
    uint256 _minAmountLD,
    uint32 _dstEid
) internal virtual override returns (uint256 amountSentLD, uint256 amountReceivedLD) {
    (amountSentLD, amountReceivedLD) = _debitView(_amountLD, _minAmountLD, _dstEid);
    _burn(_from, amountSentLD);
}
 
function _credit(
    address _to,
    uint256 _amountLD,
    uint32 /*_srcEid*/
) internal virtual override returns (uint256 amountReceivedLD) {
    if (_to == address(0x0)) _to = address(0xdead); // _mint(...) does not support address(0x0)
    _mint(_to, _amountLD);
    return _amountLD;
}

Remediation:

Apply the pause check to the cross-chain send path so that new outbound transfers are blocked while paused, and keep inbound credit behavior consistent with the chosen emergency model.

ANQ1-5 | PRIVILEGED ROLES CONCENTRATED IN DEPLOYER ADDRESS

Severity:

Low

Status:

Fixed

Path:

LayerZero/oft-upgradeable/deploy/MyOFTUpgradeable.ts

Description:

The deployment script in MyOFTUpgradeable.ts assigns all privileged roles to the deployer address without subsequent transfer logic. Specifically, the ProxyAdmin owner, OFT contract owner, and LayerZero endpoint delegate are all set to the same deployer address during deployment.

This concentration of privileges creates a single point of failure. If the deployer's private key is compromised, an attacker gains the ability to upgrade the contract implementation, modify OFT configurations, and change LayerZero endpoint settings. The lack of separation between these roles amplifies the impact of a single key compromise.

The current implementation does not include any post-deployment transfer steps.

const deploy: DeployFunction = async (hre) => {
    const { getNamedAccounts } = hre
    const { deployer } = await getNamedAccounts()
 
    console.log(`Deploying ${contractName} on network: ${hre.network.name} with ${deployer}`)
 
    const eid = hre.network.config.eid as EndpointId
    const lzNetworkName = endpointIdToNetwork(eid)
    const { address: endpointAddress } = getDeploymentAddressAndAbi(lzNetworkName, 'EndpointV2')
 
    const { address: proxyAdminAddress } = await deployProxyAdmin({
        hre,
        deployOptions: {
            from: deployer,
            args: [deployer], // owner
            skipIfAlreadyDeployed: true,
        },
        deploymentName: contractName,
    })
 
    const { address: implementationAddress } = await deployImplementation({
        hre,
        deployOptions: {
            from: deployer,
            args: [endpointAddress], // constructor arguments
            skipIfAlreadyDeployed: true,
            contract: contractName,
        },
        deploymentName: contractName,
    })
 
    const initializeInterface = new hre.ethers.utils.Interface([
        'function initialize(string memory name, string memory symbol, address delegate)',
    ])
    const initializeData = initializeInterface.encodeFunctionData('initialize', ['INR Token', 'INR', deployer])
 
    const { address: proxyAddress } = await deployProxy({
        hre,
        deployOptions: {
            from: deployer,
            args: [implementationAddress, proxyAdminAddress, initializeData], // initialize arguments
            skipIfAlreadyDeployed: true,
        },
        deploymentName: contractName,
    })
 
    await saveCombinedDeployment({ hre, deploymentName: contractName })
}

Remediation:

Implement a post-deployment procedure to transfer each privileged role to its intended address. Transfer the OApp delegate first, then transfer the OApp contract ownership. Finally, transfer the ProxyAdmin contract ownership separately, as it controls upgrade authority independently from the OFT contract. Consider assigning these roles to separate addresses or multisig wallets to reduce the impact of a single key compromise.

ANQ1-9 | UPGRADEABLE CONTRACTS MISSING _DISABLEINITIALIZERS() IN CONSTRUCTORS

Severity:

Low

Status:

Fixed

Path:

Contracts/AnqBridge/src/HubBridge.sol, Contracts/AnqBridge/src/SpokeBridge.sol

Description:

The contracts HubBridge and SpokeBridge are upgradeable but do not call _disableInitializers() in their constructors.

In upgradeable contract patterns (such as those using OpenZeppelin's UUPS or Transparent proxies), the implementation (logic) contract is deployed independently from the proxy. If the implementation contract does not call _disableInitializers() in its constructor, it can be initialized directly by anyone, which is unintended and can lead to security risks.

Reference:

Writing Upgradeable Contracts

Remediation:

Consider adding a constructor to each affected contract that calls _disableInitializers().

/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
    _disableInitializers();
}

ANQ1-2 | INITIAL MINT CAP NOT ENFORCED IN SETMINTALLOWANCE

Severity:

Informational

Status:

Acknowledged

Path:

LayerZero/oft-upgradeable/contracts/INRTokenOFT.sol#L301-L305, Contracts/INR/src/InrToken.sol#L236-L240

Description:

The setMintAllowance does not enforce initialMintCap, while setMinterRole does. A caller with MASTER_MINTER_ROLE can therefore set an allowance above the cap and bypass the intended upper bound. Because this action requires a privileged role, the impact is limited, but it weakens the cap's effectiveness as an administrative control.

function setMintAllowance(address minter, uint256 allowance) external onlyRole(MASTER_MINTER_ROLE) {
    if (minter == address(0)) revert ZeroAddress();
    _mintAllowances[minter] = allowance;
    emit MintAllowanceSet(minter, allowance);
}

Remediation:

Ensure that all allowance-setting paths enforce the same initialMintCap constraint.

Commentary from the client:

Around setInitialMintCap - that check is in place as a velocity cap on the first mint at setMinterRole (for security purposes), therefore it is not applied on setMintAllowance (setMintingAllowance can be more than setInitialMintCap).

ANQ1-6 | STALE MINT ALLOWANCE PERSISTS

Severity:

Informational

Status:

Fixed

Path:

LayerZero/oft-upgradeable/contracts/INRTokenOFT.sol#L301-L305, Contracts/INR/src/InrToken.sol#L236-L240

Description:

The function setMinterRole() revokes the MINTER_ROLE but does not clear or reset the associated mint allowance. Allowance management is decoupled from role management, allowing stale state to persist across role changes.

function setMinterRole(address minter, bool isMinter, uint256 allowance) external onlyRole(MASTER_MINTER_ROLE) {
    if (isMinter) {
        _grantRole(MINTER_ROLE, minter);
    } else {
        _revokeRole(MINTER_ROLE, minter);
    }
 
    if (initialMintCap < allowance) {
        revert InitialMintCapExceeded(initialMintCap, allowance);
    }
 
    _mintAllowances[minter] = allowance;
    emit MintAllowanceSet(minter, allowance);
}

Remediation:

Consider requiring the allowance to be 0 when revoking the minter role.

function setMinterRole(address minter, bool isMinter, uint256 allowance) external onlyRole(MASTER_MINTER_ROLE) {
    if (isMinter) {
        _grantRole(MINTER_ROLE, minter);
    } else {
        _revokeRole(MINTER_ROLE, minter);
++      require(allownace == 0);
    }
 
    if (initialMintCap < allowance) {
        revert InitialMintCapExceeded(initialMintCap, allowance);
    }
 
    _mintAllowances[minter] = allowance;
    emit MintAllowanceSet(minter, allowance);
}

Table of contents