WikiSmart Contracts LibPayment Splitter

Building a Payment Splitter

This smart contract enables automatic fund distribution to multiple recipients. It is written in Ink! without third-party libraries. Key features:

  • Multi-payee splitting: Distribute funds evenly (with remainder handling)
  • Designated authority: Single account controls payout triggers
  • Reentrancy protection: Prevents recursive attacks
  • Transparent accounting: On-chain event logging

Code Walkthrough

Error Handling

#[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
pub enum Error {
        Unauthorized = 0, /// Indicates that the caller is not authorized to perform the requested action.
        NoPayees = 1, /// Indicates that there are no payees registered in the contract.
        TransferFailed = 2, /// Indicates that the transfer of funds to a payee failed.
        ZeroShare = 3, /// Indicates that a zero value was provided where a non-zero value was expected.
        ReentrancyGuardLocked = 4,/// Reentrancy guard is locked.
}

Core Structures

#[ink(storage)]
pub struct PaymentSplitter {
    payees: Vec<AccountId>,        // Like list of output addresses
    designated_payee: AccountId,    // Similar to script signer
    locked: bool                     // Reentrancy guard 
}
 
#[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
pub struct PayoutInfo {             
    pub payee: AccountId,           // Recipient address
    pub amount: Balance,            
}

Constructor

#[ink(constructor)]
pub fn new(payees: Vec<AccountId>, designated_payee: AccountId) -> Self {
    Self {
        payees,                     // Set once (like script params)
        designated_payee,           // Immutable authority
        locked: false,               // Initial unlocked state
    }
}

Full Implementation

lib.rs

#![cfg_attr(not(feature = "std"), no_std, no_main)]
 
#[ink::contract]
mod payment_splitter {
    use ink::prelude::vec::Vec;
 
    /// Represents the possible errors that can occur within the PaymentSplitter contract.
    #[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
    #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
    pub enum Error {
        /// Indicates that the caller is not authorized to perform the requested action.
        Unauthorized = 0,
        /// Indicates that there are no payees registered in the contract.
        NoPayees = 1,
        /// Indicates that the transfer of funds to a payee failed.
        TransferFailed = 2,
        /// Indicates that a zero value was provided where a non-zero value was expected.
        ZeroShare = 3,
        /// Reentrancy guard is locked.
        ReentrancyGuardLocked = 4,
    }
 
    /// Struct to hold the amount to be transferred for each payee.
    #[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)]
    #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))]
    pub struct PayoutInfo {
        pub payee: AccountId,
        pub amount: Balance,
    }
 
    /// Defines the storage for the PaymentSplitter contract.
    #[ink(storage)]
    pub struct PaymentSplitter {
        /// A list of `AccountId`s representing the payees who will receive funds.
        payees: Vec<AccountId>,
        /// The `AccountId` that is authorized to trigger the payout process.
        designated_payee: AccountId,
        /// Reentrancy guard.
        locked: bool,
    }
 
    /// An event emitted when funds are deposited into the contract.
    #[ink::event]
    pub struct Deposit {
        /// The `AccountId` that deposited the funds.
        #[ink(topic)]
        pub from: AccountId,
        /// The amount of funds deposited.
        pub value: Balance,
    }
 
    impl PaymentSplitter {
        /// Constructor to initialize the PaymentSplitter contract.
        ///
        /// This constructor sets up the contract with a list of payees and an authorized payee.
        ///
        /// # Arguments
        ///
        /// * `payees`: A vector of `AccountId`s representing the payees who will receive payments.
        /// * `designated_payee`: The `AccountId` that is authorized to trigger the payout.
        ///
        #[ink(constructor)]
        pub fn new(payees: Vec<AccountId>, designated_payee: AccountId) -> Self {
            Self {
                payees,
                designated_payee,
                locked: false,
            }
        }
 
        /// Allows anyone to deposit funds into the contract.
        ///
        /// The deposited amount is added to the contract's balance.
        /// Emits a `Deposit` event when funds are received, recording the depositor and the amount.
        ///
        /// # Errors
        ///
        /// * `ZeroShare`: If the transferred value is zero.
        ///
        #[ink(message, payable)]
        pub fn deposit(&self) -> Result<(), Error> {
            let transferred_value = self.env().transferred_value();
            if transferred_value == 0 {
                return Err(Error::ZeroShare);
            }
            self.env().emit_event(Deposit {
                from: self.env().caller(),
                value: transferred_value,
            });
            Ok(())
        }
 
        /// Calculates the payout distribution among the registered payees.
        ///
        /// This function determines how much each payee should receive based on the contract's balance.
        /// The remainder after division is added to the first payee's share.
        ///
        /// # Errors
        ///
        /// * `Unauthorized`: If the caller is not the `designated_payee`.
        /// * `NoPayees`: If there are no registered payees.
        /// * `ZeroShare`: If the total balance is zero or if a calculation error (division by zero) occurs.
        ///
        pub fn calculate_payout(&mut self) -> Result<Vec<PayoutInfo>, Error> {
            self.ensure_caller_is_designated_payee()?;
            let total_balance = self.env().balance();
            let num_payees = self.payees.len();
 
            if num_payees == 0 {
                return Err(Error::NoPayees);
            }
 
            if total_balance == 0 {
                return Err(Error::ZeroShare);
            }
 
            // Calculate the share each payee should receive.
            let share = total_balance.checked_div(num_payees as u128).ok_or(Error::ZeroShare)?;
 
            if share == 0 {
                return Err(Error::ZeroShare);
            }
 
            // Calculate the remainder after division.
            let mut remainder = total_balance.saturating_sub(
                share.checked_mul(num_payees as u128).ok_or(Error::ZeroShare)?
            );
 
            let mut payout_info = Vec::new();
            for (i, payee) in self.payees.iter().enumerate() {
                // Add the remainder to the first payee's share.
                let to_transfer = if i == 0 {
                    share.checked_add(remainder).ok_or(Error::TransferFailed)?
                } else {
                    share
                };
 
                payout_info.push(PayoutInfo {
                    payee: *payee,
                    amount: to_transfer,
                });
 
                // Only add remainder to first payee.
                remainder = 0;
            }
            Ok(payout_info)
        }
 
        /// Triggers the actual payout process based on the payout distribution calculated by `calculate_payout`.
        ///
        /// Only the `designated_payee` is authorized to call this function.
        /// Transfers the funds to each payee based on the `PayoutInfo` provided.
        ///
        /// # Errors
        ///
        /// * `Unauthorized`: If the caller is not the `designated_payee`.
        /// * `TransferFailed`: If the transfer of funds to a payee fails.
        ///
        #[ink(message)]
        pub fn trigger_payout(&mut self) -> Result<(), Error> {
            self.ensure_caller_is_designated_payee()?;
            self.ensure_reentrancy_guard_not_locked()?;
 
            self.locked = true;
            let payout_info = self.calculate_payout()?;
 
            for info in payout_info {
                self
                    .env()
                    .transfer(info.payee, info.amount)
                    .map_err(|_| Error::TransferFailed)?;
            }
            self.locked = false;
            Ok(())
        }
 
        /// Helper function to check if the caller is the designated payee.
        fn ensure_caller_is_designated_payee(&self) -> Result<(), Error> {
            if self.env().caller() != self.designated_payee {
                return Err(Error::Unauthorized);
            }
            Ok(())
        }
 
        /// Helper function to check the reentrancy guard.
        fn ensure_reentrancy_guard_not_locked(&self) -> Result<(), Error> {
            if self.locked {
                return Err(Error::ReentrancyGuardLocked);
            }
            Ok(())
        }
    }
 
    //--- test here --
}
 

Cargo.toml

[package]
name = "payment_splitter"
version = "0.3.0"
authors = ["Your Name <your.email@example.com>"]
edition = "2021"
 
[dependencies]
ink = { version = "5.1.1", default-features = false }
scale = { package = "parity-scale-codec", version = "3.7.4", default-features = false, features = ["derive"] }
scale-info = { version = "2.11.6", default-features = false, features = ["derive"], optional = true }
[dev-dependencies]
ink_e2e = { version = "5.1.1" }
 
[lib]
name = "payment_splitter"  
path = "lib.rs"
 
[features]
default = ["std"]
std = [
    "ink/std",
    "scale-info/std",
    "scale/std",
]
ink-as-dependency = []
e2e-tests = []

Test Scenarios

    #[cfg(test)]
    mod tests {
        use super::*;
        use ink::env::{
            test::{
                default_accounts,
                set_caller,
                set_value_transferred,
                get_account_balance,
                set_account_balance,
            },
            DefaultEnvironment,
        };
        use ink::codegen::Env;
 
        // Helper function to get the current balance of an account.
        fn get_balance(account: AccountId) -> u128 {
            get_account_balance::<ink::env::DefaultEnvironment>(account).expect(
                "Cannot get account balance"
            )
        }
 
        #[ink::test]
        fn trigger_payout_unauthorized() {
            // Arrange
            let accounts = default_accounts::<DefaultEnvironment>();
            let payees = vec![accounts.bob, accounts.charlie];
            let mut contract = PaymentSplitter::new(payees.clone(), accounts.alice);
            set_caller::<DefaultEnvironment>(accounts.charlie);
            set_value_transferred::<DefaultEnvironment>(100);
 
            contract.deposit().unwrap();
 
            // Act - Payout
            set_caller::<DefaultEnvironment>(accounts.bob); // Bob is not the designated_payee
            let result = contract.calculate_payout();
 
            // Assert
            assert_eq!(result, Err(Error::Unauthorized));
        }
 
        #[ink::test]
        fn basic_workflow() {
            // Arrange
            let accounts = default_accounts::<DefaultEnvironment>();
            let payees = vec![accounts.bob, accounts.charlie];
            let mut contract = PaymentSplitter::new(payees.clone(), accounts.alice);
 
            // Set initial values
            let initial_contract_balance = 1000000;
            let bob_balance = 2000010;
            let charlie_balance = 3000010;
            let alice_deposit = 121;
            let balance_plus_deposit = initial_contract_balance + alice_deposit;
            let expected_bob_received = 500061;
            let expected_charlie_received = 500060;
 
            // Set initial balances
            set_account_balance::<ink::env::DefaultEnvironment>(
                contract.env().account_id(),
                initial_contract_balance
            );
            set_account_balance::<ink::env::DefaultEnvironment>(accounts.bob, bob_balance);
            set_account_balance::<ink::env::DefaultEnvironment>(accounts.charlie, charlie_balance);
 
            // Act - Deposit
            set_caller::<ink::env::DefaultEnvironment>(accounts.alice);
            set_value_transferred::<ink::env::DefaultEnvironment>(alice_deposit);
            contract.deposit().unwrap();
 
            // Update contract balance after Alice deposit
            set_account_balance::<ink::env::DefaultEnvironment>(
                contract.env().account_id(),
                balance_plus_deposit
            );
 
            //Get balances before payout
            let contract_balance_before_split = get_balance(contract.env().account_id());
            ink::env::debug_println!(
                "---- Contract balance before split: {}",
                contract_balance_before_split
            );
 
            // Assert - Deposit (still Alice as caller)
            assert_eq!(get_balance(contract.env().account_id()), balance_plus_deposit);
 
            // Calculate Payout
            let payout_info = contract.calculate_payout().unwrap();
            ink::env::debug_println!("---- payout info 0: {:?}", payout_info[0].amount);
            ink::env::debug_println!("---- payout info 1: {:?}", payout_info[1].amount);
 
            // Assert Payout Calculations
            assert_eq!(payout_info.len(), 2);
            assert_eq!(payout_info[0].payee, accounts.bob);
            assert_eq!(payout_info[0].amount, expected_bob_received);
            assert_eq!(payout_info[1].payee, accounts.charlie);
            assert_eq!(payout_info[1].amount, expected_charlie_received);
 
            // Trigger Payout
            contract.trigger_payout().unwrap();
 
            //Get balances after payout
            let contract_balance_after = get_balance(contract.env().account_id());
            ink::env::debug_println!(
                "---- Contract balance after split: {}",
                contract_balance_after
            );
 
            //Update balances after payout (should be 0 after payout)
            set_account_balance::<ink::env::DefaultEnvironment>(contract.env().account_id(), 0);
 
            // Assert - Payout
            assert_eq!(get_balance(contract.env().account_id()), 0);
            assert_eq!(get_balance(accounts.bob), bob_balance + expected_bob_received);
            assert_eq!(get_balance(accounts.charlie), charlie_balance + expected_charlie_received);
            ink::env::debug_println!("---- Bob balance: {}", get_balance(accounts.bob));
            ink::env::debug_println!("---- Charlie balance: {}", get_balance(accounts.charlie));
        }
    }

Key features:

  1. Storage Structure:
  • payees: List of predefined beneficiary addresses
  • designated_payee: Only address allowed to trigger payouts
  1. Core Functions:
  • calculate_payout: Calculates the payout distribution among the registered payees
  • trigger_payout: Distributes contract balance equally to payees
  1. Security:
  • Only designated payee can trigger distributions
  • Proper error handling for edge cases
  • Safe balance calculations with overflow protection