Demystifying Blockchain: Smart Contracts in Practice
Blockchain is more than hype โ it's a genuinely different computing paradigm. In this post I'll walk through how Solidity smart contracts actually work under the hood, deploy one to a testnet, and wire it up to a React frontend using ethers.js.
What Is a Smart Contract?
A smart contract is self-executing code that lives on the blockchain. Once deployed, it runs exactly as written โ no intermediary can alter it, censor it, or take it down. Every execution is publicly verifiable.
Think of it as a vending machine: you put in the right input (ETH + function call), the contract automatically gives you the output (tokens, ownership, etc.).
Setting Up the Dev Environment
We'll use Hardhat โ the industry standard for Solidity development:
mkdir my-contract && cd my-contract
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init
Choose "Create a TypeScript project" and accept the defaults.
Writing Your First Contract
Here's a simple EscrowVault โ a contract that holds ETH and releases it when both parties agree:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract EscrowVault {
address public buyer;
address public seller;
address public arbiter;
uint256 public amount;
bool public buyerApproved;
bool public sellerApproved;
event Released(address indexed to, uint256 amount);
event Disputed(address indexed by);
constructor(address _seller, address _arbiter) payable {
buyer = msg.sender;
seller = _seller;
arbiter = _arbiter;
amount = msg.value;
}
function approve() external {
require(msg.sender == buyer || msg.sender == seller, "Not a party");
if (msg.sender == buyer) buyerApproved = true;
if (msg.sender == seller) sellerApproved = true;
if (buyerApproved && sellerApproved) {
_release(seller);
}
}
function dispute() external {
require(msg.sender == buyer || msg.sender == seller, "Not a party");
emit Disputed(msg.sender);
}
function arbitrate(address payable winner) external {
require(msg.sender == arbiter, "Only arbiter");
_release(winner);
}
function _release(address payable to) private {
uint256 bal = amount;
amount = 0;
to.transfer(bal);
emit Released(to, bal);
}
}
Compiling & Testing
npx hardhat compile
Write a test in test/EscrowVault.ts:
import { ethers } from "hardhat";
import { expect } from "chai";
describe("EscrowVault", () => {
it("releases funds when both parties approve", async () => {
const [buyer, seller, arbiter] = await ethers.getSigners();
const escrow = await ethers.deployContract("EscrowVault",
[seller.address, arbiter.address],
{ value: ethers.parseEther("1.0") }
);
await escrow.connect(buyer).approve();
const sellerBefore = await ethers.provider.getBalance(seller.address);
await escrow.connect(seller).approve();
const sellerAfter = await ethers.provider.getBalance(seller.address);
expect(sellerAfter).to.be.gt(sellerBefore);
});
});
Run it:
npx hardhat test
Deploying to a Testnet
Deploy to Sepolia testnet. First, configure hardhat.config.ts:
import { HardhatUserConfig } from "hardhat/config";
const config: HardhatUserConfig = {
solidity: "0.8.20",
networks: {
sepolia: {
url: process.env.SEPOLIA_RPC_URL!,
accounts: [process.env.PRIVATE_KEY!],
},
},
};
export default config;
Then write a deploy script:
// scripts/deploy.ts
import { ethers } from "hardhat";
async function main() {
const [deployer] = await ethers.getSigners();
const seller = "0xYourSellerAddress";
const arbiter = "0xYourArbiterAddress";
const escrow = await ethers.deployContract("EscrowVault",
[seller, arbiter],
{ value: ethers.parseEther("0.01") }
);
await escrow.waitForDeployment();
console.log("Deployed at:", await escrow.getAddress());
}
main().catch(console.error);
npx hardhat run scripts/deploy.ts --network sepolia
Integrating with React (ethers.js)
Once deployed, connect from your frontend:
import { ethers, BrowserProvider, Contract } from "ethers";
import EscrowABI from "./EscrowVault.json"; // from artifacts/
const CONTRACT_ADDRESS = "0xYourDeployedAddress";
async function connectAndApprove() {
// Connect MetaMask
const provider = new BrowserProvider(window.ethereum);
await provider.send("eth_requestAccounts", []);
const signer = await provider.getSigner();
// Instantiate contract
const escrow = new Contract(CONTRACT_ADDRESS, EscrowABI.abi, signer);
// Call the approve function
const tx = await escrow.approve();
await tx.wait(); // wait for confirmation
console.log("Approval confirmed:", tx.hash);
}
๐ก Tip: Always use tx.wait() before updating UI state โ the transaction may be pending for several seconds.
Listening to Events
Smart contracts emit events. Subscribe to them in real time:
escrow.on("Released", (to, amount, event) => {
console.log(`Funds released to ${to}: ${ethers.formatEther(amount)} ETH`);
console.log("Transaction:", event.transactionHash);
});
Gas Optimisation Tips
| Technique | Savings |
|---|---|
Use uint256 instead of smaller ints | Avoids extra masking opcodes |
Mark functions view/pure | Zero gas when called off-chain |
| Pack struct fields | Reduces storage slots |
| Emit events instead of storing logs | 8ร cheaper than SSTORE |
Use calldata for read-only arrays | Cheaper than memory |
Key Takeaways
- Smart contracts are immutable โ test thoroughly before deploying
- Always validate
msg.senderto prevent unauthorized access - Use events for cheap on-chain logging that frontends can subscribe to
ethers.js v6has breaking API changes from v5 โ double-check your version- Never store private keys in source code โ use
.env+ a secrets manager
โ ๏ธ Warning: Smart contracts managing real funds have been exploited for billions of dollars. Always get a professional audit before mainnet deployment.