Every DeFi protocol assumes it can be monitored. Block explorers, portfolio trackers, security alert systems, and audit platforms all rely on one core primitive: eth_getLogs. It's fast, cheap, and scalable. There's just one problem — it has a blind spot, and a common Solidity pattern falls straight into it.
What is an internal ETH transfer?
In Solidity, ETH can be forwarded between contracts using a low-level call:
(bool success,) = address(target).call{value: amount}("");
require(success, "Transfer failed");
When this executes inside an internal or private function — or inside a receive() handler that silently accepts and forwards ETH — it produces no event log entry. The ETH moves. The balance changes. But eth_getLogs returns nothing, because no emit statement fired.
This is fundamentally different from an ERC-20 token transfer, which always emits a Transfer event by standard. Native ETH has no such requirement, so developers can (and often do) move it silently.
Why this is a security concern
The consequences reach further than you might expect:
- Monitoring blind spots: Portfolio monitoring tools — including automated security alert systems — that watch for unusual ETH outflows will miss internal transfers entirely if they rely on log-based indexing.
- Audit traceability: During an incident response, reconstructing exactly how ETH moved through a contract requires
debug_traceTransactionor similar execution trace APIs. These are CPU-intensive, often rate-limited on commercial RPCs, and not available at all on some providers. This also compounds the risk of unchecked return values — if a silent internal transfer fails and is not checked, neither the failed call nor the fund loss appear in logs. - Reentrancy obscurity: A
receive()function that silently forwards ETH is harder to reason about during a security audit. The absence of events makes the call graph less visible to automated tools and reviewers alike. - Indexer incompatibility: Subgraph-based indexers (The Graph) and most on-chain analytics platforms are built on event logs. Contracts that move ETH without emitting events are effectively unindexable by these tools.
The execution trace workaround — and its cost
To catch internal ETH transfers, indexers must reconstruct the full call tree and look for CALL or SELFDESTRUCT opcodes that move value. This requires requesting an execution trace for each transaction:
// Instead of the cheap:
eth_getLogs({ fromBlock, toBlock, address })
// You need the expensive:
debug_traceTransaction(txHash, { tracer: "callTracer" })
The trade-off is significant. Execution trace requests are an order of magnitude more CPU and I/O intensive than log queries. Most public RPC endpoints enforce strict gas limits (50M–100M gas) per eth_call or tight timeouts on trace requests. At scale, this becomes prohibitively expensive — which is exactly why most monitoring tools don't do it.
The practical outcome: if your contract moves ETH without emitting events, it is being monitored less thoroughly than one that does. That gap matters during an active exploit.
Solution 1: Emit a custom event in receive()
The simplest fix is to add an event declaration and emit it wherever ETH is accepted or forwarded:
// Declare at the top of your contract
event EtherReceived(address indexed sender, uint256 amount);
event EtherForwarded(address indexed recipient, uint256 amount);
// In your receive() function
receive() external payable {
emit EtherReceived(msg.sender, msg.value);
}
// At every internal forwarding site
function _forwardFunds(address payable recipient, uint256 amount) internal {
emit EtherForwarded(recipient, amount);
(bool success,) = recipient.call{value: amount}("");
require(success, "ETH transfer failed");
}
With this pattern, every ETH movement produces a log entry. eth_getLogs sees everything. Your monitoring stack works without execution traces.
Solution 2: The pull-payment pattern
A more architectural solution is to eliminate push payments entirely. Instead of the contract sending ETH to recipients, it records how much each recipient is owed, and recipients call a withdraw() function to claim their funds.
OpenZeppelin's PullPayment contract implements this pattern out of the box:
import "@openzeppelin/contracts/security/PullPayment.sol";
contract MyContract is PullPayment {
function reward(address payable recipient, uint256 amount) internal {
_asyncTransfer(recipient, amount);
// Funds are held in an escrow; recipient withdraws separately
}
}
Every _asyncTransfer call and every withdrawPayments call by a recipient generates an observable, attributable transaction. There are no internal transfers to miss. The trade-off is UX complexity: recipients must make an explicit withdrawal transaction instead of receiving ETH automatically.
Which solution to use
For most contracts, adding events is the right call — it's minimal code change with maximum observability gain. The pull-payment pattern is worth considering for contracts that frequently send ETH to many parties (royalty splitters, reward distributors) where the denial-of-service risk of push payments is also a concern.
In either case, the underlying principle is the same: if a fund movement isn't in your event logs, it won't be in anyone's monitoring system.
How auditors check for this
Modern smart contract audits include fund flow traceability as part of the observability assessment. Specifically, auditors look for:
- Any
receive()orfallback()function that accepts ETH without emitting an event. - Internal functions that call
.call{value:},.transfer(), or.send()without a precedingemit. - Whether the full ETH flow through the contract can be reconstructed from logs alone — without execution traces.
Our automated scanner flags contracts matching this pattern as VPS-015: Internal ETH Transfer Without Event Emission. Run a free audit on your contract to check whether your fund flows are fully observable.