Building a Dynamic Payment Splitter on Conflux Network

Building a Dynamic Payment Splitter on Conflux Network

Introduction

Payment splitting is a common requirement in blockchain applications, especially for scenarios like revenue sharing, team payouts, or multi-party transactions. In this technical deep-dive, we’ll explore a Conflux Payment Splitter smart contract that enables dynamic, trustless distribution of CFX (Conflux’s native token) among multiple recipients based on configurable shares.

This contract is particularly useful for:

  • Revenue sharing among team members
  • Automated royalty distribution
  • Multi-party escrow services
  • Decentralized organization payouts

Project Overview

The Conflux Payment Splitter is a Solidity smart contract deployed on the Conflux blockchain that allows:

  1. Dynamic payee management: Add recipients after contract deployment
  2. Proportional distribution: Automatically split payments based on assigned shares
  3. On-demand releases: Recipients can claim their portion at any time
  4. Owner-controlled: Admin can add new payees with specific share allocations

Architecture & Design

Core Components

The contract uses a simple but effective architecture:

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

contract ConfluxPaymentSplitter {
    address public owner;
    
    uint256 private _totalShares;
    uint256 private _totalReleased;
    
    mapping(address => uint256) private _shares;
    mapping(address => uint256) private _released;
    address[] private _payees;
}

Key Design Decisions

  1. Dynamic Payee Addition: Unlike many payment splitters that require all payees at deployment, this contract allows adding recipients post-deployment, providing flexibility for evolving teams or partnerships.

  2. Share-Based Distribution: Uses a proportional share system where each payee receives (totalReceived * shares) / totalShares. This ensures fair distribution regardless of when funds arrive.

  3. Pull Payment Model: Recipients must call release() to claim their funds, reducing gas costs and giving them control over when to withdraw.

  4. Owner-Only Administration: Only the contract owner can add new payees, preventing unauthorized modifications.

Code Walkthrough

Contract Initialization

constructor() {
    owner = msg.sender;
}

The constructor simply sets the deployer as the owner, who will manage payee additions.

Receiving Payments

The contract accepts CFX through two mechanisms:

// Automatic receipt via receive() function
receive() external payable {
    emit PaymentReceived(msg.sender, msg.value);
}

// Explicit deposit function
function deposit() external payable {
    require(msg.value > 0, "No CFX sent");
    emit PaymentReceived(msg.sender, msg.value);
}

Both methods emit a PaymentReceived event for transparency. The receive() function allows direct transfers, while deposit() provides an explicit interface.

Adding Payees

function addPayee(address account, uint256 shares_) external onlyOwner {
    require(account != address(0), "zero address");
    require(shares_ > 0, "shares = 0");
    require(_shares[account] == 0, "already added");

    _payees.push(account);
    _shares[account] = shares_;
    _totalShares += shares_;

    emit PayeeAdded(account, shares_);
}

Key validations:

  • Prevents zero address additions
  • Ensures shares are greater than zero
  • Prevents duplicate payee entries
  • Updates total shares for accurate calculations

Calculating Pending Payments

function pending(address account) public view returns (uint256) {
    uint256 totalReceived = address(this).balance + _totalReleased;
    return _pendingPayment(account, totalReceived, _released[account]);
}

function _pendingPayment(
    address account,
    uint256 totalReceived,
    uint256 alreadyReleased
) private view returns (uint256) {
    return (totalReceived * _shares[account]) / _totalShares - alreadyReleased;
}

The pending() function calculates how much CFX a payee can claim:

  • totalReceived = current balance + previously released funds
  • Payment = (totalReceived × shares) / totalShares - alreadyReleased

This formula ensures:

  • Proportional distribution based on shares
  • Accurate tracking of what’s been released
  • Fair calculation even if funds arrive at different times

Releasing Payments

function release(address payable account) public {
    require(_shares[account] > 0, "no shares");

    uint256 totalReceived = address(this).balance + _totalReleased;
    uint256 payment = _pendingPayment(account, totalReceived, _released[account]);
    require(payment > 0, "nothing to release");

    _released[account] += payment;
    _totalReleased += payment;

    (bool ok, ) = account.call{value: payment}("");
    require(ok, "ETH transfer failed");

    emit PaymentReleased(account, payment);
}

