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.sol template 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
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 + releaseDelaypermanently locks fundsAffected function:
releaseEscrow()Solidity 0.8+ uses checked arithmetic by default. A malicious payer sets
releaseDelay = type(uint256).max, makingfundedAt + releaseDelayalways revert. The payee's funds are permanently locked — no code path can free them.Fix:
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:
Vulnerability 3 — MEDIUM: Fee rate computed at release time, not at fund time
If the protocol owner calls
setFee()betweenfundEscrowandreleaseEscrow, the payee receives less than the agreed amount with no on-chain signal.Fix: Snapshot
feeBpsinto the escrow struct at fund time:Vulnerability 4 — LOW: Single-step ownership transfer allows permanent protocol lock
Using OpenZeppelin's
Ownable(single-step) means a typo intransferOwnership()permanently and irrecoverably surrenders contract control.Fix: Use
Ownable2Step(propose + accept):Suggested Arc Network action
PaymentEscrow.soltemplate with all four fixes appliedEnvironment: 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