Why pull payments are useful

In many Solidity applications, a contract needs to pay multiple parties: sellers, contributors, service providers, or winners. The naive approach is to loop through recipients and transfer funds directly. That design is fragile for several reasons:

  • A single failing recipient can revert the whole transaction.
  • External calls during distribution increase reentrancy risk.
  • Gas costs grow with the number of recipients.
  • Smart contract recipients may need custom logic to accept funds.

A pull-based vault avoids these problems by separating accounting from payment execution. The contract stores credits, and each recipient calls withdraw() when ready.

Typical use cases

  • Marketplace seller proceeds
  • Revenue sharing among collaborators
  • Refund claims after a failed sale
  • Reward distribution in staking or loyalty systems
  • Escrow-like payouts where recipients claim funds independently

Core design of a payment vault

The vault has three responsibilities:

  1. Accept deposits from an authorized source.
  2. Track how much each recipient can claim.
  3. Allow recipients to withdraw their balance safely.

A minimal implementation usually includes:

  • A mapping from recipient address to claimable amount
  • A deposit function restricted to an owner or trusted module
  • A withdrawal function that sends funds after state is updated

For production use, it is best to support both Ether and ERC20 tokens, but starting with Ether makes the pattern easier to understand.


Ether-based implementation

The following contract demonstrates a basic pull-payment vault for Ether. It uses OpenZeppelin’s Ownable and ReentrancyGuard patterns conceptually, but the example is self-contained for clarity.

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

contract PaymentVault {
    address public owner;

    mapping(address => uint256) private credits;

    event Deposited(address indexed payer, address indexed payee, uint256 amount);
    event Withdrawn(address indexed payee, uint256 amount);
    event OwnershipTransferred(address indexed oldOwner, address indexed newOwner);

    error NotOwner();
    error ZeroAddress();
    error ZeroAmount();
    error InsufficientVaultBalance();
    error NothingToWithdraw();
    error TransferFailed();

    modifier onlyOwner() {
        if (msg.sender != owner) revert NotOwner();
        _;
    }

    constructor() {
        owner = msg.sender;
        emit OwnershipTransferred(address(0), msg.sender);
    }

    receive() external payable {
        revert("Use depositFor()");
    }

    function transferOwnership(address newOwner) external onlyOwner {
        if (newOwner == address(0)) revert ZeroAddress();
        emit OwnershipTransferred(owner, newOwner);
        owner = newOwner;
    }

    function depositFor(address payee) external payable onlyOwner {
        if (payee == address(0)) revert ZeroAddress();
        if (msg.value == 0) revert ZeroAmount();

        credits[payee] += msg.value;
        emit Deposited(msg.sender, payee, msg.value);
    }

    function creditOf(address payee) external view returns (uint256) {
        return credits[payee];
    }

    function withdraw() external nonReentrant {
        uint256 amount = credits[msg.sender];
        if (amount == 0) revert NothingToWithdraw();

        credits[msg.sender] = 0;

        (bool ok, ) = payable(msg.sender).call{value: amount}("");
        if (!ok) revert TransferFailed();

        emit Withdrawn(msg.sender, amount);
    }

    function vaultBalance() external view returns (uint256) {
        return address(this).balance;
    }

    uint256 private locked = 1;

    modifier nonReentrant() {
        if (locked == 2) revert();
        locked = 2;
        _;
        locked = 1;
    }
}

How it works

  • depositFor(payee) records Ether owed to a specific address.
  • withdraw() lets the payee claim their balance.
  • The contract sets the balance to zero before making the external call.
  • A simple reentrancy lock prevents nested withdrawals.

This pattern is intentionally conservative. In a real project, you would usually import audited utilities from OpenZeppelin rather than writing your own guard logic.


Why the withdrawal order matters

The most important rule in pull payments is:

Update internal state before calling external code.

If the contract sent Ether first and reduced the balance afterward, a malicious recipient could re-enter withdraw() and claim funds multiple times. By setting credits[msg.sender] = 0 first, the contract ensures that a second call sees no remaining balance.

This is the classic checks-effects-interactions pattern:

StepPurposeExample
ChecksValidate inputs and permissionsonlyOwner, nonzero address
EffectsUpdate contract statecredits[msg.sender] = 0
InteractionsCall external contracts or send Ethercall{value: amount}("")

Following this order is one of the simplest ways to reduce security risk in Solidity.


Supporting ERC20 payouts

Many real systems distribute ERC20 tokens instead of Ether. The same pull-based idea applies: the vault records token balances per recipient, and users withdraw when they want.

A token vault differs in two main ways:

  • It stores credits per token address and recipient
  • It transfers tokens using transfer or safeTransfer

A practical structure is a nested mapping:

mapping(address token => mapping(address payee => uint256 amount)) private tokenCredits;

This allows the contract to manage multiple tokens without mixing balances.

Example token vault sketch

function depositTokenFor(address token, address payee, uint256 amount) external onlyOwner {
    require(token != address(0) && payee != address(0), "zero address");
    require(amount > 0, "zero amount");

    IERC20(token).transferFrom(msg.sender, address(this), amount);
    tokenCredits[token][payee] += amount;
}

function withdrawToken(address token) external {
    uint256 amount = tokenCredits[token][msg.sender];
    require(amount > 0, "nothing to withdraw");

    tokenCredits[token][msg.sender] = 0;
    IERC20(token).transfer(msg.sender, amount);
}

