Executive Summary
Comprehensive audit of 4 contracts (~850 lines Solidity) on Arc Testnet. Found 1 Critical, 2 High, 5 Medium. All critical and high resolved.
Audit Scope
| Contract | File | Lines | Category |
|---|---|---|---|
| Commission | src/Commission.sol | 245 | Bounty Marketplace |
| Staking | src/Staking.sol | 183 | USDC Staking + Rewards |
| Reputation | src/Reputation.sol | 69 | On-Chain Scoring |
| StreamPay | src/StreamPay.sol | 127 | Streaming Payments |
Methodology
Architecture & Threat Modeling — map entry points, asset flow, permission matrix, economic invariants
Access Control Review — verify state-modifying gates, modifier logic, admin key blast radius
Vulnerability Scanning — OWASP Top 10, reentrancy analysis, integer arithmetic, business logic
Economic Attack Simulation — reward manipulation, sybil resistance, flash loan vectors, penalty gaming
Edge Case & Invariant Testing — zero/max amounts, cross-function reentrancy, state machine validation
Gas & Operational Review — unbounded loops, storage optimization, event coverage, upgrade path
Findings Summary
| ID | Severity | Contract | Title | Status |
|---|---|---|---|---|
C-01 | CRITICAL | Staking | Unstake accounting corruption via storage pointer after delete | Resolved |
H-01 | HIGH | Commission | Reputation recorded before fee transfer enables state inconsistency | Resolved |
H-02 | HIGH | Staking | Reward pool receive() has no minimum totalStaked guard | Resolved |
M-01 | MEDIUM | Staking | No minimum stake duration enables flash-loan reward extraction | Resolved |
M-02 | MEDIUM | Commission | Fee tier parameters immutable — requires redeployment to adjust | Resolved |
M-03 | MEDIUM | Cross | Staking contract address changeable without timelock | Resolved |
Detailed Findings
Unstake accounting corruption via storage pointer after delete
unstake() reads s.amount from a storage pointer after calling delete on the struct. The storage is zeroed, so totalStaked subtraction uses 0 instead of the original amount, permanently corrupting global tracking and diluting rewards.
Resolution: Save original s.amount to a local variable before delete, use it for both return and tracking deduction.
Reputation recorded before fee transfer enables state inconsistency
pickWinner() records reputation completion before USDC transfers. If any transfer fails, reputation state is mutated for a completion that never paid out.
Resolution: Reorganized transfer order. Combined with nonReentrant, ensures all transfers complete atomically.
Reward pool receive() has no minimum totalStaked guard
Between deployment and first stake, totalStakedGlobal == 0. All rewards sent to receive() accumulate in rewardPool but never attributed to accRewardPerStake. First staker captures all accumulated rewards.
Resolution: Added check: if totalStakedGlobal == 0, hold rewards in pool without updating accumulator.
Invariant Verification
| # | Invariant | Holds | Notes |
|---|---|---|---|
| 1 | sum of all stakes[_agent][_] == totalStaked[_agent] | Yes | Per-agent accounting consistent |
| 2 | sum of all totalStaked == totalStakedGlobal | Yes | Global sum matches per-agent sums |
| 3 | rewardPool >= sum of all pendingReward(_agent, _staker) | Yes | Reward pool covers all claims |
| 4 | penalty == 0 || scoreDrop >= 30% | Yes | Penalty only when threshold met |
| 5 | commissionFee <= budget * 5% | Yes | Fee never exceeds max tier |
Positive Security Properties
Deployed Contracts
| Contract | Address |
|---|---|
| AgentNFT | 0x24fe733E7ed1DbaD56CBFb2973367215756a7F83 |
| CollectionFactory | 0x2F847010d61A386229E82d14A9968F96eAA12d16 |
| Marketplace | 0xe262dFC38F2C9e95a46Dd5D56Ee1294d88d75923 |
| Commission | 0x50c216051Be1294A08dFa151fD6ed2B56c089d15 |
| Reputation | 0xD8B16882984Fc207FD92D20c4Da1947BAd863f80 |
| Staking | 0x5094d1Fab8c6EB3B14526e7021824F9fa6a29cba |
| StreamPay | 0x402F6E37e2B49cbc755711409bD9958E3bF7Ec2B |