Process flow:

  1. Validates the account has shares
  2. Calculates pending payment
  3. Updates released amounts
  4. Transfers CFX to the recipient
  5. Emits event for transparency

The use of call{value: payment}("") is the recommended way to send native tokens in modern Solidity.

Usage Examples

Scenario 1: Team Revenue Sharing

Imagine a team of 3 developers sharing revenue from a project:

// After deployment, owner adds payees
splitter.addPayee(0xDeveloper1..., 40);  // 40% share
splitter.addPayee(0xDeveloper2..., 35);  // 35% share
splitter.addPayee(0xDeveloper3..., 25);  // 25% share
// Total: 100 shares

// Revenue arrives
splitter.deposit{value: 1000 ether}();

// Each developer can claim their portion
splitter.release(0xDeveloper1...);  // Receives 400 CFX
splitter.release(0xDeveloper2...);  // Receives 350 CFX
splitter.release(0xDeveloper3...);  // Receives 250 CFX

Scenario 2: Adding New Team Members

The dynamic nature allows adding members later:

// Initial setup
splitter.addPayee(0xFounder..., 50);
splitter.addPayee(0xCoFounder..., 50);

// Later, a new team member joins
splitter.addPayee(0xNewMember..., 20);
// Now: Founder 50, CoFounder 50, NewMember 20 = 120 total shares

// New distribution:
// Founder: 50/120 = 41.67%
// CoFounder: 50/120 = 41.67%
// NewMember: 20/120 = 16.67%

Scenario 3: Checking Pending Balances

Before claiming, payees can check their pending amount:

uint256 myPending = splitter.pending(0xMyAddress...);
// Returns the amount available to claim

Security Considerations

Strengths

  1. Reentrancy Protection: Uses the checks-effects-interactions pattern, updating state before external calls
  2. Access Control: Owner-only functions prevent unauthorized modifications
  3. Input Validation: Comprehensive checks prevent invalid operations
  4. Safe Transfers: Uses call() with proper error handling

Potential Improvements

  1. Reentrancy Guard: Consider adding OpenZeppelin’s ReentrancyGuard for additional protection
  2. Payee Removal: Current design doesn’t allow removing payees (by design for immutability)
  3. Share Modification: Shares are immutable once set (prevents manipulation)
  4. Emergency Pause: Could add pause functionality for critical situations

Gas Optimization

The contract is optimized for gas efficiency:

  • View Functions: pending() and other view functions are free to call
  • Pull Payments: Recipients pay gas for their own withdrawals
  • Minimal Storage: Only stores essential data
  • Efficient Calculations: Simple arithmetic operations

Events & Transparency

The contract emits three key events:

event PayeeAdded(address account, uint256 shares);
event PaymentReleased(address to, uint256 amount);
event PaymentReceived(address from, uint256 amount);

These events enable:

  • Off-chain monitoring and analytics
  • Frontend integration
  • Audit trails
  • Real-time notifications

Testing Recommendations

When testing this contract, consider:

  1. Unit Tests:

    • Adding payees with various share configurations
    • Depositing funds and verifying calculations
    • Releasing payments to multiple recipients
    • Edge cases (zero shares, zero balance, etc.)
  2. Integration Tests:

    • Multiple deposits over time
    • Adding payees after deposits
    • Concurrent release calls
    • Large number of payees
  3. Security Tests:

    • Unauthorized access attempts
    • Reentrancy attacks
    • Integer overflow/underflow
    • Zero address handling

Deployment Considerations

Conflux Network Specifics

  • Network: Deploy on Conflux mainnet or testnet
  • Gas Costs: CFX is used for gas (similar to ETH on Ethereum)
  • Block Time: Conflux has faster block times (~1 second)
  • Compatibility: Uses standard Solidity, compatible with Conflux EVM

