Security & Cross-Chain Development
Smart contract vulnerabilities have caused billions in losses. This guide covers the most common attack vectors, oracle security patterns, testing strategies, and the fundamentals of building cross-chain DeFi applications.
Reentrancy Attacks
Reentrancy remains the most notorious smart contract vulnerability. It occurs when an external call re-enters the contract before state updates complete, allowing an attacker to drain funds by repeatedly calling withdraw before the balance is zeroed.
The fix has two layers: update state before making external calls (checks-effects-interactions), and use OpenZeppelin's ReentrancyGuard as a second line of defence. Modern Solidity 0.8+ eliminates integer overflow as a contributing factor, but the core reentrancy pattern still applies.
Vulnerable vs. secure withdrawal// VULNERABLE — external call before state update
function withdraw() external {
uint256 amount = balances[msg.sender];
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] = 0; // Too late!
}
// SECURE — state update before external call + guard
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0; // State updated first
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}Access Control
Role-based access control separates admin, operator, and pauser privileges. This prevents a single compromised key from controlling the entire protocol. OpenZeppelin's AccessControl provides a battle-tested implementation with role hierarchies.
Critical operations — changing reward rates, pausing the contract, upgrading implementations — should require different roles. The DEFAULT_ADMIN_ROLE can grant and revoke other roles, so it should be held by a multi-sig or timelock contract, never by a single EOA.
Role-based access controlimport "@openzeppelin/contracts/access/AccessControl.sol";
contract SecureProtocol is AccessControl {
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(ADMIN_ROLE, msg.sender);
}
function setRewardRate(uint256 newRate) external onlyRole(ADMIN_ROLE) {
require(newRate <= 10000, "Rate too high");
rewardRate = newRate;
}
function pause() external onlyRole(PAUSER_ROLE) { paused = true; }
function unpause() external onlyRole(ADMIN_ROLE) { paused = false; }
}Oracle Security
Price oracle manipulation is one of the most common DeFi attack vectors. A protocol that trusts a single price feed without validation is vulnerable to flash-loan-powered price manipulation, stale data during network congestion, and oracle downtime.
Defence in depth means checking staleness (reject prices older than a threshold), validating sanity (reject negative prices or extreme deviations from the last known good price), and implementing multi-oracle aggregation with weighted voting. If all oracles fail, the protocol should pause rather than operate on bad data.
Multi-oracle with weighted aggregationfunction getWeightedPrice() public view returns (uint256) {
uint256 totalWeight = 0;
uint256 weightedSum = 0;
for (uint256 i = 0; i < oracleKeys.length; i++) {
OracleData memory oracle = oracles[oracleKeys[i]];
if (!oracle.isActive) continue;
try oracle.feed.latestRoundData() returns (
uint80, int256 price, uint256, uint256 updatedAt, uint80
) {
if (price > 0 && block.timestamp - updatedAt <= 3600) {
weightedSum += uint256(price) * oracle.weight;
totalWeight += oracle.weight;
}
} catch {
continue; // Skip failed oracle
}
}
require(totalWeight > 0, "No valid oracles");
return weightedSum / totalWeight;
}Testing and Verification
A production DeFi contract needs unit tests for every function, integration tests for complex workflows, fuzz tests for random input patterns, and ideally formal verification for critical invariants. Target at least 95 % test coverage before any audit.
Fuzz testing catches edge cases that manual tests miss. Run hundreds of randomised deposit/withdraw sequences and assert that total deposits always equals the sum of individual balances. Formal verification tools like Certora can mathematically prove invariants hold for all possible inputs — not just the ones you thought to test.
Fuzz test: state consistencydescribe("Fuzz Testing", function () {
it("Should maintain state consistency across random operations", async function () {
for (let i = 0; i < 100; i++) {
const user = Math.random() > 0.5 ? user1 : user2;
const action = Math.random() > 0.5 ? "deposit" : "withdraw";
const amount = ethers.utils.parseEther((Math.random() * 100).toFixed(2));
try {
if (action === "deposit") {
await token.connect(user).approve(protocol.address, amount);
await protocol.connect(user).deposit(amount);
} else {
const balance = await protocol.userDeposits(user.address);
if (balance.gt(0)) {
const w = amount.gt(balance) ? balance : amount;
await protocol.connect(user).withdraw(w);
}
}
} catch (error) {
expect(error.message).to.match(/(InsufficientBalance|InvalidAmount)/);
}
}
// Invariant: totals must match
const total = await protocol.totalDeposits();
const b1 = await protocol.userDeposits(user1.address);
const b2 = await protocol.userDeposits(user2.address);
expect(total).to.equal(b1.add(b2));
});
});Audit Preparation
Before engaging an auditor, run through a thorough pre-audit checklist. Code quality items: comprehensive NatSpec documentation, consistent style, no unused imports, all TODOs resolved, gas optimisations applied. Security items: reentrancy protection, access controls, input validation, oracle manipulation protection, emergency pause mechanism.
Testing requirements: unit tests for all functions, integration tests for complex workflows, edge-case tests, fuzz tests, and coverage above 95 %. Documentation: technical specification, architecture diagrams, known limitations, deployment instructions, and an emergency procedures runbook.
Cross-Chain Architecture
Multi-chain DeFi requires a chain abstraction layer that maps chain IDs to RPC URLs, contract addresses, block explorers, and native currencies. This lets your frontend dynamically switch between Ethereum, Polygon, Arbitrum, and Base without hard-coded logic throughout the codebase.
Cross-chain deposits use a bridge contract pattern: lock tokens on the source chain, send a message via the bridge, and mint or release tokens on the destination chain. The receiving contract must validate that the message came from the bridge and from the correct source-chain protocol address. Always implement replay protection and consider the finality guarantees of each chain.
Cross-chain deposit flowfunction depositCrossChain(uint256 amount, uint256 targetChain)
external nonReentrant
{
require(chainProtocols[targetChain] != address(0), "Unsupported chain");
IERC20(depositToken).transferFrom(msg.sender, address(this), amount);
bytes memory message = abi.encode(msg.sender, amount);
bytes32 messageId = bridge.sendMessage(
targetChain,
chainProtocols[targetChain],
message
);
emit CrossChainDeposit(msg.sender, amount, targetChain, messageId);
}
function receiveMessage(
uint256 sourceChain, address sourceAddress, bytes calldata message
) external {
require(msg.sender == address(bridge), "Only bridge");
require(sourceAddress == chainProtocols[sourceChain], "Invalid source");
(address user, uint256 amount) = abi.decode(message, (address, uint256));
userDeposits[user] += amount;
totalDeposits += amount;
}