Building CfxSplit: Automatic Payment Splitting on Conflux eSpace

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:

  1. Deploy a smart contract with receiver addresses and split percentages (e.g. 50% / 50%)
  2. Ask the person who owes money to send CFX to the contract
  3. 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.

  1. Deploy with receivers [friendA, friendB] and percentages [50, 50]
  2. Copy the deployed contract address
  3. Send 2 CFX to that address
  4. Contract splits: 1 CFX → friendA, 1 CFX → friendB
  5. Verify on ConfluxScan: transaction status Success, internal transfers to both receivers, PaymentSplit event 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

On evmtestnet.confluxscan.io:

  1. Open the send transaction → status should be Success
  2. Check Internal Transactions → outgoing CFX to each receiver
  3. Open the contract address → balance should be ~0 after split
  4. 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.


Resources