Pull payment паттерн
Как использовать pull payment в Solidity: начислять pending balance вместо немедленной отправки ETH и давать получателю вывести средства отдельно.
Pull payment разделяет начисление и вывод
Pull payment — это паттерн выплат, где контракт не отправляет ETH получателю сразу. Вместо этого он записывает сумму в pendingWithdrawals, а получатель сам вызывает withdraw.
Так бизнес-функция не зависит от того, умеет ли получатель принимать ETH. Внешний вызов переносится в отдельную функцию вывода.
Пишем escrow для продаж
Создайте файл contracts/MarketplaceEscrow.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MarketplaceEscrow {
struct Listing {
address seller;
uint256 price;
bool active;
}
uint256 public nextListingId;
mapping(uint256 => Listing) public listings;
mapping(address => uint256) public pendingWithdrawals;
event ListingCreated(uint256 indexed listingId, address indexed seller, uint256 price);
event ListingBought(uint256 indexed listingId, address indexed buyer, address indexed seller, uint256 price);
event WithdrawalQueued(address indexed payee, uint256 amount);
event Withdrawn(address indexed payee, uint256 amount);
error ZeroAmount();
error ListingNotFound(uint256 listingId);
error ListingNotActive(uint256 listingId);
error SellerCannotBuy();
error InvalidPayment(uint256 expected, uint256 received);
error NothingToWithdraw();
error EthTransferFailed();
function createListing(uint256 price) external returns (uint256 listingId) {
if (price == 0) {
revert ZeroAmount();
}
listingId = nextListingId;
nextListingId += 1;
listings[listingId] = Listing({
seller: msg.sender,
price: price,
active: true
});
emit ListingCreated(listingId, msg.sender, price);
}
function buy(uint256 listingId) external payable {
Listing storage listing = listings[listingId];
if (listing.seller == address(0)) {
revert ListingNotFound(listingId);
}
if (!listing.active) {
revert ListingNotActive(listingId);
}
if (msg.sender == listing.seller) {
revert SellerCannotBuy();
}
if (msg.value != listing.price) {
revert InvalidPayment(listing.price, msg.value);
}
listing.active = false;
pendingWithdrawals[listing.seller] += msg.value;
emit ListingBought(listingId, msg.sender, listing.seller, msg.value);
emit WithdrawalQueued(listing.seller, msg.value);
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
if (amount == 0) {
revert NothingToWithdraw();
}
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
if (!success) {
revert EthTransferFailed();
}
emit Withdrawn(msg.sender, amount);
}
}Этот контракт создаёт объявления о продаже. Когда покупатель платит, ETH не отправляется продавцу сразу: сумма начисляется в pendingWithdrawals, а продавец забирает её отдельным withdraw.
Как это работает
pendingWithdrawals — mapping с долгами контракта перед получателями. Ключ — адрес получателя, значение — сколько ETH он может вывести.
buy(...) — выполняет бизнес-логику покупки: проверяет листинг, принимает оплату, закрывает листинг и начисляет продавцу баланс на вывод.
pendingWithdrawals[listing.seller] += msg.value — effect без внешнего вызова. Продавец получает право на вывод, но контракт пока не вызывает его адрес.
withdraw() — единственная функция, которая отправляет ETH наружу. Если получатель — контракт с receive(), внешнее выполнение произойдёт только здесь.
pendingWithdrawals[msg.sender] = 0 — state обновляется до call. Это Checks-Effects-Interactions внутри функции вывода.
revert EthTransferFailed() — если получатель не смог принять ETH, транзакция откатится и pending balance останется на месте.
Push vs Pull
Push payment отправляет ETH сразу:
(bool success, ) = seller.call{value: price}("");Если seller — контракт, он может откатить транзакцию, выполнить callback или попытаться повторно войти в ваш контракт.
Pull payment записывает долг:
pendingWithdrawals[seller] += price;Основная бизнес-функция завершается без внешнего вызова. Получатель сам решает, когда забрать ETH.
Где применять pull payment
Паттерн подходит для маркетплейсов, аукционов, refund-логики, распределения наград, royalty, escrow и любых выплат множеству адресов.
Он особенно полезен, когда получатель может быть контрактом. Вы не хотите, чтобы чужой receive() ломал покупку, финализацию аукциона или начисление награды.
Pull payment также помогает не делать циклы с выплатами. Вместо “разослать ETH всем” контракт начисляет balances, а каждый получатель забирает свою часть сам.
Частые ошибки
Отправлять ETH внутри основной бизнес-функции → так получатель может сломать покупку или финализацию. Лучше начислите pending balance.
Обнулять pending balance после call → сначала ставьте pendingWithdrawals[msg.sender] = 0, потом отправляйте ETH.
Делать массовый payout циклом → большой список получателей может упереться в gas limit. Дайте каждому получателю отдельный withdraw.
Что дальше
- Checks-Effects-Interactions паттерн — держите безопасный порядок в
withdraw - ReentrancyGuard — добавьте lock для функций с внешними вызовами
- Mapping и вложенные маппинги — храните pending balances по адресам