F-2026-0012·decimals-mismatch

Hardcoded 18-decimal wrapped tokens break unwrap fees and wallet displays

Fixedbridgecross-chainkey-registrygithub.com/pdxwebdev/yadakeyeventwallet
TL;DR

WrappedToken.initialize does not accept a decimals parameter and ERC20Upgradeable defaults to 18 for all wrapped tokens, so unwrap fees collapse to near-zero for non-18-decimal originals and wallets display tiny incorrect balances.

Severity
LOW
Impact
MEDIUM
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
LOW
Exploitability
LOW
02Section · Description

Description

WrappedToken.initialize never accepts or sets custom decimals. ERC20Upgradeable defaults to 18 decimals for all wrapped tokens regardless of the original token's decimals.

The Bridge mints and burns raw amounts directly without decimal conversion. This creates two concrete problems:

Asymmetric fee calculation between wrap and unwrap. In _handleWrap, the fee is computed using permit.token (the original token), so decimals is correct. In _handleUnwrap, the fee is computed using permit.token (the wrapped token), which is always 18:

solidity
// _handleUnwrap, permit.token is the wrapped token
uint8 decimals = (permit.token == address(0))
? 18
: IERC20WithDecimals(permit.token).decimals();
uint256 maxFeeRate = 10 ** decimals;
uint256 tokenFee =
(recipient.amount * uctx.feeInfo.fee) / maxFeeRate;

For a 6-decimal original token (e.g., USDT on some chains): wrapping uses maxFeeRate = 10^6 (correct), but unwrapping uses maxFeeRate = 10^18 (fees become near-zero). The protocol loses fee revenue on every unwrap of non-18-decimal token pairs.

Misleading wallet and explorer display. Wrapping 1 USDC (1,000,000 raw units at 6 decimals) mints 1,000,000 raw wrapped tokens interpreted as 18 decimals, displaying as 0.000000000001 in wallets and block explorers.

While most BSC tokens use 18 decimals, the protocol has no validation preventing registration of non-18-decimal tokens.

03Section · Recommendation

Recommendation

Pass the original token's decimals to WrappedToken.initialize and override decimals() to match:

solidity
uint8 private _decimals;
function initialize(
string memory name,
string memory symbol,
address _bridge,
uint8 decimals_
) public initializer {
__ERC20_init(name, symbol);
__ERC20Permit_init(name);
__Ownable_init(_bridge);
__UUPSUpgradeable_init();
bridge = _bridge;
_decimals = decimals_;
}
function decimals() public view override returns (uint8) {
return _decimals;
}
04Section · Resolution

Resolution

YadaCoin, Confirmed. Added a decimals parameter to WrappedToken.initialize and stored it in a _decimals state variable with a corresponding decimals() override.

Zealynx, Fixed. Verified the fix including alignment of the Factory.createToken parameters to pass the decimals value through the full token creation flow.

Status
Fixed
F-2026-0012

oog
zealynx

Smart Contract Security Digest

Monthly exploit breakdowns, audit checklists, and DeFi security research — straight to your inbox

© 2026 Zealynx