Skip to content

[BUG]: Escrow settlement permanently blocked by irreversible status transition after any successful refund call #434

Description

@Odhiambo526

Severity

High (breaks core features)

Description

In Escrow.sol, after escrow() sets statuses[escrowId] = EscrowStatus.CREATED, the function settle(bytes32[] calldata escrowIds) contains if (statuses[escrowId] != EscrowStatus.CREATED) revert InvalidStatus(); followed by statuses[escrowId] = EscrowStatus.FINALIZED; and the ISettler.read() check; conversely, refundDepositor(), refundRecipient(), and refund() first enforce if (block.timestamp <= _escrow.refundTimestamp) revert RefundInvalid();, then read the current status and execute if (status == EscrowStatus.CREATED) { statuses[escrowId] = EscrowStatus.REFUND_DEPOSIT; } else if (status == EscrowStatus.REFUND_RECIPIENT) { statuses[escrowId] = EscrowStatus.FINALIZED; } else revert InvalidStatus(); (or the symmetric path for refundRecipient), after which any subsequent call to settle() on the same escrowId unconditionally reverts with InvalidStatus() regardless of the return value of ISettler(_escrow.settler).read(_escrow.settlementId, _escrow.sender, _escrow.senderChainId) or the state of any LayerZero message.

Proof of Concept

add this to escrow.t.sol

    function testSettleRevertsInvalidStatusAfterSuccessfulRefund() public {
        bytes32[] memory ids = new bytes32[](1);

        IEscrow.Escrow memory eOk = _createEscrowData(1000, 800);
        eOk.salt = bytes12(uint96(200));
        ids[0] = _createAndFundEscrow(eOk);
        vm.prank(settlerOwner);
        settler.write(sender, eOk.settlementId, 1);
        vm.expectEmit(true, false, false, false);
        emit EscrowSettled(ids[0]);
        escrow.settle(ids);
        assertEq(uint256(escrow.statuses(ids[0])), uint256(IEscrow.EscrowStatus.FINALIZED));
        assertEq(token.balanceOf(recipient), 1000);
        assertTrue(settler.read(eOk.settlementId, eOk.sender, eOk.senderChainId));

        IEscrow.Escrow memory e0 = _createEscrowData(1000, 800);
        e0.salt = bytes12(uint96(201));
        ids[0] = _createAndFundEscrow(e0);
        vm.prank(settlerOwner);
        settler.write(sender, e0.settlementId, 1);
        vm.warp(block.timestamp + 2 hours);
        escrow.refundDepositor(ids);
        assertTrue(settler.read(e0.settlementId, e0.sender, e0.senderChainId));
        vm.expectCall(
            address(settler),
            abi.encodeCall(ISettler.read, (e0.settlementId, e0.sender, e0.senderChainId)),
            uint64(0)
        );
        vm.expectRevert(Escrow.InvalidStatus.selector);
        escrow.settle(ids);

        IEscrow.Escrow memory e1 = _createEscrowData(1000, 800);
        e1.salt = bytes12(uint96(202));
        ids[0] = _createAndFundEscrow(e1);
        vm.prank(settlerOwner);
        settler.write(sender, e1.settlementId, 1);
        vm.warp(block.timestamp + 2 hours);
        escrow.refundRecipient(ids);
        vm.expectRevert(Escrow.InvalidStatus.selector);
        escrow.settle(ids);

        IEscrow.Escrow memory e2 = _createEscrowData(1000, 800);
        e2.salt = bytes12(uint96(203));
        ids[0] = _createAndFundEscrow(e2);
        vm.prank(settlerOwner);
        settler.write(sender, e2.settlementId, 1);
        vm.warp(block.timestamp + 2 hours);
        escrow.refund(ids);
        vm.expectRevert(Escrow.InvalidStatus.selector);
        escrow.settle(ids);
    }
  • First block (eOk): Shows the intended happy path which is, settle() succeeds when status == CREATED, correctly calls settler.read(), emits EscrowSettled, transfers the full escrowAmount, and ends in FINALIZED.

  • Second block (e0): Shows the vulnerable path which is, after refundDepositor() succeeds, even though settler.read() returns true (explicitly asserted), settle() reverts with InvalidStatus() without ever calling settler.read() (proven by vm.expectCall(..., 0)).

  • The vm.expectCall(..., uint64(0)) is an excellent technical assertion: it proves that the ISettler.read() line inside settle() is never executed once the status has left CREATED. This directly validates the core problem: the status transition is irreversible and short-circuits the settlement check.

forge test --match-test testSettleRevertsInvalidStatusAfterSuccessfulRefund -vvv
[⠊] Compiling...
No files changed, compilation skipped

Ran 1 test for test/Escrow.t.sol:EscrowTest
[PASS] testSettleRevertsInvalidStatusAfterSuccessfulRefund() (gas: 1193589)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 20.57ms (14.51ms CPU time)

Ran 1 test suite in 24.57ms (20.57ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Additional Context

Impact

Once any refund* function succeeds on an escrowId, settle() becomes unreachable for that escrowId for the remainder of the contract's lifetime.

Mitigation

Replace the strict CREATED check in settle() with if (statuses[escrowId] == EscrowStatus.FINALIZED) revert InvalidStatus(); and perform the status transition to FINALIZED only after a successful ISettler.read() check.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions