Telcoin Association

Telcoin: tel-v3

Cantina Security Report

Organization

@Telcoin

Engagement Type

Cantina Reviews

Period

-

Researchers


Findings

Low Risk

7 findings

4 fixed

3 acknowledged

Informational

6 findings

5 fixed

1 acknowledged


Low Risk7 findings

  1. NativeBridge/TelcoinBridge API exposes LayerZero-specific parameters without enforcing intended defaults

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    cergyk


    The only transfer endpoint exposes the complete LayerZero SendParam and MessagingFee structures:

    function send(    SendParam calldata _sendParam,    MessagingFee calldata _fee,    address _refundAddress) external payable override whenNotPaused returns (...) {    return _send(_sendParam, _fee, _refundAddress);}

    This leaks transport-specific details into integrations and accepts fields that project documentation describes as unused, including:

    • _sendParam.composeMsg
    • _sendParam.oftCmd
    • _fee.lzTokenFee

    Integrators can therefore submit configurations outside the project’s intended basic-transfer profile.

    Recommendation: Add a simplified bridge() wrapper that constructs SendParam, rejects unsupported commands/composition, and applies project-approved defaults. The inherited send() may remain available if advanced LayerZero functionality is intentionally supported.

  2. LayerZero Compose messages are accepted without a guaranteed compose execution configuration

    State

    Severity

    Severity: Low

    Submitted by

    cergyk


    The bridge accepts arbitrary composeMsg data through SendParam:

    function send(    SendParam calldata _sendParam,    MessagingFee calldata _fee,    address _refundAddress) external payable override whenNotPaused returns (...) {    return _send(_sendParam, _fee, _refundAddress);}

    A nonempty compose payload changes LayerZero's message type from SEND (1) to SEND_AND_CALL (2) and causes the destination to schedule lzCompose after crediting tokens. The deployment configuration, however, defines and configures only SEND:

    uint16 constant SEND = 1;...params[updateCount] = EnforcedOptionParam({    eid: dst.eid,    msgType: SEND,    options: desired});

    There is no corresponding enforced-options entry for SEND_AND_CALL, and _buildLzReceiveOption() only encodes OPTION_TYPE_LZRECEIVE; it does not add an executor lzCompose gas option:

    function _buildLzReceiveOption(uint128 gas) internal pure returns (bytes memory) {    return abi.encodePacked(        uint16(3),        uint8(1),        uint16(17),        uint8(1), // OPTION_TYPE_LZRECEIVE        gas    );}

    Consequently, composed messages do not inherit the configured minimum receive gas for ordinary SEND messages, and no minimum compose gas is enforced. A caller may supply suitable options manually, but malformed or incomplete SEND_AND_CALL options can leave the composed call unexecuted after tokens have already been credited, potentially stranding funds in a recipient contract whose workflow depends on composition.

    Recommendation: Either reject nonempty composeMsg, or add explicit SEND_AND_CALL enforced options containing both lzReceive and lzCompose gas and test the complete compose lifecycle.

  3. NativeBridge/TelcoinBridge pause/unpause requiring owner role may affect liveness during emergency

    State

    Severity

    Severity: Low

    Submitted by

    cergyk


    The bridge owner controls both pausing directions:

    function pause() external onlyOwner {    _pause();}
    function unpause() external onlyOwner {    _unpause();}

    The same owner also controls LayerZero configuration inherited from the OApp and token rescue functionality. If the same governance account owns MintBurnWrapper, it additionally controls which bridge receives mint/burn authority.

    This combines routine emergency operations with high-impact configuration authority.

    Recommendation: Introduce separate pauser and unpauser roles. Keep bridge configuration and wrapper ownership behind a multisig and preferably a timelock.

  4. Permit compatibility depends on EIP-7702 delegate implementing ERC-1271

    State

    Severity

    Severity: Low

    Submitted by

    cergyk


    Permit validation uses SignatureChecker:

    if (!SignatureChecker.isValidSignatureNow(owner_, hash, signature)) {    revert InvalidSignature();}

    SignatureChecker uses ECDSA only when owner_.code.length == 0. An EIP-7702 delegated account has code, so validation is routed through IERC1271.isValidSignature.

    Consequently, ordinary ECDSA permits fail if the delegated implementation does not implement compatible ERC-1271 validation. Delegated smart accounts that correctly implement ERC-1271 remain supported.

    Recommendation: Document the requirement, add EIP-7702 compatibility tests, or implement an explicitly defined fallback policy if ECDSA signatures from delegated accounts must remain supported.

  5. Token pause can be bypassed through bridge burn/mint paths

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    cergyk


    The pause check excludes mint and burn operations:

    function _update(address from, address to, uint256 value) internal override(ERC20) {    if (paused() && from != address(0) && to != address(0)) {        revert EnforcedPause();    }
        ERC20._update(from, to, value);}

    Therefore, if TelcoinBridge remains unpaused:

    1. A user can burn paused-chain TEL through an outbound bridge transfer.
    2. TEL can be minted to another address on a destination chain.
    3. With a return route, value can ultimately be minted to another address on the originally paused chain.

    Thus, pausing TelcoinV3 does not globally freeze movement.

    Recommendation: Coordinate token and bridge pausing operationally, or make bridge mint/burn honor token pause state if pause is intended as a global freeze.

  6. ITelcoinBridge does not describe the deployed bridge ABI

    State

    Severity

    Severity: Low

    Submitted by

    cergyk


    The interface exposes obsolete methods:

    function bridge(    uint32 _dstEid,    address _to,    uint256 _amount,    bytes calldata _options) external payable returns (MessagingReceipt memory receipt);
    function quote(...) external view returns (MessagingFee memory fee);
    function rescueTokens(address _token, uint256 _amount) external;

    The implementation instead exposes inherited send()/quoteSend(), and its rescue method requires a recipient:

    function rescueTokens(    address _token,    uint256 _amount,    address _to) external onlyOwner

    The interface also declares custom BridgeSent and BridgeReceived events that the implementation does not emit. Integrations compiled against this interface will call nonexistent selectors or monitor nonexistent events.

    Recommendation: Regenerate the interface from the current implementation and add ABI-conformance tests.

  7. Immediate bridge replacement will block in-flight messages

    State

    Acknowledged

    Severity

    Severity: Low

    Submitted by

    cergyk


    Affected: src/MintBurnWrapper.sol:95-102

    A new bridge immediately replaces the existing authorized bridge:

    function authorizeBridge(address _bridge) external onlyOwner {    if (_bridge == address(0)) revert ZeroAddress();    if (bridge == _bridge) revert BridgeAlreadySet();
        bridge = _bridge;    emit BridgeAuthorized(_bridge);}

    After replacement, delayed inbound messages delivered through the old bridge fail when it calls MintBurnWrapper.mint(). LayerZero messages may be retryable, but processing remains blocked until authorization is restored or migration is otherwise coordinated.

    Recommendation: Use separate inbound/outbound authorization, stage bridge replacement, and retain old inbound authority until all in-flight messages have drained.

Informational6 findings

  1. Outdated natspec in TelcoinBridge and NativeBridge

    State

    Severity

    Severity: Informational

    Submitted by

    cergyk


    The NatSpec immediately preceding send() says that the function pauses the bridge:

    /** * @notice Pauses the bridge — blocks send and receive. * @dev Overrides OFTCore.send() to enforce pausability on the standard OFT entry point. */function send(...) external payable override whenNotPaused returns (...) {    return _send(_sendParam, _fee, _refundAddress);}

    This does not represent what send() does. The function initiates an outbound bridge transfer and merely checks through whenNotPaused that the bridge has not already been paused. It neither changes pause state nor independently blocks inbound receives. The separate owner-only pause() function changes the pause state, while _lzReceive() separately applies whenNotPaused to inbound delivery.

    The documentation also asserts that _lzReceive() emits a custom BridgeReceived event. The implementation only delegates to LayerZero and emits no such event directly:

    /** * @dev Enforces pausability on inbound messages and emits BridgeReceived for indexing. */function _lzReceive(...) internal whenNotPaused override {    super._lzReceive(_origin, _guid, _message, _executor, _extraData);}

    The inherited implementation emits LayerZero’s OFTReceived, not BridgeReceived.

    Recommendation: Change the send() notice to describe an outbound transfer that reverts while paused. Update _lzReceive() NatSpec to reference the inherited OFTReceived event and document that inbound and outbound paths enforce pause state independently.

  2. renounceRole() prohibition can be bypassed through self-revocation

    State

    Severity

    Severity: Informational

    Submitted by

    cergyk


    The contract claims role holders cannot voluntarily give up their roles:

    function renounceRole(bytes32, address) public pure override {    revert CannotRenounceRole();}

    Nevertheless, an admin can use inherited revokeRole() against itself:

    vault.revokeRole(DEFAULT_ADMIN_ROLE, msg.sender);

    Because DEFAULT_ADMIN_ROLE administers itself, the sole admin can permanently disable upgrades and future role administration.

    Recommendation: Override revokeRole() where self-revocation must be prohibited, or use AccessControlDefaultAdminRulesUpgradeable with delayed two-step admin transfer.

  3. Withdrawn legacy tokens can be recycled through a reopened migration

    State

    Severity

    Severity: Informational

    Submitted by

    cergyk


    Legacy tokens may be withdrawn after expiry:

    function withdrawOldTokens(address destination) external onlyOwner {    if (block.timestamp < migrationExpiry + withdrawalDelay) revert WithdrawalLocked();
        uint256 balance = oldToken.balanceOf(address(this));    oldToken.safeTransfer(destination, balance);}

    The owner can subsequently move the expiry into the future:

    function setMigrationExpiry(uint256 newMigrationExpiry) external onlyOwner {    if (newMigrationExpiry == 0 || migrationExpiry > newMigrationExpiry) {        revert InvalidExpiry();    }
        migrationExpiry = newMigrationExpiry;}

    The withdrawn OLD tokens can then be submitted again to mint additional NEW tokens. The local MIGRATION_SUPPLY_CAP still prevents local totalSupply() from exceeding 100 billion, so the comment’s claim of directly bypassing that check is imprecise. Nevertheless, recycling violates one-OLD-to-one-NEW conservation and can inflate global supply when previously minted NEW tokens have been bridged and burned locally.

    Recommendation: Permanently close migration once OLD tokens are withdrawn, or prohibit expiry extension after the original expiry/first withdrawal.

  4. Unused Nonces import

    State

    Severity

    Severity: Informational

    Submitted by

    cergyk


    import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";

    Nonces is not referenced directly. Nonce handling is inherited through ERC20Permit.

    Recommendation: Remove the unused import.

  5. Legacy initializer is callable because its name does not match the contract

    State

    Severity

    Severity: Informational

    Submitted by

    cergyk


    The contract is named TelcoinV2, but its constructor-style function is named Telcoin:

    contract TelcoinV2 {    function Telcoin(address _distributor) public {        balances[_distributor] = totalSupply;        Transfer(0x0, _distributor, totalSupply);    }}

    Under Solidity 0.4.18, a constructor must match the contract name exactly. Therefore, in the current source, Telcoin() is a normal public runtime function. Anyone can repeatedly assign the full nominal supply to arbitrary addresses, creating effectively unbounded balances.

    The existing Ethereum mainnet TEL contract is deployed with a correctly named Telcoin contract, so it is unaffected. Any testnet or new deployment compiled from this exact TelcoinV2 source is unsafe.

    Recommendation: Replace it with an explicit constructor-compatible name:

    function TelcoinV2(address _distributor) public {    balances[_distributor] = totalSupply;    Transfer(address(0), _distributor, totalSupply);}
  6. Supply cap is enforced independently per chain rather than globally

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    cergyk


    mint() compares only the current chain's local totalSupply() against the nominal migration cap:

    uint256 public constant MIGRATION_SUPPLY_CAP = 100_000_000_000 ether;
    function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {    _mint(to, amount);    if (totalSupply() > MIGRATION_SUPPLY_CAP) revert SupplyCapExceeded();}

    Every TelcoinV3 deployment maintains an independent totalSupply(). If TEL is deployed on three chains, each deployment can therefore have up to 100 billion TEL while satisfying this check, allowing the aggregate supply across all chains to reach as much as 300 billion.

    Normal LayerZero burn-and-mint bridging is intended to preserve aggregate supply, but this property is not enforced by TelcoinV3.mint() itself. Independent minters, migration contracts, configuration errors, or compromised mint authority can increase supply on multiple chains while every local cap check continues to pass.

    Recommendation: Define whether MIGRATION_SUPPLY_CAP is intended as a per-chain or global limit. If global, designate a canonical supply chain or implement cross-chain supply accounting and restrict non-bridge minting on satellite chains. At minimum, monitor and reconcile the aggregate totalSupply() across every deployment.