Solidity Fundamentals

Your First Smart Contract: Build a Simple Storage DApp

Put everything together by building, deploying, and interacting with a complete storage smart contract.

30 min

Designing the Simple Storage Contract

Now it is time to put everything together and build your first complete smart contract — a Simple Storage DApp that allows users to store and retrieve values on the blockchain. While simple in concept, this project teaches you the full lifecycle of smart contract development: writing, compiling, testing, deploying, and interacting.

The contract will have three core features: storing a uint256 value with an associated label, retrieving stored values by address, and maintaining a history of all stored values. Here is the contract: "// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract SimpleStorage { struct StoredData { uint256 value; string label; uint256 timestamp; } mapping(address => StoredData) public userValues; address[] public users; event ValueStored(address indexed user, uint256 value, string label); }".

The struct groups related data — the stored number, a human-readable label, and the timestamp of storage. The mapping connects each user's address to their stored data, providing O(1) lookups. The users array tracks all addresses that have stored values, enabling enumeration. The event enables off-chain applications to listen for storage actions in real time.

We use indexed parameters on the event's address field so that front-end applications can efficiently filter events by specific users. The SPDX license identifier at the top is required by the compiler and specifies the open-source license for your code. The pragma statement locks the compiler version to 0.8.20 or higher within the 0.8.x range, ensuring consistent behavior across compilation environments.

Implementing the Contract Functions

With the state variables and event defined, add the core functions. The store function allows any user to save a value: "function store(uint256 _value, string calldata _label) external { require(bytes(_label).length > 0, 'Label cannot be empty'); if (userValues[msg.sender].timestamp == 0) { users.push(msg.sender); } userValues[msg.sender] = StoredData(_value, _label, block.timestamp); emit ValueStored(msg.sender, _value, _label); }".

This function demonstrates several concepts. The require statement validates input — labels cannot be empty. The conditional check on timestamp determines if this is a new user (timestamp defaults to 0 for unmapped addresses) and adds them to the users array only once. We use calldata for the string parameter since we only need to read it, saving gas compared to memory. The event emission at the end logs the action for off-chain consumption.

Add a view function to retrieve data: "function retrieve(address _user) external view returns (uint256 value, string memory label, uint256 timestamp) { StoredData memory data = userValues[_user]; return (data.value, data.label, data.timestamp); }". This returns multiple values as a tuple. We copy the struct to memory because returning storage references directly is not supported for external functions.

Finally, add a utility function: "function getTotalUsers() external view returns (uint256) { return users.length; }". View functions cost no gas when called externally because they do not modify state and can be computed locally by any node. This makes them ideal for front-end data fetching. Your complete contract is now around 40 lines — small enough to understand fully, but rich enough to demonstrate real patterns used in production contracts.

Testing and Deploying Your Contract

Writing tests is non-negotiable in smart contract development. Create a test file at "test/SimpleStorage.test.js". Using Hardhat's testing framework with Ethers.js and Chai: "const { expect } = require('chai'); const { ethers } = require('hardhat'); describe('SimpleStorage', function () { let storage, owner, user1; beforeEach(async function () { const SimpleStorage = await ethers.getContractFactory('SimpleStorage'); storage = await SimpleStorage.deploy(); [owner, user1] = await ethers.getSigners(); }); it('should store and retrieve a value', async function () { await storage.connect(user1).store(42, 'Answer'); const result = await storage.retrieve(user1.address); expect(result.value).to.equal(42); expect(result.label).to.equal('Answer'); }); });".

Run your tests with "npx hardhat test". Hardhat spins up a temporary blockchain, deploys your contract, executes each test in isolation, and reports results. Add tests for edge cases: storing with an empty label should revert, storing multiple times should update the value, and the user count should increment correctly.

For deployment, create "scripts/deploy.js": "async function main() { const SimpleStorage = await ethers.getContractFactory('SimpleStorage'); const storage = await SimpleStorage.deploy(); await storage.waitForDeployment(); console.log('SimpleStorage deployed to:', await storage.getAddress()); } main().catch(console.error)". Run "npx hardhat run scripts/deploy.js --network sepolia" to deploy to the Sepolia testnet. Make sure your hardhat.config.js has the Sepolia network configured with your RPC URL and private key.

After deployment, verify your contract on Etherscan with "npx hardhat verify --network sepolia DEPLOYED_ADDRESS". Verification publishes your source code, allowing anyone to read and verify that the deployed bytecode matches. This transparency builds trust and lets users interact with your contract directly through Etherscan's interface.

Your First Smart Contract: Build a Simple Storage DApp — Solidity Fundamentals | Crypto Hawking