Skip to content

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

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

Activity

  1. osr21 commented on Jul 4, 2026

    @osr21
    Author

    Cross-checked this advisory against our own Arc Testnet payment contracts (ConditionalEscrow, InvoiceFactoring, NanopayVault — from arc-stablecoin-dapp). A couple of data points that might be useful:

    • Vuln feat: add arcup installer, node docs, and consensus streaming improvements #2 (no payee recourse): our ConditionalEscrow already has an escape hatch for this — the beneficiary can raise a dispute at any time independent of releaseTime, and a neutral arbiter can force release with no time-based gate. Might be worth adding "dispute + time-independent arbiter authority" as a suggested pattern alongside the direct payee-release fix in the advisory.
    • Vuln feat: persistence meter, consensus tuning, TLS fix, spammer/arch docs #3 (fee computed at release, not fund time): our InvoiceFactoring contract snapshots feeBps into the invoice struct at creation time rather than reading a mutable global rate, so it isn't affected — confirms this is a solid general pattern worth calling out explicitly.
    • Vuln docs: link to node spinup documentation #4 (single-step ownership): found an equivalent risk in a different contract of ours — NanopayVault.setOperator() is a single-step, self-gated role transfer (onlyOperator), so a bad address argument permanently bricks the operator role the same way single-step Ownable.transferOwnership() does. Worth noting in the advisory that this generalizes beyond Ownable to any single-step privileged-role transfer, not just contract ownership.
  2. added
    securitySecurity vulnerability or auth/verification gap
    ecosystemComponent: ecosystem
    on Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ecosystemComponent: ecosystemsecuritySecurity vulnerability or auth/verification gap

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions