Skip to main content

Blockchain and Web3 Development in Cursor: Solidity, Hardhat, and ethers.js

Blockchain and Web3 Development in Cursor

Blockchain development combines smart contract programming with frontend integration to create decentralized applications (dApps). Cursor, an AI-powered IDE editor, accelerates every layer of Web3 development: writing and auditing Solidity contracts, configuring Hardhat toolchains, generating ethers.js interaction layers, building wagmi-powered React frontends, and running comprehensive test suites. This guide covers Solidity smart contracts, the Hardhat development environment, ethers.js and wagmi integration, contract testing strategies, and AI-assisted contract generation and security auditing.

Prerequisites​

Before you begin, make sure you have the following:

  • Cursor (latest version recommended) as your IDE editor
  • Node.js 18 or higher
  • A basic understanding of JavaScript, TypeScript, and React
  • MetaMask or another Web3 wallet installed in your browser
  • Test ETH on a testnet (Sepolia or Holesky) for deployment practice
tip

Blockchain development has a steep learning curve. Before writing your first contract, ask Cursor to explain the Ethereum Virtual Machine (EVM), gas costs, and the transaction lifecycle. Understanding these fundamentals prevents costly mistakes when deploying to mainnet.

Solidity Smart Contracts​

Solidity is the most widely used language for writing smart contracts on EVM-compatible blockchains. Cursor helps you write, review, and optimize Solidity code with AI-powered suggestions and security analysis.

Writing a Token Contract​

Ask Cursor to generate an ERC-20 token contract:

Create an ERC-20 token contract in Solidity using OpenZeppelin.
The token should be named "TutorialToken" with symbol "TUT",
18 decimals, and a total supply of 1,000,000 tokens minted to
the deployer. Include a public minting function with a per-transaction
cap and an emergency pause mechanism.

Cursor generates:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";

contract TutorialToken is ERC20, AccessControl, Pausable {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");

// Maximum mint per transaction: 10,000 tokens (with 18 decimals)
uint256 public constant MAX_MINT_PER_TX = 10_000 * 10 ** 18;
// Total supply cap: 1,000,000 tokens
uint256 public constant TOTAL_SUPPLY_CAP = 1_000_000 * 10 ** 18;
uint256 public currentSupply;

event Minted(address indexed to, uint256 amount);
event EmergencyPaused(address indexed by);

constructor() ERC20("TutorialToken", "TUT") {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(MINTER_ROLE, msg.sender);

uint256 initialSupply = 1_000_000 * 10 ** 18;
_mint(msg.sender, initialSupply);
currentSupply = initialSupply;
}

function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) whenNotPaused {
require(amount <= MAX_MINT_PER_TX, "Exceeds max mint per transaction");
require(currentSupply + amount <= TOTAL_SUPPLY_CAP, "Exceeds total supply cap");

_mint(to, amount);
currentSupply += amount;

emit Minted(to, amount);
}

function pause() external onlyRole(DEFAULT_ADMIN_ROLE) {
_pause();
emit EmergencyPaused(msg.sender);
}

function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) {
_unpause();
}

// Override required by Solidity for multiple inheritance
function _update(address from, address to, uint256 amount) internal override(ERC20, Pausable) {
Pausable._update(from, to, amount);
}

function supportsInterface(bytes4 interfaceId)
public view override(AccessControl) returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
tip

Always use OpenZeppelin's audited contract libraries instead of writing cryptographic or token standard logic from scratch. Ask Cursor to import the latest OpenZeppelin contracts and verify the version matches your Solidity compiler version. Mismatched versions cause compilation errors and can introduce subtle security bugs.

warning

Solidity integer overflow protection changed in version 0.8.0. In versions before 0.8.0, arithmetic operations could silently overflow, leading to catastrophic vulnerabilities. If you are reviewing old contracts, ask Cursor to check for SafeMath usage and flag any arithmetic that relies on pre-0.8.0 behavior.

The Hardhat Development Environment​

Hardhat is the most popular development environment for Ethereum. It provides local blockchain simulation, contract compilation, testing, deployment scripts, and plugin extensibility.

Project Setup​

