DEV Community

Cover image for A .NET Dinosaur in Web3. Day 14 β€” Pull Pattern vs Dangerous Push Payments
Alena
Alena

Posted on • Originally published at Medium

A .NET Dinosaur in Web3. Day 14 β€” Pull Pattern vs Dangerous Push Payments

🧐 Challenge Day 2 of 7: Why Push Payments Will Ruin Your dApp (Pull-over-Push & Escrow)

Today's learning topic left me confused β€” in the best possible way. It was hard, but interesting and genuinely challenging, because it starts to touch real risks and real money. The main question of the day: how do you get funds out of a contract safely?

The answer is less obvious than it looks.

The Problem

In smart contracts, automatically distributing ETH to external addresses within core state-changing functions is called the Push Pattern. In reality, it's a dangerous architectural flaw.

If a contract attempts to push funds to an external address and that address fails to accept the transfer, the entire transaction reverts. In a naive implementation, a single failed transfer blocks the administrative function β€” causing a Denial of Service (DoS) condition and permanently locking everyone's assets inside the contract.

// ❌ BAD β€” Push Pattern
function release() external onlyArbiter {
    (bool success, ) = seller.call{value: balance}("");
    require(success, "Transfer failed");
    // If seller's contract reverts, this entire function is blocked forever
}

Enter fullscreen mode Exit fullscreen mode

The Contract: SimpleEscrow

Three participants: a buyer, a seller, and an arbiter. The buyer deposits ETH. The arbiter either releases funds to the seller or refunds the buyer. Nobody pushes anything β€” the seller and buyer pull their own funds when they're ready.

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

contract SimpleEscrow {
    error OnlyArbiter();
    error InvalidState();
    error TransferFailed();
    error NothingToWithdraw();

    enum State { AwaitingPayment, AwaitingDelivery, Completed, Refunded }

    address public immutable buyer;
    address public immutable seller;
    address public immutable arbiter;

    uint256 public immutable amount;
    State public currentState;

    mapping(address => uint256) public balanceOf;

    event Deposited(address indexed buyer, uint256 amount);
    event Released();
    event RefundedToBuyer();
    event Withdrawn(address indexed payee, uint256 amount);

    modifier onlyArbiter() {
        if (msg.sender != arbiter) revert OnlyArbiter();
        _;
    }

    constructor(address _seller, address _arbiter, uint256 _amount) {
        buyer = msg.sender;
        seller = _seller;
        arbiter = _arbiter;
        amount = _amount;
        currentState = State.AwaitingPayment;
    }

    function deposit() external payable {
        if (msg.sender != buyer) revert InvalidState();
        if (currentState != State.AwaitingPayment) revert InvalidState();
        if (msg.value != amount) revert InvalidState();
        currentState = State.AwaitingDelivery;
        emit Deposited(buyer, msg.value);
    }

    function release() external onlyArbiter {
        if (currentState != State.AwaitingDelivery) revert InvalidState();
        currentState = State.Completed;
        balanceOf[seller] += amount;
        emit Released();
    }

    function refund() external onlyArbiter {
        if (currentState != State.AwaitingDelivery) revert InvalidState();
        currentState = State.Refunded;
        balanceOf[buyer] += amount;
        emit RefundedToBuyer();
    }

    function withdraw() external {
        // 1. Checks
        uint256 payment = balanceOf[msg.sender];
        if (payment == 0) revert NothingToWithdraw();

        // 2. Effects β€” zero out BEFORE sending
        balanceOf[msg.sender] = 0;

        // 3. Interactions β€” low-level call, no gas limit
        (bool success, ) = msg.sender.call{value: payment}("");
        if (!success) revert TransferFailed();

        emit Withdrawn(msg.sender, payment);
    }
}

Enter fullscreen mode Exit fullscreen mode

Pull-over-Push: The Bulkhead Pattern

Coming from an enterprise .NET background, the natural instinct is to reach for fault tolerance patterns. But there's an important distinction to make here.

Circuit Breaker (via Polly in .NET) stops all traffic to a failing downstream service globally. If the payment gateway breaks, the Circuit Breaker opens β€” no requests go through for anyone until it recovers.

Bulkhead is different. It isolates resources so a failure in one compartment doesn't sink the ship. Inspired by watertight compartments in a ship's hull: if one section floods, the bulkhead contains it. The rest of the ship stays dry.

Pull-over-Push is a Bulkhead implementation:

  • release() and refund() only modify internal state β€” balanceOf[user]. This is a guaranteed EVM storage operation. Nothing external, nothing that can fail.
  • The actual ETH movement is deferred to withdraw(), executed independently by the payee.
  • If a recipient's contract is broken, the failure is strictly contained within their transaction. Other participants and core contract logic are completely unaffected.

