Skip to content

security: developer advisory — 4 vulnerabilities found in common PaymentEscrow patterns when building on Arc Network #86

@osr21

Description

@osr21

Summary

During a formal security audit of ArcPay — a stablecoin invoicing dApp built on Arc Network — we discovered 4 exploitable vulnerabilities in a PaymentEscrow contract pattern representative of how developers build escrow/payment flows on Arc. We are filing this as a developer advisory so the Arc team can optionally surface it in documentation or workshops.

All four issues are patched in our contracts. The same patterns are likely to appear in other dApps building payment infrastructure on Arc Network.


Vulnerability 1 — HIGH: Arithmetic overflow on fundedAt + releaseDelay permanently locks funds

Affected function: releaseEscrow()

// VULNERABLE — reverts for any releaseDelay near type(uint256).max
require(block.timestamp >= e.fundedAt + e.releaseDelay, "Too early");

Solidity 0.8+ uses checked arithmetic by default. A malicious payer sets releaseDelay = type(uint256).max, making fundedAt + releaseDelay always revert. The payee's funds are permanently locked — no code path can free them.

Fix:

uint256 public constant MAX_RELEASE_DELAY = 90 days;

function fundEscrow(uint256 id, uint256 releaseDelay) external {
    require(releaseDelay <= MAX_RELEASE_DELAY, "Delay too large");
    // ...
}

Vulnerability 2 — HIGH: Payee has no on-chain recourse if payer is unresponsive

Many escrow implementations only allow the payer or an elapsed timer to call releaseEscrow(). If the payer goes offline or refuses, the payee — who already delivered services — cannot recover funds even after the release window passes.

Fix: Add payee as an unconditional release authority:

function releaseEscrow(uint256 id) external {
    Escrow storage e = escrows[id];
    bool authorized = (
        msg.sender == e.payer ||
        msg.sender == e.payee ||          // payee always has release authority
        block.timestamp >= e.fundedAt + e.releaseDelay
    );
    require(authorized, "Not authorized");
    // ...
}

Vulnerability 3 — MEDIUM: Fee rate computed at release time, not at fund time

// VULNERABLE — fee changes between fundEscrow and releaseEscrow affect payee silently
uint256 fee = (e.amount * feeBps) / 10_000;

If the protocol owner calls setFee() between fundEscrow and releaseEscrow, the payee receives less than the agreed amount with no on-chain signal.

Fix: Snapshot feeBps into the escrow struct at fund time:

struct Escrow {
    // ...
    uint16 feeBpsAtFund;  // locked at fundEscrow time
}

function fundEscrow(...) external {
    escrows[id].feeBpsAtFund = uint16(feeBps);
}

function releaseEscrow(uint256 id) external {
    uint256 fee = (e.amount * e.feeBpsAtFund) / 10_000;
}

Vulnerability 4 — LOW: Single-step ownership transfer allows permanent protocol lock

Using OpenZeppelin's Ownable (single-step) means a typo in transferOwnership() permanently and irrecoverably surrenders contract control.

Fix: Use Ownable2Step (propose + accept):

import "@openzeppelin/contracts/access/Ownable2Step.sol";
contract PaymentEscrow is Ownable2Step { ... }

Suggested Arc Network action

  1. Add a "Building Payment Contracts on Arc" security guide covering these four patterns
  2. Provide a reference PaymentEscrow.sol template with all four fixes applied
  3. Consider adding Slither / Foundry invariant tests to the arc-node contracts directory as a starting point for ecosystem developers

Environment: Arc Testnet (chainId 5042002), Solidity 0.8.20, PaymentEscrow pattern (approve → fundEscrow → releaseEscrow)
Discovered via: Full security audit of ArcPay (stablecoin invoice and payment platform) — May 2026

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    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