Smart contracts are the backbone of decentralized applications (dApps) in the Web3 ecosystem. They enable trustless, automated interactions across blockchains. One of the most powerful aspects of smart contracts is their ability to interact with each other — whether by calling functions, creating new contracts, or transferring value.
In this in-depth guide, we’ll explore key mechanisms for contract-to-contract communication, including call, staticcall, delegatecall, multicall, create, and create2, as well as best practices for sending and receiving ETH. Whether you're a developer building dApps or a Web3 enthusiast diving into the technical side, this article will equip you with practical knowledge and real-world examples using Foundry for testing.
👉 Discover how to deploy and test smart contracts securely on a leading blockchain platform.
Understanding Contract Calls in Solidity
At its core, a smart contract can invoke functions on another contract — much like one function calling another in traditional programming. There are multiple ways to achieve this, each with different implications for security, gas efficiency, and functionality.
Direct Interface-Based Calling
The safest and most readable method is using an interface to define the external functions of a target contract.
interface ICallee {
function setX(uint _x) external;
function getX() external view returns (uint);
}
contract Caller {
function callSetX(address calleeAddress, uint _x) public {
ICallee(calleeAddress).setX(_x);
}
function callGetX(address calleeAddress) public view returns (uint) {
return ICallee(calleeAddress).getX();
}
}This approach provides compile-time checks and ensures type safety. It’s ideal when you know the target contract’s ABI (Application Binary Interface).
Low-Level Call: Flexible but Risky
When the interface isn’t available, you can use the low-level .call() method:
(bool success, ) = calleeAddress.call(
abi.encodeWithSignature("setX(uint256)", _x)
);
require(success, "setX call failed");.call() sends arbitrary data to a contract and executes its code in the context of that contract. While flexible, it bypasses type checking and can lead to vulnerabilities if misused.
👉 Learn how to securely interact with smart contracts using advanced tools.
Why Use call?
- To send ETH (recommended by Ethereum docs).
- To interact with unknown contracts.
- To trigger
fallbackorreceivefunctions.
⚠️ Security Note: Avoid using .call() for critical logic unless absolutely necessary. Prefer interfaces for safer interactions.Static Call: Read-Only External Queries
For read-only operations (e.g., view or pure functions), use .staticcall():
(bool success, bytes memory data) = target.staticcall(
abi.encodeWithSignature("getX()")
);
require(success, "Call failed");
return abi.decode(data, (uint));.staticcall() reverts if the called function attempts to modify state, making it safe for off-chain queries and frontend integrations.
DelegateCall: Shared Storage, Separate Logic
delegatecall is crucial for proxy patterns used in upgradable contracts. Unlike regular calls, delegatecall executes the logic of one contract in the storage context of another.
(bool success, ) = logic.delegatecall(
abi.encodeWithSignature("setNum(uint256)", _num)
);Key Differences:
- Storage: Modified in the calling (proxy) contract.
- msg.sender: Remains the original caller.
- Use Case: Upgradable smart contracts via proxy patterns.
This pattern powers platforms like OpenZeppelin’s Upgrades Plugin and many DeFi protocols.
Multicall: Batch Operations for Efficiency
Executing multiple contract calls in a single transaction reduces gas costs and improves UX. The multicall pattern aggregates several function calls:
struct Call {
address target;
bytes data;
}
function multicall(Call[] calldata calls) external returns (bytes[] memory results) {
results = new bytes[](calls.length);
for (uint i = 0; i < calls.length; i++) {
(bool success, bytes memory result) = calls[i].target.call(calls[i].data);
require(success, "Call failed");
results[i] = result;
}
}Benefits of Multicall:
- Gas Savings: One transaction instead of many.
- Atomicity: All calls succeed or fail together.
- Frontend Optimization: Fetch multiple data points in one RPC call.
Foundry tests confirm that multicall returns expected values from multiple pure functions efficiently.
Creating Contracts: Create vs Create2
Contracts can dynamically deploy other contracts using new, which compiles down to EVM opcodes: CREATE and CREATE2.
CREATE: Simple but Unpredictable
Foo foo = new Foo(_age);The resulting address depends on the creator’s address and nonce — which changes with every deployment. This makes addresses non-deterministic across chains.
CREATE2: Predictable and Deterministic Addresses
Foo foo = new Foo{salt: SALT}(_age);With CREATE2, the address is computed as:
address = hash(0xff, creator, salt, initCode)This allows same-address deployment across multiple EVM chains, enabling seamless cross-chain interoperability.
Real-World Use Cases:
- Uniswap V2/V3: Predictable pair addresses.
- Account Abstraction: Wallet factories with known addresses.
- Cross-chain bridges: Deploy identical contracts on L2s.
You can even predict the address before deployment:
address predicted = address(uint160(uint256(keccak256(abi.encodePacked(
bytes1(0xff),
address(factory),
salt,
keccak256(bytecode)
)))));Test comparisons show predicted and actual addresses match perfectly.
Sending and Receiving ETH: Best Practices
Handling native ETH transfers requires understanding three key components.
Sending ETH: Three Methods Compared
| Method | Gas Limit | Reverts on Failure | Recommended? |
|---|---|---|---|
.call() | None | No (manual check) | ✅ Yes |
.transfer() | 2300 | ✅ Yes | ⚠️ Legacy |
.send() | 2300 | No | ❌ Avoid |
✅ Best Practice: Use .call{value: amount}("") for sending ETH — it’s more reliable and future-proof.Receiving ETH: receive(), fallback(), and Payable Functions
A contract can accept ETH via:
receive()– Triggered when no calldata is sent.receive() external payable {}fallback()– Triggered when function selector doesn’t match or noreceive().fallback() external payable {}Payable Functions – Regular functions marked
payable.function deposit() external payable { ... }
👉 Explore secure ways to manage digital assets across chains.
Frequently Asked Questions (FAQ)
Q: What’s the difference between call and delegatecall?
A: call runs code in the target contract’s context (its storage). delegatecall runs the target’s code in the caller’s storage — essential for proxy-based upgrades.
Q: When should I use create2 over create?
A: Use create2 when you need predictable addresses — ideal for factories, cross-chain deployments, or when frontends need to know addresses in advance.
Q: Is .transfer() safe to use?
A: While safe due to automatic reverts, it's deprecated because of the 2300 gas stipend limitation. Modern contracts should use .call() instead.
Q: Can staticcall modify state?
A: No. If a staticcall tries to write to storage, it will revert. This enforces read-only behavior.
Q: Why does multicall save gas?
A: Because all calls happen within one transaction and share execution context — avoiding repeated transaction overhead and leveraging internal message calls.
Q: How do I prevent reentrancy attacks when using .call()?
A: Always follow the Checks-Effects-Interactions pattern. Update your state before making external calls to avoid malicious callbacks.
This guide covers essential Web3 development patterns that power modern dApps. From secure contract interactions to efficient batch operations and deterministic deployments, mastering these concepts is key to building scalable and interoperable blockchain applications.