0/5
## Building a Secure Vault Contract in Solidity Welcome to this lesson on constructing a `Vault` smart contract using Solidity. This contract is a crucial component in many DeFi systems, particularly those involving deposit/withdrawal mechanisms tied to custom tokens, like a rebase token. The Vault acts as the primary user interaction point, managing Ether (ETH) deposits, coordinating with a token contract to mint/burn tokens, handling ETH redemptions, and serving as a central repository for system funds and potential rewards. We'll cover the Vault's purpose, essential requirements, step-by-step implementation, and key Solidity best practices demonstrated throughout its construction. ### Purpose and Requirements of the Vault Contract The `Vault` contract serves several key functions within our hypothetical rebase token system: 1. **ETH Deposit:** Users need a way to deposit ETH into the system. 2. **Token Minting:** Upon ETH deposit, the Vault must interact with the associated `RebaseToken` contract to mint the corresponding amount of tokens for the user. 3. **Token Redemption:** Users need to be able to redeem their rebase tokens back for the underlying ETH. 4. **Token Burning:** Upon redemption, the Vault must interact with the `RebaseToken` contract to burn the user's tokens. 5. **ETH Withdrawal:** The Vault must securely transfer the corresponding amount of ETH back to the user during redemption. 6. **Reward Accumulation:** The Vault should be able to receive ETH transfers, potentially representing rewards generated elsewhere in the system, acting as the central pool for all related ETH. To achieve this, the Vault needs to know the address of the `RebaseToken` contract it needs to interact with. ### Initial Setup and State Variables Let's start by creating a new file, `Vault.sol`, and setting up the basic contract structure. ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; // Forward declaration for the interface we will create interface IRebaseToken; contract Vault { // State variable to store the RebaseToken contract address IRebaseToken private immutable i_rebaseToken; // Constructor to set the immutable token address constructor(IRebaseToken _rebaseToken) { i_rebaseToken = _rebaseToken; } // ... rest of the contract ... } ``` **Key Points:** * **SPDX License & Pragma:** Standard Solidity file requirements. * **State Variable (`i_rebaseToken`):** * We store the address of the `RebaseToken` contract. * **`private`:** Direct external access isn't required; we'll provide a specific getter function. * **`immutable`:** This is a crucial optimization. Since the `RebaseToken` address is set once in the constructor and never changes, marking it `immutable` saves significant gas costs compared to a regular storage variable. The value is stored directly in the contract's bytecode rather than in storage slots. * **Type (`IRebaseToken`):** We initially might think of using `address`, but to call functions like `mint` and `burn` on this address in a type-safe way, we use an interface type, `IRebaseToken`. We'll define this interface shortly. * **Constructor:** This special function runs only once when the contract is deployed. It accepts the address of the deployed `RebaseToken` (as an `IRebaseToken` type) and assigns it to our `immutable` state variable `i_rebaseToken`. Because `i_rebaseToken` is private, we provide a public getter function so external entities can query which token this vault is associated with: ```solidity // Public getter for the RebaseToken address /** * @notice Returns the address of the RebaseToken contract this vault interacts with. * @return address The address of the RebaseToken contract. */ function getRebaseTokenAddress() external view returns (address) { // Cast the interface type back to address for the return value return address(i_rebaseToken); } ``` **Note on NatSpec:** Notice the use of `/** ... */` for documentation comments. This multi-line format allows tools and IDEs to properly parse tags like `@notice` and `@return`, improving code readability and maintainability. Ensure all public/external functions and state variables are well-documented using NatSpec. ### Defining the Interaction Interface (`IRebaseToken`) To call functions on the `RebaseToken` contract from our `Vault`, Solidity needs to know *which* functions are available on that contract address. Storing it simply as `address` isn't enough. We use an **Interface**. An interface defines a contract's external function signatures without implementing them. It acts as a blueprint or contract Application Binary Interface (ABI) definition within Solidity. Create a new file, typically in an `interfaces` subdirectory (`src/interfaces/IRebaseToken.sol`): ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.24; /** * @title IRebaseToken Interface * @notice Defines the functions the Vault contract needs to call on the RebaseToken contract. */ interface IRebaseToken { /** * @notice Mints new tokens to a specified address. * @param _to The address to receive the minted tokens. * @param _amount The amount of tokens to mint. */ function mint(address _to, uint256 _amount) external; /** * @notice Burns tokens from a specified address. * @param _from The address whose tokens will be burned. * @param _amount The amount of tokens to burn. */ function burn(address _from, uint256 _amount) external; } ``` Back in `Vault.sol`, we import this interface: ```solidity import "./interfaces/IRebaseToken.sol"; ``` Now, when we use the type `IRebaseToken` for our `i_rebaseToken` variable, Solidity understands that this variable represents a contract where we can safely call `mint(address, uint256)` and `burn(address, uint256)`. ### Handling ETH Deposits (`deposit` function) The `deposit` function allows users to send ETH to the Vault and receive an equivalent amount of rebase tokens. ```solidity // Event emitted when a user deposits ETH event Deposit(address indexed user, uint256 amount); /** * @notice Allows users to deposit ETH and receive an equivalent amount of RebaseTokens. * @dev Mints tokens based on msg.value sent with the transaction. */ function deposit() external payable { // msg.value is the amount of ETH sent with the call uint256 amountToMint = msg.value; // Ensure some ETH was actually sent if (amountToMint == 0) { revert("Deposit amount must be greater than zero"); // Or use a custom error } // Call the mint function on the RebaseToken contract // msg.sender is the address that called this deposit function i_rebaseToken.mint(msg.sender, amountToMint); // Emit an event to log the deposit emit Deposit(msg.sender, amountToMint); } ``` **Explanation:** 1. **`external payable`:** The function is callable from outside the contract (`external`) and can accept ETH (`payable`). 2. **`msg.value`:** This global variable holds the amount of ETH (in Wei) sent along with the function call. 3. **`msg.sender`:** This global variable holds the address of the account that initiated the transaction. 4. **Interaction:** We call `i_rebaseToken.mint(msg.sender, amountToMint)`. This instructs the `RebaseToken` contract (whose address is stored in `i_rebaseToken`) to mint `amountToMint` tokens and assign them to the user (`msg.sender`). 5. **Event (`Deposit`):** We define and emit a `Deposit` event. Events log actions on the blockchain, making them easily searchable and usable by off-chain applications or user interfaces. * **`indexed`:** Marking the `user` parameter as `indexed` allows efficient filtering of events based on the user's address. ### Handling Token Redemption (`redeem` function) The `redeem` function allows users to burn their rebase tokens and receive the corresponding amount of ETH back from the Vault. This function demonstrates several important concepts, including the Checks-Effects-Interactions pattern and secure ETH transfer. ```solidity // Event emitted when a user redeems tokens for ETH event Redeem(address indexed user, uint256 amount); // Custom error for failed ETH transfer during redemption error Vault_RedeemFailed(); /** * @notice Allows users to burn their RebaseTokens and receive the equivalent amount of ETH. * @param _amount The amount of RebaseTokens to burn and redeem for ETH. */ function redeem(uint256 _amount) external { // Ensure the user is redeeming a non-zero amount if (_amount == 0) { revert("Redeem amount must be greater than zero"); // Or use a custom error } // --- Checks-Effects-Interactions Pattern --- // Effect: Burn the user's tokens first. // This modifies the state *before* the external call. i_rebaseToken.burn(msg.sender, _amount); // Interaction: Send ETH back to the user. // Using low-level .call for ETH transfer is recommended practice. (bool success, ) = payable(msg.sender).call{value: _amount}(""); // Check: Verify the external call (ETH transfer) succeeded. if (!success) { revert Vault_RedeemFailed(); } // Emit an event to log the redemption emit Redeem(msg.sender, _amount); } ``` **Explanation:** 1. **`external`:** Callable from outside, but not `payable` as it doesn't receive ETH. 2. **`_amount`:** The parameter specifying how many rebase tokens the user wants to burn. 3. **Checks-Effects-Interactions:** This is a crucial security pattern to prevent reentrancy attacks. * **Checks:** (Implicit check is done by `burn` which should revert if `msg.sender` doesn't have `_amount` tokens). We also added an explicit check for `_amount > 0`. * **Effects:** We first perform the state change by calling `i_rebaseToken.burn(msg.sender, _amount)`. This reduces the user's token balance *before* we attempt to send them ETH. * **Interactions:** Only after the internal state is updated do we interact with an external address by sending ETH using `.call`. 4. **Secure ETH Transfer (`.call`):** * Instead of using `.transfer()` or `.send()`, we use the low-level `payable(address).call{value: amount}("")`. * **Why?** `.transfer()` and `.send()` have a fixed gas stipend (2300 gas), which can fail if the recipient is a contract with a complex fallback/receive function, or due to future gas cost changes. `.call` forwards all available gas (unless explicitly limited), making it more robust. * `payable(msg.sender)`: Casts the recipient address to the `payable` type. * `{value: _amount}`: Specifies the amount of ETH to send. * `("")`: Passes empty calldata, indicating a plain ETH transfer, not a function call. * `(bool success, )`: `.call` returns a boolean indicating success and any return data (which we ignore here). 5. **Check Transfer Success:** It is **critical** to check the `success` boolean returned by `.call`. If the ETH transfer fails (e.g., the vault doesn't have enough ETH), we must revert the transaction. 6. **Custom Error (`Vault_RedeemFailed`):** Instead of `require(!success, "ETH transfer failed")`, we define and use a custom error (`error Vault_RedeemFailed(); ... revert Vault_RedeemFailed();`). Custom errors are significantly more gas-efficient than revert strings since Solidity 0.8.4. 7. **Event (`Redeem`):** Similar to `Deposit`, we emit an event to log the successful redemption, again indexing the `user`. ### Receiving ETH Rewards (`receive` function) To allow the Vault contract to receive direct ETH transfers (e.g., from reward distribution mechanisms or manual funding), we implement the special `receive` function. ```solidity /** * @notice Allows the contract to receive plain ETH transfers (e.g., for rewards). */ receive() external payable { // This function can optionally emit an event or perform other logic, // but here it simply accepts the ETH, increasing the contract's balance. } ``` **Explanation:** * The `receive()` function is executed when the contract receives ETH via a transaction that has no calldata (or empty calldata). * It **must** be declared `external payable`. * In this case, the function body is empty, meaning it simply accepts the incoming ETH, increasing `address(this).balance`. ### Summary of Best Practices Applied This Vault contract illustrates several important Solidity development practices: * **Interfaces:** Use interfaces (`IRebaseToken`) for type-safe interaction between contracts. * **Immutable Variables:** Use `immutable` for state variables set only in the constructor to save gas (`i_rebaseToken`). * **Custom Errors:** Prefer custom errors (`error Vault_RedeemFailed();`) over `require` with string messages for gas efficiency. * **Indexed Event Parameters:** Use `indexed` for event parameters like addresses (`user`) to facilitate off-chain filtering. * **Secure ETH Transfer:** Use low-level `.call{value: amount}("")` for sending ETH to external addresses and always check the return status. * **Checks-Effects-Interactions:** Apply this pattern, especially when performing external calls after internal state changes, to prevent reentrancy vulnerabilities (`redeem` function). * **NatSpec Documentation:** Use `/** ... */` comments with tags like `@notice`, `@param`, `@return`, and `@dev` to thoroughly document your code. * **`receive()` Function:** Implement the `receive()` function if your contract needs to accept plain ETH transfers. By implementing these features and following these practices, we have built a robust and relatively secure `Vault` contract that serves as a foundational piece for interacting with our rebase token system.
A foundational walkthrough to Building a Secure Vault Contract in Solidity - Learn to construct a Solidity Vault contract for managing ETH deposits/redemptions and interacting with a token contract. Implement key security patterns like Checks-Effects-Interactions and best practices for robust DeFi development.
Previous lesson
Previous
Next lesson
Next
Give us feedback
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Guest lecturers:
Juliette Chevalier
Lead Developer relations at Aragon
Nader Dabit
Director of developer relations at EigenLayer
Ally Haire
Developer relations at Protocol Labs
Harrison
Founder at GasliteGG
Last updated on April 4, 2025
Solidity Developer
Advanced FoundryDuration: 36min
Duration: 3h 06min
Duration: 5h 02min
Duration: 6h 02min
Duration: 2h 47min
Duration: 1h 23min
Duration: 4h 28min
Duration: 1h 19min
Duration: 1h 10min
Course Overview
About the course
Advanced smart contract development
How to develop a stablecoin
How to develop a DeFi protocol
How to develop a DAO
Advanced smart contracts testing
Fuzz testing
Manual verification
Web3 Developer Relations
$85,000 - $125,000 (avg. salary)
Web3 developer
$60,000 - $150,000 (avg. salary)
Smart Contract Engineer
$100,000 - $150,000 (avg. salary)
Smart Contract Auditor
$100,000 - $200,000 (avg. salary)
Security researcher
$49,999 - $120,000 (avg. salary)
Guest lecturers:
Juliette Chevalier
Lead Developer relations at Aragon
Nader Dabit
Director of developer relations at EigenLayer
Ally Haire
Developer relations at Protocol Labs
Harrison
Founder at GasliteGG
Last updated on April 4, 2025
Testimonials
Read what our students have to say about this course.
Chainlink
Chainlink
Gustavo Gonzalez
Solutions Engineer at OpenZeppelin
Francesco Andreoli
Lead Devrel at Metamask
Albert Hu
DeForm Founding Engineer
Radek
Senior Developer Advocate at Ceramic
Boidushya
WalletConnect
Idris
Developer Relations Engineer at Axelar