Deployment Steps

  1. Compile the contract with Solidity 0.8.19+
  2. Deploy to Conflux network
  3. Verify contract on ConfluxScan
  4. Initialize by adding payees
  5. Share contract address with recipients

Use Cases

  1. SaaS Revenue Sharing: Automatically distribute subscription revenue among team members
  2. NFT Royalties: Split royalty payments between creators and platform
  3. DAO Treasury: Distribute funds to multiple stakeholders
  4. Escrow Services: Hold and split funds based on milestones
  5. Affiliate Programs: Automate commission payments

Conclusion

The Conflux Payment Splitter provides a robust, flexible solution for automated payment distribution on the Conflux blockchain. Its dynamic payee management, proportional share system, and pull-payment model make it suitable for various use cases while maintaining security and gas efficiency.

The contract’s simplicity is its strength—it does one thing well: fairly and transparently split payments among multiple recipients based on configurable shares.

Full Contract Code

For reference, here’s the complete contract:

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

/**
 * @title ConfluxPaymentSplitter (Dynamic Version)
 * @notice Very simple ETH-only payment splitter WITHOUT constructor.
 *         Admin manually adds payees + shares after deployment.
 */
contract ConfluxPaymentSplitter {

    address public owner;

    event PayeeAdded(address account, uint256 shares);
    event PaymentReleased(address to, uint256 amount);
    event PaymentReceived(address from, uint256 amount);

    uint256 private _totalShares;
    uint256 private _totalReleased;

    mapping(address => uint256) private _shares;
    mapping(address => uint256) private _released;
    address[] private _payees;

    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

   // Accept CFX from wallets
receive() external payable {
    emit PaymentReceived(msg.sender, msg.value);
}

// Accept CFX by calling this function
function deposit() external payable {
    require(msg.value > 0, "No CFX sent");
    emit PaymentReceived(msg.sender, msg.value);
}


    /* ----------------------------- VIEW FUNCTIONS ---------------------------- */

    function totalShares() public view returns (uint256) {
        return _totalShares;
    }

    function totalReleased() public view returns (uint256) {
        return _totalReleased;
    }

    function shares(address account) public view returns (uint256) {
        return _shares[account];
    }

    function released(address account) public view returns (uint256) {
        return _released[account];
    }

    function payee(uint256 index) public view returns (address) {
        return _payees[index];
    }

    function pending(address account) public view returns (uint256) {
        uint256 totalReceived = address(this).balance + _totalReleased;
        return _pendingPayment(account, totalReceived, _released[account]);
    }

    /* ----------------------------- ADMIN FUNCTIONS ---------------------------- */

    /// @notice Add a payee and assign shares (for dynamic list)
    function addPayee(address account, uint256 shares_) external onlyOwner {
        require(account != address(0), "zero address");
        require(shares_ > 0, "shares = 0");
        require(_shares[account] == 0, "already added");

        _payees.push(account);
        _shares[account] = shares_;
        _totalShares += shares_;

        emit PayeeAdded(account, shares_);
    }

    /* ------------------------------ PAYMENT LOGIC ----------------------------- */

    function release(address payable account) public {
        require(_shares[account] > 0, "no shares");

        uint256 totalReceived = address(this).balance + _totalReleased;
        uint256 payment = _pendingPayment(account, totalReceived, _released[account]);
        require(payment > 0, "nothing to release");

        _released[account] += payment;
        _totalReleased += payment;

        (bool ok, ) = account.call{value: payment}("");
        require(ok, "ETH transfer failed");

        emit PaymentReleased(account, payment);
    }

    /* ----------------------------- INTERNAL LOGIC ----------------------------- */

    function _pendingPayment(
        address account,
        uint256 totalReceived,
        uint256 alreadyReleased
    ) private view returns (uint256) {
        return (totalReceived * _shares[account]) / _totalShares - alreadyReleased;
    }
}

Resources

Demo Video:https://youtu.be/BKbMU5ys0vI

GitHub repo: https://github.com/Vikash-8090-Yadav/Cfxpaymentsplit