Building CFXCrowd: A Decentralized Crowdfunding dApp on Conflux eSpace Testnet

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.20SimpleCrowdfund
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

  1. Creator deploys with a goal (CFX) and duration (minutes).
  2. Users contribute before the deadline.
  3. 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;
  • goal is stored in wei (CFX has 18 decimals).
  • contributions tracks 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

  1. Paste cfxcrowd.sol in Remix.
  2. Compile with 0.8.20.
  3. Deploy on Conflux eSpace Testnet via Injected Provider (MetaMask).
  4. Constructor example: _goalInCFX = 1, _durationInMinutes = 60.
  5. 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 :left_right_arrow: 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:

  1. eth_requestAccounts — connect wallet
  2. wallet_switchEthereumChain / wallet_addEthereumChain — Conflux testnet
  3. Expose signer for transactions and address for contributions(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.