CFXCrowd is a minimal crowdfunding application that runs on Conflux eSpace Testnet (chain ID 71). A Solidity smart contract holds campaign funds and enforces the rules; a Next.js frontend lets users connect MetaMask, contribute CFX, and—after the deadline—either withdraw (creator) or claim a refund (contributors).
This post walks through the architecture, the most important code, and how the frontend connects to the contract.
What we built
| Layer | Tech |
|---|---|
| Smart contract | Solidity ^0.8.20 — SimpleCrowdfund
|
| Deploy | Remix or Hardhat → Conflux eSpace Testnet |
| Frontend | Next.js, TypeScript, Tailwind |
| Blockchain SDK | ethers.js v6 |
| Wallet | MetaMask (or any window.ethereum provider) |
Campaign lifecycle
- Creator deploys with a goal (CFX) and duration (minutes).
- Users contribute before the deadline.
- After the deadline:
- Goal met → creator withdraws all raised CFX.
- Goal not met → contributors refund their own deposits.
Architecture
┌─────────────────┐ RPC (read) ┌──────────────────────┐
│ Next.js UI │ ──────────────────► │ Conflux eSpace │
│ (browser) │ │ Testnet (chain 71) │
└────────┬────────┘ │ SimpleCrowdfund │
│ └──────────────────────┘
│ MetaMask (sign) ▲
└────────────────────────────────────────┘
contribute / withdraw / refund
Data flow
-
Reads — public RPC + contract ABI →
goal,deadline,totalRaised, etc. -
Writes — MetaMask signer + same ABI →
contribute,withdraw,refund.
Smart contract: SimpleCrowdfund
File: cfxcrowd.sol (also compiled from contracts/cfxcrowd.sol for Hardhat).
State variables
The contract stores everything needed for one campaign:
address public creator;
uint public goal;
uint public deadline;
uint public totalRaised;
mapping(address => uint) public contributions;
-
goalis stored in wei (CFX has 18 decimals). -
contributionstracks how much each address sent.
Constructor — set goal and duration
This is one of the most important snippets. The second argument is minutes, not a Unix timestamp.
constructor(uint _goalInCFX, uint _durationInMinutes) {
creator = msg.sender;
goal = _goalInCFX * 1e18;
deadline = block.timestamp + (_durationInMinutes * 1 minutes);
}
| Argument | Example | Meaning |
|---|---|---|
_goalInCFX |
10 |
Raise 10 CFX |
_durationInMinutes |
60 |
Campaign lasts 1 hour from deploy |
_durationInMinutes |
1440 |
24 hours |
contribute() — payable deposits
function contribute() public payable {
require(block.timestamp < deadline, "Campaign ended");
require(msg.value > 0, "Send some CFX");
contributions[msg.sender] += msg.value;
totalRaised += msg.value;
}
Anyone can send CFX while the campaign is active. The frontend calls this with contribute({ value: ethers.parseEther(amount) }).
withdraw() — creator payout on success
function withdraw() public {
require(msg.sender == creator, "Not creator");
require(block.timestamp >= deadline, "Campaign not ended");
require(totalRaised >= goal, "Goal not reached");
payable(creator).transfer(address(this).balance);
}
Only the creator, only after the deadline, only if the goal was reached.
refund() — contributor payout on failure
function refund() public {
require(block.timestamp >= deadline, "Campaign not ended");
require(totalRaised < goal, "Goal was reached");
uint amount = contributions[msg.sender];
require(amount > 0, "No contribution");
contributions[msg.sender] = 0;
payable(msg.sender).transfer(amount);
}
Uses checks → effects → interactions: zero the mapping entry before transferring, which reduces reentrancy risk for this simple pattern.
Deploy on testnet
Option A — Remix
- Paste
cfxcrowd.solin Remix. - Compile with 0.8.20.
- Deploy on Conflux eSpace Testnet via Injected Provider (MetaMask).
- Constructor example:
_goalInCFX = 1,_durationInMinutes = 60. - Copy the deployed contract address.
Option B — Hardhat
# .env
DEPLOYER_PRIVATE_KEY=0x...
GOAL_CFX=10
DURATION_MINUTES=60
npm run compile
npm run deploy:espace-testnet
Deploy script core:
import hre from "hardhat";
async function main() {
const { ethers, network } = hre;
const goalCFX = BigInt(process.env.GOAL_CFX ?? "10");
const durationMinutes = BigInt(process.env.DURATION_MINUTES ?? "60");
const Factory = await ethers.getContractFactory("SimpleCrowdfund");
const crowdfund = await Factory.deploy(goalCFX, durationMinutes);
await crowdfund.waitForDeployment();
console.log("Deployed to:", await crowdfund.getAddress());
}
Frontend
blockchain connection
The bridge between UI and chain is four pieces:
| File | Role |
|---|---|
.env.local |
Contract address, chain ID, RPC |
lib/contract.ts |
ABI + CONTRACT_ADDRESS
|
lib/provider.ts |
Read-only JSON-RPC provider |
hooks/use-crowdfund.ts |
Fetch state + send transactions |
Environment config
Create .env.local in the project root (never commit private keys):
NEXT_PUBLIC_CONTRACT_ADDRESS=0xYourDeployedContractAddress
NEXT_PUBLIC_CHAIN_ID=71
NEXT_PUBLIC_RPC_URL=https://evmtestnet.confluxrpc.com
NEXT_PUBLIC_BLOCK_EXPLORER=https://evmtestnet.confluxscan.org
Restart the dev server after any change:
npm run dev
Chain config (Conflux testnet)
export const CONFLUX_ESPACE_TESTNET = {
chainId: 71,
chainIdHex: "0x47",
name: "Conflux eSpace Testnet",
rpcUrls: ["https://evmtestnet.confluxrpc.com"],
blockExplorerUrls: ["https://evmtestnet.confluxscan.org"],
};
The wallet layer uses this to add/switch MetaMask to chain 71 on connect.
ABI + address
lib/contract.ts exports the same ABI you get from Remix, so function names match exactly:
export const CONTRACT_ABI = [
// constructor, contribute, contributions, creator, deadline, goal, refund, totalRaised, withdraw
] as const;
export const CONTRACT_ADDRESS =
process.env.NEXT_PUBLIC_CONTRACT_ADDRESS ?? ZERO_ADDRESS;
ethers.js needs ABI + address to encode calls. Without both, the UI cannot talk to your deployment.
Reading on-chain state
hooks/use-crowdfund.ts is the heart of the integration.
Campaign data model
export interface CampaignData {
creator: string;
goal: bigint;
deadline: bigint;
totalRaised: bigint;
myContribution: bigint;
isEnded: boolean;
goalReached: boolean;
}
Parallel read from the contract
const readProvider = getReadProvider();
const contract = new Contract(CONTRACT_ADDRESS, CONTRACT_ABI, readProvider);
const [creator, goal, deadline, totalRaised] = await Promise.all([
contract.creator(),
contract.goal(),
contract.deadline(),
contract.totalRaised(),
]);
const block = await readProvider.getBlock("latest");
const chainNow = BigInt(block?.timestamp ?? Math.floor(Date.now() / 1000));
const isEnded = chainNow >= deadline;
const goalReached = totalRaised >= goal;
Why use block timestamp?
The contract uses block.timestamp for deadline checks. Comparing against the latest block time keeps the UI aligned with on-chain logic.
Why a separate read provider?
Reads go through the public testnet RPC, so campaign stats load even before the user connects a wallet.
Writing transactions from the UI
Contribute
const contract = new Contract(CONTRACT_ADDRESS, CONTRACT_ABI, signer);
const tx = await contract.contribute({
value: ethers.parseEther(amountInCFX),
});
await tx.wait();
parseEther converts human-readable CFX (e.g. "1") to wei for msg.value.
Withdraw and refund
await contract.withdraw();
// or
await contract.refund();
The UI shows these buttons only when:
- Wallet is connected on chain 71
- Campaign has ended
- User is creator (withdraw) or has a contribution (refund)
Wallet and network (chain 71)
lib/wallet-context.tsx wraps MetaMask:
-
eth_requestAccounts— connect wallet -
wallet_switchEthereumChain/wallet_addEthereumChain— Conflux testnet - Expose
signerfor transactions andaddressforcontributions(address)
Wrong network → banner prompts user to switch; transactions are blocked until chain ID is 71.
Project structure
cfxcrowd/
├── cfxcrowd.sol # Main contract (Remix / docs)
├── contracts/cfxcrowd.sol # Hardhat compile target
├── scripts/deploy.ts # Hardhat deploy
├── hardhat.config.ts # Networks (testnet chainId: 71)
├── lib/
│ ├── contract.ts # ABI + address
│ ├── chains.ts # Conflux testnet config
│ ├── provider.ts # RPC read provider
│ ├── wallet-context.tsx # MetaMask connect
│ └── campaign-time.ts # Deadline formatting
├── hooks/use-crowdfund.ts # On-chain reads + txs
├── components/crowdfund/ # Dashboard UI
└── app/page.tsx # Entry page
Common pitfalls
1. Using a Unix timestamp as _durationInMinutes
Wrong (Remix):
{ "_goalInCFX": "1", "_durationInMinutes": "1779046818" }
That adds billions of minutes, not “until timestamp 1779046818”. The UI will show a deadline in year 5408.
Right:
{ "_goalInCFX": "1", "_durationInMinutes": "60" }
2. .env.local vs .env
Next.js loads .env.local first. If you update .env but not .env.local, the UI still points at the old contract.
3. Each deploy = new address
Fixing constructor args requires a new deployment. Update NEXT_PUBLIC_CONTRACT_ADDRESS and restart npm run dev.
4. Verify on ConfluxScan
Call deadline() on your contract. It should be a reasonable Unix time (e.g. deploy + 60 minutes ≈ 2026), not 108521854990.
Ideas for production
- Emit events:
Contributed,Withdrawn,Refunded - Reentrancy guard on external calls
- Prevent creator from contributing to own campaign
- Multi-campaign factory contract
- IPFS metadata (title, description, image)
- Unit tests with Hardhat + mainnet/testnet CI
Quick start (recap)
# 1. Install
npm install
# 2. Configure (after Remix/Hardhat deploy)
cp .env.example .env.local
# Edit NEXT_PUBLIC_CONTRACT_ADDRESS
# 3. Run frontend
npm run dev
Open http://localhost:3000, connect MetaMask on Conflux eSpace Testnet, and interact with your deployed SimpleCrowdfund instance.
resources
Video;https://youtu.be/ah7B5fkPDfo
GitHub repo;https://github.com/Vikash-8090-Yadav/cfxcrowd
Summary
| Piece | Takeaway |
|---|---|
| Contract | Goal + time-boxed campaign; contribute / withdraw / refund |
| Constructor |
_durationInMinutes = minutes, not Unix time |
| Frontend |
use-crowdfund.ts + lib/contract.ts + .env.local
|
| Network | Conflux eSpace Testnet, chain ID 71 |
CFXCrowd is a small but complete example of read from RPC, write via wallet—the same pattern used in larger DeFi and crowdfunding products on EVM chains.
License: MIT · Built for learning and testnet demos on Conflux eSpace.
blockchain connection