For production code, prefer SafeERC20 because some tokens do not return a boolean value consistently.


When to use pull payments instead of push payments

Pull payments are not always necessary, but they are often the safer default.

Good fit

  • Multiple recipients with independent claim timing
  • Unknown or untrusted recipient contracts
  • Systems where a failed transfer should not block others
  • High-volume payout workflows where gas efficiency matters

Less useful

  • Single recipient, immediate settlement
  • Simple one-off transfers where failure should revert the whole transaction
  • Systems where the contract must guarantee automatic delivery at a specific moment

The table below summarizes the trade-offs:

AspectPush paymentsPull payments
Recipient controlLowHigh
Failure isolationPoorStrong
Reentrancy exposureHigherLower
Gas predictabilityWorse for many recipientsBetter
UX simplicitySimpler for one recipientBetter for many recipients

Best practices for a production vault

A payment vault is small, but it still benefits from disciplined engineering.

1. Use custom errors

Custom errors reduce deployment size and make revert reasons cheaper than string messages. They also improve readability when used consistently.

Examples:

  • error NothingToWithdraw();
  • error ZeroAddress();
  • error TransferFailed();

2. Emit events for accounting changes

Events are essential for off-chain indexing and user interfaces. A frontend can query deposits and withdrawals without scanning storage directly.

Recommended events:

  • Deposited(payer, payee, amount)
  • Withdrawn(payee, amount)
  • OwnershipTransferred(oldOwner, newOwner)

3. Keep authorization narrow

Only the deposit source should be allowed to assign credits if the vault is meant to represent trusted accounting. If anyone can deposit for anyone else, that may be fine for donations or rewards, but not for controlled revenue distribution.

4. Handle unexpected Ether carefully

If the contract should only accept Ether through depositFor(), reject plain transfers with receive() and fallback() behavior that reverts. This prevents accidental funding that is not reflected in accounting.

5. Consider emergency recovery

Sometimes Ether can be forced into a contract via selfdestruct from another contract, even if receive() reverts. If your application needs strict balance accounting, add an owner-only recovery function with clear rules and audit it carefully.


Extending the vault with scheduled releases

A useful enhancement is to combine pull payments with release schedules. Instead of making all credits immediately withdrawable, the contract can unlock them over time.

A common pattern is:

  • Store total allocation
  • Store start time and duration
  • Compute vested amount on demand
  • Let users withdraw only the releasable portion

This is especially useful for contributor rewards, advisor payouts, or milestone-based compensation. However, this becomes a vesting system, so the accounting logic is more complex than a plain vault.

If you only need independent claims, keep the vault simple. Simplicity is a security feature.


Testing scenarios you should cover

A payment vault should be tested with both normal and adversarial cases.

Functional tests

  • Owner can deposit for a recipient
  • Recipient can withdraw exactly the credited amount
  • Credits reset to zero after withdrawal
  • Multiple deposits accumulate correctly
  • Non-owner cannot deposit if restricted

Security tests

  • Withdrawal cannot be re-entered
  • Withdrawal fails cleanly if transfer fails
  • Zero address deposits are rejected
  • Zero-value deposits are rejected
  • Unauthorized ownership transfer is rejected

Edge cases

  • Recipient is a smart contract with a reverting fallback
  • Recipient is a smart contract that attempts reentrancy
  • Contract receives forced Ether outside the deposit path
  • Large balances do not overflow under Solidity 0.8+ checked arithmetic

A good test suite should also verify emitted events, because event correctness matters for analytics and UI integration.


Practical integration pattern

In a real application, the vault is often not the main contract. Instead, it acts as a settlement layer used by another system.

For example, a marketplace contract might do this:

  1. Buyer purchases an item.
  2. Marketplace calculates seller proceeds and protocol fee.
  3. Marketplace deposits seller proceeds into the vault.
  4. Marketplace deposits protocol fee into a treasury address.
  5. Seller withdraws later.

This separation keeps the marketplace logic focused on business rules while the vault handles payout safety.

A similar approach works for:

  • Subscription billing
  • Affiliate commissions
  • Revenue splits among project contributors
  • Refund queues after dispute resolution

Common mistakes to avoid

Sending Ether inside loops

Do not iterate over many recipients and send funds directly in one transaction. It is expensive and brittle.

Ignoring failed calls

Never assume call succeeds. Always check the return value and revert or handle failure explicitly.

Updating state after external calls

This is the most dangerous mistake in payout logic. Always update balances first.

Using transfer() blindly

transfer() forwards a fixed gas stipend and can fail unexpectedly for contract recipients. Modern Solidity code generally prefers call with proper checks.

Mixing accounting with business logic

Keep the vault focused on balances and withdrawals. Put pricing, eligibility, and distribution rules in separate contracts or modules.


Conclusion

A pull-based payment vault is one of the most practical Solidity patterns for safe fund distribution. It improves reliability, reduces reentrancy risk, and scales better than push-based transfers when many recipients are involved.

The core idea is straightforward: record what each address can claim, then let recipients withdraw on their own terms. Once you understand that separation, you can adapt the same pattern to Ether, ERC20 tokens, revenue sharing, refunds, and more complex settlement workflows.

Learn more with useful resources