Organization
- @Telcoin
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Low Risk
7 findings
4 fixed
3 acknowledged
Informational
6 findings
5 fixed
1 acknowledged
Low Risk7 findings
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
SendParamandMessagingFeestructures: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 constructsSendParam, rejects unsupported commands/composition, and applies project-approved defaults. The inheritedsend()may remain available if advanced LayerZero functionality is intentionally supported.LayerZero Compose messages are accepted without a guaranteed compose execution configuration
The bridge accepts arbitrary
composeMsgdata throughSendParam: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) toSEND_AND_CALL(2) and causes the destination to schedulelzComposeafter crediting tokens. The deployment configuration, however, defines and configures onlySEND: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 encodesOPTION_TYPE_LZRECEIVE; it does not add an executorlzComposegas 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
SENDmessages, and no minimum compose gas is enforced. A caller may supply suitable options manually, but malformed or incompleteSEND_AND_CALLoptions 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 explicitSEND_AND_CALLenforced options containing bothlzReceiveandlzComposegas and test the complete compose lifecycle.NativeBridge/TelcoinBridge pause/unpause requiring owner role may affect liveness during emergency
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.
Permit compatibility depends on EIP-7702 delegate implementing ERC-1271
Permit validation uses
SignatureChecker:if (!SignatureChecker.isValidSignatureNow(owner_, hash, signature)) { revert InvalidSignature();}SignatureCheckeruses ECDSA only whenowner_.code.length == 0. An EIP-7702 delegated account has code, so validation is routed throughIERC1271.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.
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
TelcoinBridgeremains unpaused:- A user can burn paused-chain TEL through an outbound bridge transfer.
- TEL can be minted to another address on a destination chain.
- With a return route, value can ultimately be minted to another address on the originally paused chain.
Thus, pausing
TelcoinV3does 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.
ITelcoinBridge does not describe the deployed bridge ABI
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 onlyOwnerThe interface also declares custom
BridgeSentandBridgeReceivedevents 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.
Immediate bridge replacement will block in-flight messages
State
- Acknowledged
Severity
- Severity: Low
Submitted by
cergyk
Affected:
src/MintBurnWrapper.sol:95-102A 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
Outdated natspec in TelcoinBridge and NativeBridge
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 throughwhenNotPausedthat the bridge has not already been paused. It neither changes pause state nor independently blocks inbound receives. The separate owner-onlypause()function changes the pause state, while_lzReceive()separately applieswhenNotPausedto inbound delivery.The documentation also asserts that
_lzReceive()emits a customBridgeReceivedevent. 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, notBridgeReceived.Recommendation: Change the
send()notice to describe an outbound transfer that reverts while paused. Update_lzReceive()NatSpec to reference the inheritedOFTReceivedevent and document that inbound and outbound paths enforce pause state independently.renounceRole() prohibition can be bypassed through self-revocation
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_ROLEadministers itself, the sole admin can permanently disable upgrades and future role administration.Recommendation: Override
revokeRole()where self-revocation must be prohibited, or useAccessControlDefaultAdminRulesUpgradeablewith delayed two-step admin transfer.Withdrawn legacy tokens can be recycled through a reopened migration
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_CAPstill prevents localtotalSupply()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.
Unused Nonces import
import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol";import {Nonces} from "@openzeppelin/contracts/utils/Nonces.sol";Noncesis not referenced directly. Nonce handling is inherited throughERC20Permit.Recommendation: Remove the unused import.
Legacy initializer is callable because its name does not match the contract
The contract is named
TelcoinV2, but its constructor-style function is namedTelcoin: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
Telcoincontract, so it is unaffected. Any testnet or new deployment compiled from this exactTelcoinV2source is unsafe.Recommendation: Replace it with an explicit constructor-compatible name:
function TelcoinV2(address _distributor) public { balances[_distributor] = totalSupply; Transfer(address(0), _distributor, totalSupply);}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 localtotalSupply()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_CAPis 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 aggregatetotalSupply()across every deployment.