F-2025-0004·state-machine-clarity

The Created State Alone Does Not Indicate Whether a Payment is Unlocked

Acknowledgedescrowpaymentserc-20
TL;DR

Payments are created in the Created state with an unlockTime, but the Created state alone does not indicate whether a payment is unlocked, complicating UI status queries and filtering of ready-to-settle payments.

Severity
LOW
Impact
LOW
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
LOW
Exploitability
LOW
02Section · Description

Description

Payments are created in the Created state with an unlockTime determining when they can be settled. However, the Created state alone does not indicate whether a payment is unlocked (i.e., unlockTime <= block.timestamp).

This ambiguity complicates querying payment status, as users must check unlockTime separately. The settlePayment function enforces this via two modifiers:

  • paymentCreatedStatus (ensures Created state)
  • paymentUnlocked (verifies unlockTime).
03Section · Impact

Impact

  • More complex user interfaces: UI needs to display both the payment status and unlock timing information.
  • Filtering and sorting challenges: difficult to implement efficient filtering of payments that are "ready to settle" versus those still locked.
04Section · Proof of Concept

Proof of Concept

solidity
function testUnlockedButNotSettledState() public {
uint256 timelockPeriod = 1 days;
vm.startPrank(user1);
token.approve(address(payments), paymentAmount);
payments.createERC20PaymentWithTimeLock(user2, address(token), paymentAmount, timelockPeriod);
vm.stopPrank();
PaymaticPayments.Payment memory payment = payments.getPaymentDetails(1);
recordStateChange(1, payment.status);
assertEq(uint256(payment.status), uint256(PaymaticPayments.PaymentStatus.Created));
vm.startPrank(user2);
vm.expectRevert(abi.encodeWithSelector(PaymaticPayments.PaymentTimelocked.selector));
payments.settlePayment(1);
vm.stopPrank();
vm.warp(block.timestamp + timelockPeriod + 1);
payment = payments.getPaymentDetails(1);
assertEq(uint256(payment.status), uint256(PaymaticPayments.PaymentStatus.Created));
assertTrue(payment.unlockTime < block.timestamp);
vm.startPrank(user2);
payments.settlePayment(1);
vm.stopPrank();
payment = payments.getPaymentDetails(1);
recordStateChange(1, payment.status);
// Verify state changed to Settled
assertEq(uint256(payment.status), uint256(PaymaticPayments.PaymentStatus.Settled));
// Verify state history
assertTrue(validateStateHistory(1));
// Verify transition was directly from Created to Settled,
// without an intermediate state indicating it was unlocked
assertEq(paymentStateHistory[1].length, 2);
assertEq(uint256(paymentStateHistory[1][0]), uint256(PaymaticPayments.PaymentStatus.Created));
assertEq(uint256(paymentStateHistory[1][1]), uint256(PaymaticPayments.PaymentStatus.Settled));
}
05Section · Recommendation

Recommendation

Add a view function to check if a payment is unlocked, improving usability without altering the contract's state structure.

solidity
function getPaymentLockStatus(uint256 id) external view returns (bool) {
if (payments[id].status == PaymentStatus.Created && payments[id].unlockTime > block.timestamp) {
return true;
}
return false;
}
F-2025-0004

oog
zealynx

Smart Contract Security Digest

Monthly exploit breakdowns, audit checklists, and DeFi security research — straight to your inbox

© 2026 Zealynx