Repository navigation
Description
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
- Add a "Building Payment Contracts on Arc" security guide covering these four patterns
- Provide a reference
PaymentEscrow.soltemplate with all four fixes applied - 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
osr21 commentedon Jul 4, 2026
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:ConditionalEscrowalready has an escape hatch for this — the beneficiary can raise a dispute at any time independent ofreleaseTime, 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.InvoiceFactoringcontract snapshotsfeeBpsinto 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.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-stepOwnable.transferOwnership()does. Worth noting in the advisory that this generalizes beyondOwnableto any single-step privileged-role transfer, not just contract ownership.