Source-Text and Compiler Pitfalls
The bugs in this section have a common pattern: the auditor's eyes are deceived by something that is not in the bytecode the compiler ultimately produces. A Unicode trick changes what the reviewer reads versus what the compiler parses. A floating pragma changes which compiler runs. A deprecated function compiles to a different opcode than the reviewer assumed. A modifier with subtle ordering changes the call sequence. These bugs survive code review by hiding outside the lines of source the auditor is concentrating on.
Right-to-Left Override (U+202E) and Bidi Tricks
Unicode includes invisible control characters that flip the visual rendering direction of subsequent text. The right-to-left override character U+202E is the canonical example. Inserted into source code, it makes the text appear to read one way while the compiler parses it the other.
function ()drowssap_emanresu(transfer uint256 amount, address to) external {
// looks like: function transfer_username_password(uint256, address)
// is actually: function transfer(uint256, address) with no underscore split
}
Practical attack scenarios:
- Token contracts named visually identical to legitimate tokens, with a hidden bidi character making the on-chain symbol different from what scanners and wallets display.
- Comments that visually reassure the reviewer while the code does something else.
- Function names in a malicious dependency that appear to match a trusted interface.
Other dangerous-by-design Unicode codepoints include zero-width characters (U+200B, U+200C, U+200D), homoglyphs (Latin a vs. Cyrillic а), and bidi-overriding marks (U+202A–E, U+2066–9).
Remediation.
- Configure linters and editors to flag non-ASCII characters in source files. Solhint's
no-unicode-textrule and similar work. - The Solidity compiler emits a warning when bidi control characters appear in source; do not suppress it.
- Require ASCII-only source as a CI gate. If non-ASCII is needed in user-facing strings, isolate it to clearly-marked constants.
- Read code through
cat -vor a hex viewer when something looks visually inconsistent.
Floating Pragma
A pragma like pragma solidity ^0.8.20; permits the contract to be compiled with any 0.8.x compiler at or above 0.8.20. The contract you reviewed at 0.8.20 may be deployed under 0.8.27 (or a hypothetical 0.8.99) — potentially with different code generation, different optimizer behavior, or different bug surface from a newly-introduced compiler bug.
Remediation. Lock the pragma to a single version in production code: pragma solidity 0.8.27;. Use floating ranges only in libraries that genuinely need to compile against multiple downstream consumers, and even then be conservative. The deployment metadata (Sourcify, Etherscan verification) should record the exact compiler version used; verify it matches the source.
Outdated Compiler
A locked pragma is necessary but not sufficient. The version locked must not be one with known bugs. Solidity publishes a bugs.json and bugs_by_version.json in the compiler repo; tools like Slither check against it automatically. Versions to be especially wary of:
- Anything below 0.8.0 lacks default overflow checking. Code depending on
SafeMathfor safety must apply it consistently. - Anything below 0.5.0 has the constructor-naming bug, default-public functions, and uninitialized-storage-pointer bug — see §4.18.7.
- 0.5.0 through 0.5.9 have the ABI Encoder v2 storage-array bug.
- 0.6.x and 0.7.x are abandoned for new development; production contracts on these versions should at minimum be checked against the known-bugs list for their exact version.
Remediation. Use a recent, stable compiler — currently any 0.8.x at or above the most recent patch release of the active line. Check bugs.json for the chosen version. Re-verify after every compiler upgrade that the deployed bytecode still matches the source.
Use of Deprecated Solidity Functions
Several language features have been deprecated, removed, or replaced over Solidity's history. Encountering them in code is a signal that the contract was written against an older mental model and may carry other dated assumptions.
| Deprecated | Replacement | Notes |
|---|---|---|
suicide(addr) | selfdestruct(addr) | Removed in 0.5.0; see also EIP-6780 semantic change (Cancun). |
sha3(...) | keccak256(...) | Removed in 0.5.0. |
throw | require / revert | Removed in 0.5.0; produces no error message. |
years unit | (none) | Removed in 0.5.0; was 365 days, ignored leap years. |
var x = ... (type inference) | explicit type | Removed in 0.5.0; type inference silently produced unintended smaller types. |
callcode | delegatecall | Removed in 0.5.0. |
function () { ... } (anonymous fallback) | fallback() external { ... } / receive() external payable { ... } | Split in 0.6.0. |
now | block.timestamp | Deprecated 0.7.0; identical semantics. |
msg.sender.transfer(x) | (bool ok, ) = msg.sender.call{value:x}("") | Discouraged post-EIP-1884; see §4.18.4. |
Remediation. If deprecated forms appear in the source you are auditing, treat their presence as evidence that the entire codebase needs a compiler-and-style review, not just a fix to the deprecated call. Modern Solidity tooling rejects most of these at compile time; their appearance suggests the contract was either ported from an older version without thorough review, or developed by someone working from outdated references.
Variable Shadowing
A local variable, parameter, or modifier parameter with the same name as a state variable silently masks it within scope. The compiler warns; the warning is sometimes suppressed.
contract Vault {
address public owner;
function init(address owner) external { // shadows state owner
owner = owner; // assigns parameter to itself
}
}
The state variable is never set. The audit-trip is that the code looks correct.
Variants:
- State variable shadowed by inherited contract. Base and derived both declare
owner; the derived one masks the base. Pre-0.6 this compiled silently; modern Solidity errors, but legacy contracts exist. - Modifier parameter shadowing. A modifier with a parameter named identically to a state variable used inside the modifier body.
- Function name shadowed by event. Less dangerous but produces unreadable code.
Remediation. Adopt a naming convention that prevents collision: _paramName for parameters, s_stateName (or m_/leading underscore) for state. Treat compiler shadowing warnings as errors in CI.
Complex Modifiers
A modifier is a code-injection mechanism: its body wraps the function it adorns, with _; marking where the function body runs. Bugs arise when:
- Multiple modifiers are stacked and the order matters.
nonReentrant onlyOwnerruns the reentrancy check before the ownership check;onlyOwner nonReentrantruns them in the opposite order. If the ownership check writes state (rare but possible), the order matters. - The modifier's body extends beyond the
_;placeholder. Post-_;code runs after the function body, which may include external calls or state changes that violate the function's apparent invariants. - The modifier branches around
_;. As shown in §4.18.1 Incorrect Modifier Names,if (cond) _;without anelse { revert }silently no-ops on failure. - A modifier internally calls another modified function. Recursion via modifier interaction is rare but has produced findings.
Remediation. Prefer modifiers that contain only a require (or revert) followed by _;. Push complex logic into internal functions called from the modifier body so the structure is readable. Make modifier ordering explicit in NatSpec when it matters.
Incorrect Interface
A contract calls an external contract through an interface declaration. If the interface's function signature does not match the deployed contract — wrong parameter type, wrong return type, wrong function name — the call either fails (selector mismatch, transaction reverts) or, worse, succeeds in calling a different function that happens to share the selector.
Common sources:
- The interface is copied from an older version of the target's source; the target has since been upgraded with a different signature.
- A parameter is
uint256in the interface butuint128in the target. Calldata still decodes "successfully" but with the high bits dropped. - The target function returns
boolbut the interface declares no return. The contract proceeds as if the call succeeded even when the target returnedfalse(the classic non-checking-USDT pattern). - The interface includes a function the target does not implement. Solidity's compile-time checks do not validate against deployed bytecode.
Remediation. Pin interfaces to the exact deployed bytecode where possible: use the target's published interface artifact (often available in the deployment's npm package or repo). For widely-used standards (ERC-20, ERC-721), use OpenZeppelin's interfaces, which are widely vetted. For bool-returning calls, use SafeERC20.safeTransfer, safeTransferFrom, etc., which check both the return value and the presence of return data.
Auditor Checklist
- CI rejects non-ASCII characters in source unless explicitly allowed; compiler bidi warnings not suppressed.
- Pragma is locked to a single compiler version in all production contracts.
-
Compiler version is current, free of known bugs (cross-check
bugs.jsonfor the chosen version). -
No deprecated functions (
suicide,sha3,throw,var,callcode, anonymous fallback,now) in the source. - No shadowing warnings; naming convention enforced for parameters and state variables.
-
Modifiers are simple (single
require/revertfollowed by_;); ordering documented when significant. -
Every external interface matches the target's deployed signature; ERC-20 calls use
SafeERC20. - Deployment metadata (Sourcify, Etherscan verification) records the exact compiler version, settings, and source used.