Why .transfer() Is Dead

Tutorials recommend payee.transfer(amount) because it limits forwarded gas to 2,300 units, theoretically preventing reentrancy. But this is wrong β€” and the Istanbul hardfork of 2019 shows the concrete reason why.

Ethereum core developers increased the gas cost of the SLOAD opcode (storage reads). Overnight, thousands of production contracts using .transfer() broke β€” because honest recipient contracts suddenly needed more than 2,300 gas.

Gnosis Safe is the canonical example. It's a multi-signature corporate wallet where transactions require approval from multiple signers. When receiving ETH, it runs internal verification checks β€” storage reads. After Istanbul, those checks cost more than 2,300 gas. Every .transfer() to a Gnosis Safe wallet started failing with Out of Gas.

The bad idea was the hardcoded gas limit. Tying runtime gas expectations into deployed bytecode violates loose coupling. The EVM evolves; contracts with hardcoded gas assumptions become bricks.

The correct approach:

(bool success, ) = target.call{value: amount}("");
if (!success) revert TransferFailed();

Enter fullscreen mode Exit fullscreen mode

.call forwards all remaining gas. The EVM can change opcode pricing; the contract adapts.

One critical note: .call does not revert on failure β€” it returns a boolean. The if (!success) revert check is mandatory. Omitting it means the Effects step has already zeroed the balance with no automatic rollback.

Checks-Effects-Interactions (CEI)

The withdraw() function enforces CEI strictly to prevent reentrancy:

  1. Checks β€” validate that the caller has a positive balance
  2. Effects β€” zero out storage state before initiating the transfer
  3. Interactions β€” trigger the external call

A natural question: if we zero out the balance first and then .call fails, does the user lose their money?

No β€” and this is where EVM atomicity comes in. The blockchain equivalent of ACID database transactions: if any part of a transaction reverts, all state changes within that transaction are rolled back. The zeroed balance reverts to its previous value.

But this only applies if the failure actually causes a revert. Since .call returns a boolean instead of reverting, you must check it and revert manually. Without if (!success) revert TransferFailed(), the balance stays zeroed and the ETH never moves. CEI without the explicit failure check is broken.

Testing the Contract

⚠️ Do not make the same mistake I did.

When testing this contract on a local node, my first attempts kept failing. The cause: I had been running the local node continuously, and after redeploying β€” with different parameters during experimentation β€” Ignition was using cached state from a previous run. The contract on-chain had amount set to something different from what the console session expected.

The fix: reset everything.

# Stop the running node (Ctrl+C), then restart it fresh
npx hardhat node

# Deploy with --reset to force Ignition to ignore its journal
npx hardhat ignition deploy ignition/modules/SimpleEscrow.ts --network localhost --reset

Enter fullscreen mode Exit fullscreen mode

After a clean deploy, the full happy path in the console:

npx hardhat console --network localhost

const { viem } = await network.create();
const [buyer, seller, arbiter] = await viem.getWalletClients();

const escrowAddress = "0x5FbDB2315678afecb367f032d93F642f64180aa3";
const escrow = await viem.getContractAt("SimpleEscrow", escrowAddress);

// Buyer deposits exactly 1 ETH
await escrow.write.deposit([], { value: 1000000000000000000n });

// State should be 1 (AwaitingDelivery)
await escrow.read.currentState();

// Arbiter releases funds β€” only updates balanceOf, no ETH moves yet
const escrowAsArbiter = await viem.getContractAt("SimpleEscrow", escrowAddress, { client: { wallet: arbiter } });
await escrowAsArbiter.write.release();

// Seller pulls their funds
const escrowAsSeller = await viem.getContractAt("SimpleEscrow", escrowAddress, { client: { wallet: seller } });
await escrowAsSeller.write.withdraw();

// Seller's balance inside the contract should now be 0n
await escrow.read.balanceOf([seller.account.address]);

Enter fullscreen mode Exit fullscreen mode

The sequence maps exactly to the Pull-over-Push design: release() only changes numbers in a mapping, withdraw() is where ETH actually moves β€” initiated by the seller, on their own terms.

What's Next

Day 3: DAO Voting β€” dynamic arrays, struct mappings, and on-chain governance logic.


Repo: Day 2 of 7: Escrow contract

Follow the journey on Telegram: t.me/dotnetToWeb3

Stage: Dinosaur πŸ¦• β€” going deeper into the bedrock. Day 2 of 7.

Top comments (0)