Add community security audit report & contract safety improvements#27
Add community security audit report & contract safety improvements#27saiboyizhan wants to merge 8 commits into
Conversation
Independent security audit of the BAP-578 reference implementation covering the BAP578.sol contract. Findings summary: - 2 Critical: emergencyWithdraw() can drain all agent funds (rug pull vector) - 3 High: missing reentrancy guard, retroactive free mint changes, treasury failure - 4 Medium: unbounded loop DoS, missing events, deprecated API, no input validation - 3 Low: immutable fee, missing fallback, burn edge case - 3 Informational: test suite compat, documentation, gas optimization
Security Audit Report (SECURITY-AUDIT.md): - 2 Critical, 4 High, 3 Medium, 4 Low, 3 Informational findings - Full line-level verification against contract source Code Fixes (contracts/BAP578.sol): - Restrict emergencyWithdraw() to unallocated funds only via totalAgentBalances - Add require(msg.value == 0) for free mints to prevent ETH lock - Add public burn() function (_burn was previously unreachable dead code) - Add metadata string length validation (MAX_METADATA_URI_LENGTH, etc.) - Add nonReentrant and require(msg.value > 0) to fundAgent() - Add require(amount > 0) to withdrawFromAgent() - Fix isFreeMint fragile pattern (increment tokenId before use) - Add FreeMintsPerUserUpdated event to setFreeMintsPerUser() - Add fallback() with descriptive revert message - Add tokensOfOwnerPaginated() for safe pagination - Clean up agentStates/agentMetadata mappings after burn - Add uint256[39] __gap for upgrade-safe storage Test Updates (test/BAP578.test.js): - Update emergencyWithdraw test to verify agent funds are protected - Add test for agent owner withdrawal - Add test for burn functionality All 33 tests passing.
|
Thanks @saiboyizhan we are reviewing this now. Great improvements proposed. |
|
This is a great PR thank you. Directionally, the audit is well-scoped and the top two criticals are real, high-impact issues for any contract that lets users deposit funds ( We have some initial suggestions here: 1. Please confirm
2. Burn semantics
3. Free mint ETH-lock fix
4. Pagination function
5. Upgradeable storage gap
|
| // STORAGE GAP (for upgrade safety) | ||
| // ============================================ | ||
|
|
||
| uint256[39] private __gap; |
Check warning
Code scanning / Slither
Conformance to Solidity naming conventions Warning
|
Thanks for the detailed review! I'll add the test cases for all 5 items and push an update. |
1. totalAgentBalances invariant — verify balance >= totalAgentBalances across deposits, withdrawals, multiple agents, and burn scenarios 2. Burn semantics — owner-only permission, balance must be 0, state cleanup after burn, event emission 3. Free mint ETH lock — verify sending ETH with free mint reverts, no ETH locked in contract after free mints 4. Pagination edge cases — offset >= balance, limit=0, last page, exceeding limit, empty address 5. Upgradeable storage gap — UUPS proxy verification, state preservation through upgrade, no V2 storage conflicts
|
Hi @christelbuchanan, just a friendly follow-up — I've pushed the test cases covering all 5 areas you requested (commit 3224c6b), and CI is fully green (11/11 checks passing). Let me know if there's anything else needed for this to move forward. Happy to make further adjustments if needed! |
Address review feedback: totalAgentBalances must be initialized when upgrading from V1 to prevent withdrawal underflow and emergencyWithdraw bypass.
75ef8f0 to
7e1361b
Compare
|
Thanks so much @saiboyizhan |
Greptile SummaryThis PR is a community security audit contribution that ships a detailed audit report ( Key observations from the review:
Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant BAP578
participant Treasury
participant Owner
Note over BAP578: V1→V2 Upgrade Path
Owner->>BAP578: upgradeToAndCall(newImpl, initializeV2(seed))
BAP578-->>Owner: totalAgentBalances = seed
Note over BAP578: Free Mint (H-03 fix)
User->>BAP578: createAgent(to, ...) [msg.value == 0 enforced]
BAP578-->>User: tokenId minted, isFreeMint[tokenId] = true
Note over BAP578: Paid Mint
User->>BAP578: createAgent(to, ...) [msg.value == MINT_FEE]
BAP578->>Treasury: transfer MINT_FEE
BAP578-->>User: tokenId minted
Note over BAP578: Fund / Withdraw Agent
User->>BAP578: fundAgent(tokenId) [ETH]
BAP578-->>BAP578: agentStates[id].balance += value\ntotalAgentBalances += value
User->>BAP578: withdrawFromAgent(tokenId, amount)
BAP578-->>BAP578: balance -= amount\ntotalAgentBalances -= amount
BAP578-->>User: transfer amount
Note over BAP578: Emergency Withdraw (C-01/C-02 fix)
Owner->>BAP578: emergencyWithdraw()
BAP578-->>BAP578: unallocated = balance - totalAgentBalances
BAP578-->>Owner: transfer unallocated only
Note over BAP578: Burn (new public function)
User->>BAP578: burn(tokenId) [balance must be 0]
BAP578-->>BAP578: delete agentStates/agentMetadata/isFreeMint
BAP578-->>User: emit AgentBurned
Last reviewed commit: "fix: add nonReentran..." |
|
Hi @christelbuchanan @snowmanxm — friendly follow-up! Greptile flagged a valid issue on Once that's pushed, all review feedback from both of you + Greptile should be fully addressed. Would love to get this across the finish line — let me know if there's anything else needed! |
- Add onlyOwner to initializeV2() to prevent unauthorized access - Move AgentBurned event after _burn() to follow CEI pattern - Clean up isFreeMint mapping on burn for storage consistency
Compute end bound using subtraction (limit > tokenCount - offset) instead of addition (offset + limit > tokenCount) to avoid Panic(0x11) revert when limit is very large (e.g. type(uint256).max).
- Add nonReentrant modifier to emergencyWithdraw for defense-in-depth - Add NatDoc warning to initializeV2: must use upgradeToAndCall() atomically and pause contract before upgrading to prevent race conditions with fundAgent() calls
| function emergencyWithdraw() external onlyOwner nonReentrant { | ||
| uint256 unallocated = address(this).balance - totalAgentBalances; | ||
| require(unallocated > 0, "No unallocated balance"); | ||
| (bool success, ) = payable(owner()).call{ value: unallocated }(""); | ||
| require(success, "Emergency withdraw failed"); | ||
| } |
There was a problem hiding this comment.
emergencyWithdraw emits no event
emergencyWithdraw is a high-impact administrative action — it moves unallocated ETH out of the contract — yet it emits no event. Given the sensitivity of this function (it is the re-scoped successor of what the audit identified as a rug-pull vector), the absence of an event makes it impossible for off-chain monitoring tools, security dashboards, or users to detect when it is called, how much was withdrawn, or whether the invariant address(this).balance >= totalAgentBalances was preserved after the call.
All other ETH-moving functions in this contract (fundAgent, withdrawFromAgent) emit events. emergencyWithdraw should too:
| function emergencyWithdraw() external onlyOwner nonReentrant { | |
| uint256 unallocated = address(this).balance - totalAgentBalances; | |
| require(unallocated > 0, "No unallocated balance"); | |
| (bool success, ) = payable(owner()).call{ value: unallocated }(""); | |
| require(success, "Emergency withdraw failed"); | |
| } | |
| function emergencyWithdraw() external onlyOwner nonReentrant { | |
| uint256 unallocated = address(this).balance - totalAgentBalances; | |
| require(unallocated > 0, "No unallocated balance"); | |
| (bool success, ) = payable(owner()).call{ value: unallocated }(""); | |
| require(success, "Emergency withdraw failed"); | |
| emit EmergencyWithdraw(owner(), unallocated); | |
| } |
You'll also need to declare the event near the other events:
event EmergencyWithdraw(address indexed recipient, uint256 amount);
| require(balance > 0, "No balance"); | ||
| (bool success, ) = payable(owner()).call{ value: balance }(""); | ||
| function emergencyWithdraw() external onlyOwner nonReentrant { | ||
| uint256 unallocated = address(this).balance - totalAgentBalances; |
There was a problem hiding this comment.
emergencyWithdraw permanently bricked if initializeV2 seed is over-counted
Line 380 computes address(this).balance - totalAgentBalances. In Solidity 0.8+ this is an implicit checked subtraction — if totalAgentBalances > address(this).balance, the entire function reverts with an arithmetic panic.
This matters because initializeV2 seeds totalAgentBalances from an off-chain-computed parameter. If the operator supplies a value that is even 1 wei too high (e.g., due to a miscalculation or rounding error), the invariant address(this).balance >= totalAgentBalances is immediately violated. From that point on, every call to emergencyWithdraw will panic-revert — permanently — because:
- Subsequent
fundAgentcalls increment bothaddress(this).balanceandtotalAgentBalancesby the same amount, preserving the deficit. - Subsequent
withdrawFromAgentcalls decrement both by the same amount, also preserving the deficit. reinitializer(2)can only be called once, so there is no way to correct the seed value without a V3 upgrade.
The consequence is that any unallocated ETH that accumulates in the contract (e.g., from future accidental sends, or from tokens sent directly to the contract despite receive() reverting via selfdestruct) would be permanently unrecoverable via emergencyWithdraw.
An explicit guard before the subtraction makes the failure mode visible and non-permanent within a single call:
| uint256 unallocated = address(this).balance - totalAgentBalances; | |
| require(address(this).balance >= totalAgentBalances, "Accounting invariant violated"); | |
| uint256 unallocated = address(this).balance - totalAgentBalances; |
This converts the silent arithmetic panic into a readable revert message, which is also easier to diagnose in transaction traces.
| function burn(uint256 tokenId) external onlyTokenOwner(tokenId) { | ||
| _burn(tokenId); | ||
| emit AgentBurned(tokenId, msg.sender); | ||
| } |
There was a problem hiding this comment.
burn() lacks nonReentrant guard
All other state-changing functions that modify agentStates or ETH accounting (fundAgent, withdrawFromAgent, createAgent) are protected with nonReentrant. The burn() function modifies agentStates (via delete in _burn()), agentMetadata, and isFreeMint, but has no reentrancy protection.
While _burn() makes no external ETH calls and is therefore not currently reentrancy-vulnerable, adding nonReentrant is low-cost and consistent with the defense-in-depth philosophy already applied elsewhere in the contract (e.g., fundAgent was hardened with nonReentrant as I-02 in the audit, explicitly for this reason). Future upgrades that add callbacks or hooks inside _burn() would silently lack the guard.
| function burn(uint256 tokenId) external onlyTokenOwner(tokenId) { | |
| _burn(tokenId); | |
| emit AgentBurned(tokenId, msg.sender); | |
| } | |
| function burn(uint256 tokenId) external onlyTokenOwner(tokenId) nonReentrant { | |
| _burn(tokenId); | |
| emit AgentBurned(tokenId, msg.sender); | |
| } |
| * which would allow emergencyWithdraw() to drain agent funds. Pause the contract | ||
| * before upgrading to eliminate this risk. | ||
| */ | ||
| function initializeV2(uint256 _totalAgentBalances) external onlyOwner reinitializer(2) { |
Check warning
Code scanning / Slither
Conformance to Solidity naming conventions Warning
Summary
Independent community security audit of
BAP578.solwith verified code fixes. As a builder shipping a prediction market on BSC, I audited the BAP-578 reference implementation to assess integration safety and contribute back to the ecosystem.Audit Report:
SECURITY-AUDIT.md— 16 findings (2 Critical, 4 High, 3 Medium, 4 Low, 3 Informational)Code Fixes (13 issues resolved in
contracts/BAP578.sol):emergencyWithdraw()restricted to unallocated funds viatotalAgentBalancestrackerrequire(msg.value == 0)in free mint path to prevent ETH locktokensOfOwnerPaginated()for safe bounded queriesFreeMintsPerUserUpdatedevent tosetFreeMintsPerUser()fallback()with descriptive revertdelete agentStates/agentMetadatacleanup after burnnonReentranttofundAgent()for defense-in-depthburn()—_burn()was previously unreachable dead coderequire(msg.value > 0)tofundAgent()require(amount > 0)towithdrawFromAgent()isFreeMintfragile pattern (increment tokenId before use)uint256[39] __gapfor upgrade-safe storageDesign-level recommendations (UUPS timelock, pull-based treasury, snapshot-based free mints) are documented in the audit report as suggestions for the team to evaluate.
Test Results
All 33 tests passing (30 original + 3 new tests for emergency withdraw protection, owner withdrawal, and burn functionality).
Files Changed
SECURITY-AUDIT.md— Full audit reportcontracts/BAP578.sol— Bug fixestest/BAP578.test.js— Updated and new tests