Ask Cursor to scaffold a Hardhat project:

Create a Hardhat TypeScript project with the following configuration:
- Solidity compiler version 0.8.24
- Hardhat toolbox plugin for testing and deployment
- A deployment script for the TutorialToken contract
- Environment variables for RPC URL and private key
- TypeChain for TypeScript contract bindings

Cursor generates the project structure:

my-dapp/
contracts/
TutorialToken.sol
scripts/
deploy.ts
test/
TutorialToken.test.ts
hardhat.config.ts
package.json
.env.example

Hardhat Configuration​

// hardhat.config.ts
import { HardhatUserConfig } from 'hardhat/config';
import '@nomicfoundation/hardhat-toolbox';
import * as dotenv from 'dotenv';

dotenv.config();

const config: HardhatUserConfig = {
solidity: {
version: '0.8.24',
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
networks: {
hardhat: {
chainId: 31337,
},
sepolia: {
url: process.env.SEPOLIA_RPC_URL || '',
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},
mainnet: {
url: process.env.MAINNET_RPC_URL || '',
accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [],
},
},
typechain: {
target: 'ethers-v6',
outDir: 'typechain-types',
},
gasReporter: {
enabled: true,
currency: 'USD',
coinmarketcap: process.env.COINMARKETCAP_API_KEY,
token: 'ETH',
},
};

export default config;

Deployment Script​

// scripts/deploy.ts
import { ethers } from 'hardhat';

async function main() {
const [deployer] = await ethers.getSigners();
console.log('Deploying contracts with account:', deployer.address);
console.log('Account balance:', ethers.formatEther(await ethers.provider.getBalance(deployer.address)), 'ETH');

const TutorialToken = await ethers.getContractFactory('TutorialToken');
const token = await TutorialToken.deploy();
await token.waitForDeployment();

const tokenAddress = await token.getAddress();
console.log('TutorialToken deployed to:', tokenAddress);

// Verify the deployment
const name = await token.name();
const symbol = await token.symbol();
const totalSupply = await token.totalSupply();
console.log('Name:', name);
console.log('Symbol:', symbol);
console.log('Total Supply:', ethers.formatEther(totalSupply));
}

main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
tip

Ask Cursor to generate deployment scripts for multiple networks with automatic contract verification on Etherscan. A prompt like "Create a deploy script that detects the network, deploys the contract, and calls verify on Etherscan if not on localhost" saves hours of manual verification work.

Contract Testing​

Testing smart contracts is critical because deployed contracts are immutable. Cursor generates comprehensive test suites that cover happy paths, edge cases, and security scenarios.

Writing Tests with Hardhat and Chai​

// test/TutorialToken.test.ts
import { expect } from 'chai';
import { ethers, network } from 'hardhat';
import { TutorialToken } from '../typechain-types';
import { SignerWithAddress } from '@nomicfoundation/hardhat-network-signers';

describe('TutorialToken', () => {
let token: TutorialToken;
let owner: SignerWithAddress;
let minter: SignerWithAddress;
let alice: SignerWithAddress;
let bob: SignerWithAddress;

const MINTER_ROLE = ethers.keccak256(ethers.toUtf8Bytes('MINTER_ROLE'));
const MAX_MINT = ethers.parseEther('10000');
const TOTAL_CAP = ethers.parseEther('1000000');

beforeEach(async () => {
[owner, minter, alice, bob] = await ethers.getSigners();

const TutorialToken = await ethers.getContractFactory('TutorialToken');
token = await TutorialToken.deploy();
await token.waitForDeployment();
});

describe('Deployment', () => {
it('sets the correct name and symbol', async () => {
expect(await token.name()).to.equal('TutorialToken');
expect(await token.symbol()).to.equal('TUT');
});

it('mints the total supply to the deployer', async () => {
const balance = await token.balanceOf(owner.address);
expect(balance).to.equal(TOTAL_CAP);
});

it('grants DEFAULT_ADMIN_ROLE and MINTER_ROLE to deployer', async () => {
expect(await token.hasRole(ethers.ZeroHash, owner.address)).to.be.true;
expect(await token.hasRole(MINTER_ROLE, owner.address)).to.be.true;
});
});

describe('Minting', () => {
beforeEach(async () => {
await token.grantRole(MINTER_ROLE, minter.address);
});

it('allows minter to mint within per-transaction cap', async () => {
const mintAmount = ethers.parseEther('5000');
await token.connect(minter).mint(alice.address, mintAmount);
expect(await token.balanceOf(alice.address)).to.equal(mintAmount);
});

it('reverts when minting exceeds per-transaction cap', async () => {
const mintAmount = ethers.parseEther('10001');
await expect(
token.connect(minter).mint(alice.address, mintAmount)
).to.be.revertedWith('Exceeds max mint per transaction');
});

it('reverts when non-minter tries to mint', async () => {
await expect(
token.connect(alice).mint(bob.address, ethers.parseEther('100'))
).to.be.revertedWithCustomError(token, 'AccessControlUnauthorizedAccount');
});

it('reverts when minting exceeds total supply cap', async () => {
// Already minted 1,000,000 on deployment, so any additional mint should fail
await expect(
token.connect(minter).mint(alice.address, ethers.parseEther('1'))
).to.be.revertedWith('Exceeds total supply cap');
});
});

describe('Pause Mechanism', () => {
it('allows admin to pause and unpause', async () => {
await token.pause();
expect(await token.paused()).to.be.true;

await token.unpause();
expect(await token.paused()).to.be.false;
});

it('reverts minting when paused', async () => {
await token.grantRole(MINTER_ROLE, minter.address);
await token.pause();

await expect(
token.connect(minter).mint(alice.address, ethers.parseEther('100'))
).to.be.revertedWithCustomError(token, 'EnforcedPause');
});

it('reverts when non-admin tries to pause', async () => {
await expect(token.connect(alice).pause())
.to.be.revertedWithCustomError(token, 'AccessControlUnauthorizedAccount');
});
});

describe('Transfers', () => {
it('allows token transfers and reverts when paused', async () => {
const amount = ethers.parseEther('100');
await token.transfer(alice.address, amount);
expect(await token.balanceOf(alice.address)).to.equal(amount);

// Now pause and verify transfer is blocked
await token.pause();
await expect(token.transfer(alice.address, amount))
.to.be.revertedWithCustomError(token, 'EnforcedPause');
});
});
});
tip

Ask Cursor to generate fuzzing tests that randomize inputs. A prompt like "Create a fuzz test that tries minting with random amounts between 0 and 100,000 to find edge cases in the per-transaction cap and total supply cap logic" can reveal overflow and boundary condition bugs that fixed-value tests miss.

warning

Never deploy a contract without full test coverage. Immutable contracts cannot be patched after deployment. A single missed edge case can lead to permanent fund loss. Run your full test suite and also ask Cursor to audit the contract for common vulnerability patterns before any mainnet deployment.

ethers.js Integration​

ethers.js is the most popular JavaScript library for interacting with Ethereum. Cursor generates type-safe interaction layers that wrap your contract ABIs.

Contract Interaction Layer​

// src/lib/token.ts
import { ethers, Contract, formatEther, parseEther } from 'ethers';
import type { TutorialToken } from '../typechain-types';

export interface TokenInfo {
name: string;
symbol: string;
decimals: number;
totalSupply: string;
}

export class TokenService {
private contract: TutorialToken;
private provider: ethers.Provider;
private signer?: ethers.Signer;

constructor(
address: string,
abi: ethers.InterfaceAbi,
provider: ethers.Provider,
signer?: ethers.Signer
) {
this.provider = provider;
this.signer = signer;
this.contract = (signer
? new Contract(address, abi, signer)
: new Contract(address, abi, provider)) as unknown as TutorialToken;
}

async getInfo(): Promise<TokenInfo> {
const [name, symbol, decimals, totalSupply] = await Promise.all([
this.contract.name(),
this.contract.symbol(),
this.contract.decimals(),
this.contract.totalSupply(),
]);

return {
name,
symbol,
decimals: Number(decimals),
totalSupply: formatEther(totalSupply),
};
}

async balanceOf(address: string): Promise<string> {
const balance = await this.contract.balanceOf(address);
return formatEther(balance);
}

async mint(to: string, amount: string): Promise<string> {
if (!this.signer) throw new Error('Signer required for minting');
const tx = await this.contract.mint(to, parseEther(amount));
await tx.wait();
return tx.hash;
}

async transfer(to: string, amount: string): Promise<string> {
if (!this.signer) throw new Error('Signer required for transfers');
const tx = await this.contract.transfer(to, parseEther(amount));
await tx.wait();
return tx.hash;
}

async isPaused(): Promise<boolean> {
return this.contract.paused();
}

async hasMinterRole(address: string): Promise<boolean> {
const MINTER_ROLE = ethers.keccak256(ethers.toUtf8Bytes('MINTER_ROLE'));
return this.contract.hasRole(MINTER_ROLE, address);
}
}

Using ethers.js with a Browser Wallet​

// src/lib/wallet.ts
import { BrowserProvider } from 'ethers';

let provider: BrowserProvider | null = null;

export async function getProvider(): Promise<BrowserProvider> {
if (!provider) {
if (!window.ethereum) {
throw new Error('No Web3 wallet found. Please install MetaMask.');
}
provider = new BrowserProvider(window.ethereum);
}
return provider;
}

export async function connectWallet(): Promise<string> {
const provider = await getProvider();
const accounts = await provider.send('eth_requestAccounts', []);
return accounts[0];
}

export async function getSigner() {
const provider = await getProvider();
return provider.getSigner();
}

export function onAccountsChanged(callback: (accounts: string[]) => void): void {
if (window.ethereum) {
window.ethereum.on('accountsChanged', callback);
}
}

export function onChainChanged(callback: (chainId: string) => void): void {
if (window.ethereum) {
window.ethereum.on('chainChanged', callback);
}
}
warning

Always verify the user is connected to the expected network before sending transactions. Sending a mainnet transaction while connected to a testnet, or vice versa, causes confusing errors. Check the chain ID and prompt the user to switch networks if it does not match.

Building a wagmi Frontend​

wagmi is a collection of React Hooks for Ethereum that simplifies wallet connection, contract reads, and contract writes. Cursor generates wagmi-powered React components with full type safety.

wagmi Configuration​

// src/lib/wagmi.ts
import { createConfig, http } from 'wagmi';
import { sepolia, mainnet } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';

export const config = createConfig({
chains: [sepolia, mainnet],
connectors: [injected()],
transports: {
[sepolia.id]: http('https://rpc.sepolia.org'),
[mainnet.id]: http(),
},
});

// Contract address and ABI
export const TOKEN_ADDRESS = '0x...'; // Replace with your deployed address
export const TOKEN_ABI = [
// ABI generated by TypeChain or hardhat
] as const;

Token Dashboard Component​

// src/components/TokenDashboard.tsx
import { useAccount, useReadContract, useWriteContract, useWatchContractEvent } from 'wagmi';
import { parseEther, formatEther } from 'viem';
import { TOKEN_ADDRESS, TOKEN_ABI } from '../lib/wagmi';
import { useState } from 'react';

export function TokenDashboard() {
const { address, isConnected } = useAccount();
const { writeContractAsync } = useWriteContract();
const [mintAmount, setMintAmount] = useState('100');
const [transferTo, setTransferTo] = useState('');
const [transferAmount, setTransferAmount] = useState('10');

// Batch-read contract state
const { data: name } = useReadContract({ address: TOKEN_ADDRESS, abi: TOKEN_ABI, functionName: 'name' });
const { data: symbol } = useReadContract({ address: TOKEN_ADDRESS, abi: TOKEN_ABI, functionName: 'symbol' });
const { data: balance } = useReadContract({ address: TOKEN_ADDRESS, abi: TOKEN_ABI, functionName: 'balanceOf', args: [address] });
const { data: isPaused } = useReadContract({ address: TOKEN_ADDRESS, abi: TOKEN_ABI, functionName: 'paused' });

// Listen for mint events
useWatchContractEvent({ address: TOKEN_ADDRESS, abi: TOKEN_ABI, eventName: 'Minted', onLogs: (logs) => console.log('Mint:', logs) });

const handleMint = () => writeContractAsync({ address: TOKEN_ADDRESS, abi: TOKEN_ABI, functionName: 'mint', args: [address, parseEther(mintAmount)] });
const handleTransfer = () => writeContractAsync({ address: TOKEN_ADDRESS, abi: TOKEN_ABI, functionName: 'transfer', args: [transferTo, parseEther(transferAmount)] });

if (!isConnected) {
return <div className="p-8 text-center text-gray-500">Connect your wallet to view your token balance.</div>;
}

return (
<div className="space-y-6 p-4">
<div className="rounded-lg bg-gray-100 p-4">
<h2 className="text-xl font-bold">{name} ({symbol})</h2>
<p className="mt-1 text-sm">Balance: {balance ? formatEther(balance as bigint) : '0'} {symbol}</p>
<p className="text-sm">Status: {isPaused ? 'Paused' : 'Active'}</p>
</div>

<div className="rounded-lg border p-4">
<h3 className="mb-2 font-semibold">Mint Tokens</h3>
<input type="number" value={mintAmount} onChange={(e) => setMintAmount(e.target.value)} className="mb-2 w-full rounded border px-3 py-2" disabled={isPaused} />
<button onClick={handleMint} disabled={isPaused} className="w-full rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50">Mint</button>
</div>

<div className="rounded-lg border p-4">
<h3 className="mb-2 font-semibold">Transfer Tokens</h3>
<input type="text" placeholder="Recipient address (0x...)" value={transferTo} onChange={(e) => setTransferTo(e.target.value)} className="mb-2 w-full rounded border px-3 py-2" />
<input type="number" placeholder="Amount" value={transferAmount} onChange={(e) => setTransferAmount(e.target.value)} className="mb-2 w-full rounded border px-3 py-2" disabled={isPaused} />
<button onClick={handleTransfer} disabled={isPaused} className="w-full rounded bg-green-600 px-4 py-2 text-white disabled:opacity-50">Transfer</button>
</div>
</div>
);
}
tip

Ask Cursor to generate a wagmi Read hook for every view function in your contract and a Write hook for every state-changing function. A prompt like "Create custom React hooks wrapping wagmi useReadContract and useWriteContract for every function in my TutorialToken ABI" gives you a clean, reusable interaction layer.

AI-Assisted Contract Generation and Auditing​

Cursor's AI can generate entire contract systems from a description and audit existing contracts for common vulnerability patterns.

Generating a Full Contract System​

Create a decentralized marketplace contract system with:
1. An ERC-721 NFT contract for listing items
2. A marketplace contract that accepts ETH and the NFT
3. Royalty enforcement using ERC-2981
4. A 2.5% platform fee sent to a treasury address
5. Reentrancy protection on all withdrawal functions
6. Events for every state change
Use OpenZeppelin libraries and include full NatSpec comments.

Auditing for Vulnerabilities​

Run this audit prompt against your contract:

Audit my Solidity contract for the following vulnerability classes:
1. Reentrancy attacks (check for CEI pattern and ReentrancyGuard)
2. Integer overflow/underflow
3. Access control bypass
4. Front-running susceptibility
5. Denial of service via gas limit
6. Timestamp dependence
7. Delegate call injection
8. Unchecked external calls
Rate the severity of each finding and provide a fix.
warning

AI-assisted security audits are a first line of defense, not a replacement for professional audits. Before deploying any contract that handles real value, commission a formal audit from a reputable firm. AI can miss subtle vulnerability patterns that experienced auditors catch.

Summary​

Blockchain and Web3 development in Cursor combines the power of AI-assisted code generation with the rigor of smart contract engineering. By following the patterns in this guide, OpenZeppelin-based contracts, comprehensive Hardhat test suites, type-safe ethers.js interaction layers, wagmi-powered React frontends, and AI-driven contract auditing, you can build and ship decentralized applications with confidence. Always test exhaustively, audit for vulnerabilities before mainnet deployment, and never deploy contracts you do not fully understand.