CfxSplit is a full-stack dApp that deploys and manages an AutoPaymentSplitter smart contract on Conflux eSpace Testnet (chain ID 71). When a user sends CFX to the contract address, funds are automatically distributed to multiple receivers based on pre-configured percentages — no manual transfers, no intermediary.
This post walks through the problem, architecture, core smart contract logic, frontend integration, and how to run the project end-to-end.
The Problem
Imagine three friends go out for dinner. One person pays the full bill upfront, and the others agree to pay their share later. Instead of chasing payments manually, you can:
- Deploy a smart contract with receiver addresses and split percentages (e.g. 50% / 50%)
- Ask the person who owes money to send CFX to the contract
- Let the contract automatically forward each share to the correct wallet
The split rules are immutable after deployment and enforced on-chain.
Architecture Overview
┌─────────────────┐ MetaMask ┌──────────────────────────┐
│ Next.js UI │ ◄──────────────► │ Conflux eSpace Testnet │
│ (React + ethers)│ │ Chain ID: 71 │
└────────┬────────┘ └────────────┬─────────────┘
│ │
│ deploy / send / read │
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ useWallet.tsx │ ────────────────►│ AutoPaymentSplitter.sol │
│ lib/contracts │ │ receive() → _splitPayment│
└─────────────────┘ └──────────────────────────┘
| Layer | Technology |
|---|---|
| Smart contract | Solidity 0.8.20, Hardhat |
| Frontend | Next.js 16, React 19, TypeScript |
| Web3 | ethers.js v6 |
| Network | Conflux eSpace Testnet (chain 71) |
| Forms | react-hook-form + Zod |
Smart Contract: AutoPaymentSplitter.sol
The contract stores receivers and percentages at deploy time. Any incoming CFX triggers an automatic split via the receive() fallback.
Constructor — set receivers and validate 100%
constructor(address[] memory _receivers, uint256[] memory _percentages) {
require(_receivers.length == _percentages.length, "Arrays length mismatch");
require(_receivers.length > 0, "At least one receiver required");
uint256 total = 0;
for (uint256 i = 0; i < _percentages.length; i++) {
require(_receivers[i] != address(0), "Invalid receiver address");
require(_percentages[i] > 0, "Percentage must be greater than 0");
total += _percentages[i];
}
require(total == 100, "Total percentages must equal 100");
receivers = _receivers;
percentages = _percentages;
totalPercentage = total;
}
Key rules:
- At least one receiver is required
- No zero addresses
- Every percentage must be greater than
0 - Percentages must sum to exactly 100
receive() — trigger split on incoming CFX
receive() external payable {
require(msg.value > 0, "Amount must be greater than 0");
_splitPayment();
}
There is no separate “split” button on-chain. A plain CFX transfer to the contract address is enough to invoke receive().
_splitPayment() — core distribution logic
function _splitPayment() internal {
uint256 amount = msg.value;
for (uint256 i = 0; i < receivers.length; i++) {
uint256 share = (amount * percentages[i]) / 100;
payable(receivers[i]).transfer(share);
}
emit PaymentSplit(amount, block.timestamp);
}
For each receiver:
share = (totalAmount × percentage) / 100
Example: sending 2 CFX with a 50/50 split sends 1 CFX to each receiver.
View functions — read contract state
function getReceivers() external view returns (address[] memory);
function getPercentages() external view returns (uint256[] memory);
function getContractBalance() external view returns (uint256);
The frontend uses these to display receivers, percentages, and remaining balance in the Inspect tab.
Events
event PaymentSplit(uint256 totalAmount, uint256 timestamp);
event ReceiverAdded(address indexed receiver, uint256 percentage);
PaymentSplit is emitted after every successful split and can be verified on ConfluxScan.
Conflux eSpace Network Configuration
The app targets Conflux eSpace Testnet only.
// lib/conflux.ts
export const confluxEspaceTestnet = defineChain({
id: 71,
name: "Conflux eSpace Testnet",
nativeCurrency: { decimals: 18, name: "Conflux", symbol: "CFX" },
rpcUrls: {
default: { http: ["https://evmtestnet.confluxrpc.com"] },
},
blockExplorers: {
default: { name: "ConfluxScan", url: "https://evmtestnet.confluxscan.io" },
},
});
export const CONFLUX_CHAIN_ID = 71;
Note: Conflux has Core Space (
cfx:...) and eSpace (0x...). This project runs entirely on eSpace (EVM-compatible). Check balances on the EVM explorer, not Core Space.
Frontend Integration: useWallet.tsx
All blockchain interactions live in a React context powered by ethers.js v6.
Connect wallet and switch to chain 71
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const accounts = await window.ethereum.request({ method: "eth_requestAccounts" });
await window.ethereum.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: `0x${CONFLUX_CHAIN_ID.toString(16)}` }], // 0x47 = 71
});
If the network is not added in MetaMask, the app calls wallet_addEthereumChain with the Conflux testnet RPC and explorer URLs.
Deploy the contract
const factory = new ContractFactory(
AUTO_PAYMENT_SPLITTER_ABI,
AUTO_PAYMENT_SPLITTER_BYTECODE,
signer
);
const contract = await factory.deploy(receivers, percentages);
await contract.waitForDeployment();
const contractAddress = await contract.getAddress();
AUTO_PAYMENT_SPLITTER_ABI and AUTO_PAYMENT_SPLITTER_BYTECODE are generated from Hardhat compilation via pnpm compile.
Send CFX to trigger the split
const tx = await signer.sendTransaction({
to: contractAddress,
value: ethers.parseEther(amount),
});
const receipt = await tx.wait();
return receipt.hash;
This is a standard native CFX transfer. The contract’s receive() handles the rest.
Read contract details
const contract = new Contract(contractAddress, AUTO_PAYMENT_SPLITTER_ABI, provider);
const [receivers, percentages, balance] = await Promise.all([
contract.getReceivers(),
contract.getPercentages(),
contract.getContractBalance(),
]);
UI Flow
The dashboard (app/page.tsx) exposes three tabs:
| Tab | Action |
|---|---|
| Deploy | Set receiver addresses + percentages (must total 100%), deploy contract |
| Send & Split | Send CFX to the contract address |
| Inspect | View receivers, split chart, and contract balance |
The Active Contract bar persists the deployed address in localStorage so it survives page refreshes.
Deploy form validation (client-side)
const deploySchema = z.object({
receivers: z.array(
z.object({
address: z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid address"),
percentage: z.number().min(1).max(100),
})
).min(1),
});
// Percentages must sum to 100 before deploy is enabled
const total = percentages.reduce((a, b) => a + b, 0);
if (total !== 100) throw new Error("Percentages must sum to 100%");
Client-side validation mirrors the on-chain require(total == 100) check so users get immediate feedback before paying gas.
Compiling & Deploying
Hardhat config (Conflux eSpace Testnet)
// hardhat.config.ts
networks: {
confluxESpaceTestnet: {
url: process.env.CONFLUX_ESPACE_TESTNET_RPC_URL ?? "https://evmtestnet.confluxrpc.com",
chainId: 71,
accounts: deployerKey ? [deployerKey] : [],
},
},
Commands
# Install dependencies
pnpm install
# Compile Solidity → sync ABI + bytecode to lib/contracts.ts
pnpm compile
# Run the frontend
pnpm dev
# Deploy via CLI (optional)
RECEIVERS=0xAddr1,0xAddr2 PERCENTAGES=50,50 pnpm deploy:testnet
Environment variables
DEPLOYER_PRIVATE_KEY=your_key_here
CONFLUX_ESPACE_TESTNET_RPC_URL=https://evmtestnet.confluxrpc.com
NEXT_PUBLIC_CONFLUX_RPC_URL=https://evmtestnet.confluxrpc.com
Get test CFX from the Conflux Testnet Faucet.
End-to-End Example
Scenario: Two friends already paid the bill. A third friend owes 2 CFX total — 1 CFX to each.
- Deploy with receivers
[friendA, friendB]and percentages[50, 50] - Copy the deployed contract address
- Send 2 CFX to that address
- Contract splits: 1 CFX → friendA, 1 CFX → friendB
- Verify on ConfluxScan: transaction status Success, internal transfers to both receivers,
PaymentSplitevent emitted
Project Structure
contracts/
AutoPaymentSplitter.sol # Smart contract source
lib/
conflux.ts # Chain 71 network config
contracts.ts # Auto-generated ABI + bytecode
hooks/
useWallet.tsx # Deploy, send, read contract
useAppContract.tsx # Contract address + tx history state
components/
DeployForm.tsx # Deploy UI + validation
SendFundsForm.tsx # Send CFX UI
ContractDetails.tsx # Inspect receivers & balance
WalletConnect.tsx # MetaMask connect/disconnect
scripts/
deploy.ts # Hardhat CLI deploy script
sync-artifacts.ts # Sync compile output → lib/contracts.ts
hardhat.config.ts # Solidity compiler + testnet network
How to Verify It Worked
- Open the send transaction → status should be Success
- Check Internal Transactions → outgoing CFX to each receiver
- Open the contract address → balance should be ~0 after split
- Open each receiver address → balance increased by their share
If the transaction failed, common causes are:
- Receiver is a contract that cannot accept plain
.transfer()(2300 gas stipend) - Wrong network (not chain 71)
- Insufficient CFX for gas
Limitations & Future Improvements
| Current limitation | Possible improvement |
|---|---|
| Percentages fixed at deploy | Upgradeable proxy or factory pattern |
Uses .transfer() (2300 gas) |
Use .call{value: share}("") for contract receivers |
| Rounding dust may remain | Send remainder to last receiver |
| Testnet only | Add mainnet (chain 1030) config |
| No on-chain event UI | Listen to PaymentSplit events in real time |
Conclusion
CfxSplit demonstrates a minimal but complete Web3 flow on Conflux eSpace:
- Solidity enforces split rules on-chain
- ethers.js handles deploy, send, and read operations
- Next.js provides a clean interface for non-technical users
Deploy once, send CFX anytime — the contract handles the rest.