# Security Audit Report — PoidhV3 **Auditor**: LeftClaw automated two-phase audit (breadth + depth reconciliation) **Target**: `PoidhV3` — bounty/escrow protocol with solo & open bounties, weighted contributor voting, pull-payment withdrawals, and ERC-721 claim escrow **Address**: `0x5555fa783936c260f77385b4e153b9725fef1719` **Chains**: Base mainnet (8453) and Arbitrum One (42161) — identical source and bytecode verified on both via Sourcify **Compiler**: Solidity 0.8.24 **Scope**: `src/PoidhV3.sol` (918 lines, single contract). `src/interfaces/IPoidhClaimNFT.sol` reviewed as context only — the actual `PoidhClaimNFT` implementation contract is external and not in scope; findings note where PoidhV3 places trust in it. --- ## Methodology This report combines two independent audit passes reconciled into one unified set of findings: - **Phase 1 (breadth)** — 7 domain-checklist agents (general, precision-math, governance, access-control, DoS, ERC-721, chain-specific) walked ~500 checklist items derived from published audit methodologies (Dacian, beirao.xyz, Sigma Prime, RareSkills, Cyfrin, and others). - **Phase 2 (depth)** — 12 independent attacker-mindset agents (9 specialty lenses + 3 cross-lens gap-hunters) analyzed the same source **blind to Phase 1's findings**, each required to produce a concrete end-to-end exploit trace or an explicit, calibrated "lead." - **Phase 3 (reconciliation)** — findings from both phases were deduplicated by `(contract, function, bug class)`, cross-tagged by origin, and every phase-unique finding was re-verified against source before inclusion. **Reconciliation summary**: 11 unique `(function, bug-class)` groups identified across both phases raw output → **11 covered** in this unified report (0 dropped). **7 findings corroborated by both phases independently** (the strongest possible signal this methodology produces); **3 findings from Phase 1 only** (confirmed against source in reconciliation); **1 note from Phase 2 only** (confirmed non-exploitable by 4 independent agents). Severity uses the standard scale: **Critical** (direct loss of funds, no preconditions) / **High** (loss of funds requiring specific conditions, or permanent DoS) / **Medium** (degraded behavior, trust-model violation, incorrect accounting) / **Low** (best-practice violation or latent bug without direct fund risk) / **Info** (no security impact). --- ## Summary of Findings | # | Severity | Title | Origin | |---|----------|-------|--------| | 1 | **High** | No quorum in open-bounty vote resolution — issuer can drain contributor funds via a Sybil claim | **[both]** — corroborated by 7/7 phase-1 relevant agents + 11/12 phase-2 agents | | 2 | Medium | Sybil dust-join exhaustion permanently blocks new open-bounty contributors | [phase1: evm-audit-dos] | | 3 | Medium | Issuer can indefinitely block contributor withdrawals via perpetual claim re-submission | **[both]** | | 4 | Medium | NFT-transfer failure at claim acceptance permanently locks all bounty funds | **[both]** | | 5 | Low | `tx.origin == msg.sender` EOA gate excludes smart-contract wallets and is not a reliable guarantee on L2 | [phase1: evm-audit-access-control / evm-audit-chain-specific] | | 6 | Low | `everHadExternalContributor` never reset — one transient join permanently forces the slower vote path | **[both]** | | 7 | Low | Claim NFTs of rejected/cancelled claims are permanently stuck in escrow | **[both]** | | 8 | Low | Constructor sentinel pre-fill loop is unbounded — large `_startClaimIndex` makes deployment infeasible | [phase1: evm-audit-erc721] | | 9 | Low | Pagination view helpers zero-pad fixed-length-10 arrays | **[both]** | | 10 | Low / Info | Fee rounds to zero below 40 wei; rounding remainder favors the claimer over the treasury | **[both]** | | 11 | Info | Aggregate `address(this).balance` solvency check is a fragile-but-currently-dead pattern | [phase2 only — confirmed non-exploitable by 4 independent agents] | --- ## [1] No quorum in open-bounty vote resolution — issuer can drain contributor funds via a Sybil claim **Severity**: High **Origin**: **[both]** — independently identified by all 3 phase-1 agents whose checklists touched governance/access-control/general, and 11 of 12 phase-2 attacker agents, each with a full independent exploit trace **Location**: `resolveVote()` line 719, `submitClaimForVote()` lines 667-677 **Description** `resolveVote` decides a claim using: ```solidity bool passed = v.yes > ((v.no + v.yes) / 2); ``` This is a majority of **votes actually cast**, with no quorum measured against the total snapshotted participant weight. `submitClaimForVote` seeds the tally by auto-crediting the issuer's own weight as YES and marking the issuer as already voted: ```solidity bountyVotingTracker[bountyId] = Votes({yes: issuerWeight, no: 0, deadline: deadline}); lastVotedRound[bountyId][msg.sender] = roundId; // issuer auto-YES ``` If no external contributor casts a NO vote inside the fixed 2-day `votingPeriod`, the round resolves on the issuer's weight alone: `issuerWeight > issuerWeight / 2` is true for any nonzero weight. Because the bounty issuer cannot personally be the claim issuer (`IssuerCannotClaim` blocks only `msg.sender == bountyIssuer`), a malicious issuer simply files the winning claim from a second address they control. On acceptance, `_acceptClaim` pays the **entire pooled `bounty.amount`** — including all external contributions — to that address, minus the 2.5% protocol fee. The mathematics of the majority check itself is sound (verified by multiple agents: `yes > (yes+no)/2` is exactly equivalent to strict `yes > no`, with no off-by-one at any parity, and ties correctly fail). The defect is architectural: the threshold is measured against the wrong denominator (cast weight instead of total eligible weight), and contributor silence is treated as tacit approval rather than as an abstention that should count against acceptance. **Proof of Concept** (traced independently and converging on the same numbers across 6+ agents) 1. Attacker creates an open bounty funding only `MIN_BOUNTY_AMOUNT` (e.g. 0.01 ETH) via `createOpenBounty`. Attacker is participant slot 0 with weight 0.01 ETH. 2. Victims B, C, D each call `joinOpenBounty` contributing 10 ETH each. `bounty.amount = 30.01 ETH`; `everHadExternalContributor[bountyId] = true` (this permanently forces all future acceptances through the vote path, so the attacker cannot use the cheaper direct-accept route — they must go through voting, which is exactly the mechanism being exploited). 3. Attacker's second address X (not the bounty issuer) calls `createClaim(bountyId, ...)` — allowed, since the guard only excludes `bountyIssuer` itself. 4. Attacker calls `submitClaimForVote(bountyId, claimX)`. The tracker becomes `{yes: 0.01e18, no: 0, deadline: now + 2 days}`; the attacker is recorded as having voted. 5. B, C, D do not vote NO within the 2-day window (inattention, off-chain timing, or simply not noticing the round started — the realistic default for most passive pooled contributors). 6. After the deadline, anyone (including the attacker) calls `resolveVote(bountyId)`. `passed = 0.01e18 > (0 + 0.01e18)/2 = 0.005e18` → **true**. `_acceptClaim` computes `fee = 30.01e18 * 250/10000 ≈ 0.75 ETH`, `payout ≈ 29.26 ETH`, credited to `pendingWithdrawals[X]`. The claim NFT transfers to the attacker. 7. Attacker calls `withdraw()` and receives ~29.26 ETH — having risked only their original 0.01 ETH stake (which is itself returned inside the payout, since it's part of `bounty.amount`). Net theft: ~29.25 ETH from B, C, D, at a true cost to the attacker of essentially nothing beyond gas and the 2.5% fee on their own contribution. This is compounded by Finding 3 below: even if B/C/D vote NO and defeat one round, the attacker can immediately re-submit the same unaccepted claim for a fresh round, retrying indefinitely until contributors miss one window, and front-running any contributor's attempt to withdraw in the interim. **Recommendation** Require the passing threshold to be measured against total snapshotted eligible weight (a true quorum), not against weight actually cast: ```diff + mapping(uint256 => uint256) public bountyTotalVoteWeight; function submitClaimForVote(uint256 bountyId, uint256 claimId) external nonReentrant { ... address[] memory p = participants[bountyId]; uint256[] memory amounts = participantAmounts[bountyId]; + uint256 totalWeight; for (uint256 i = 0; i < p.length; i++) { address a = p[i]; uint256 w = amounts[i]; if (a != address(0) && w > 0) { voteWeightSnapshot[bountyId][a] = w; voteWeightSnapshotRound[bountyId][a] = roundId; + totalWeight += w; } } + bountyTotalVoteWeight[bountyId] = totalWeight; ... } function resolveVote(uint256 bountyId) external nonReentrant { ... - bool passed = v.yes > ((v.no + v.yes) / 2); + bool passed = v.yes * 2 > bountyTotalVoteWeight[bountyId]; ... } ``` Consider also excluding the issuer's auto-seeded YES from counting toward the quorum threshold, and/or barring a bounty issuer from ever nominating a claim whose issuer they control (unenforceable on-chain against Sybils, but worth documenting as a trust assumption). --- ## [2] Sybil dust-join exhaustion permanently blocks new open-bounty contributors **Severity**: Medium **Origin**: [phase1: evm-audit-dos] **Location**: `joinOpenBounty()` lines 419-464, `MAX_PARTICIPANTS = 150` (line 24), `freeParticipantSlots` (lines 87, 435-450) **Description** `joinOpenBounty` appends a new participant slot for each fresh address and reverts with `MaxParticipantsReached` once `participants[bountyId].length >= 150`. Slots are only recycled when their occupant *voluntarily* withdraws — `freeParticipantSlots` provides no defense against a griefer who simply never withdraws. An attacker can occupy all 149 non-issuer slots with `MIN_CONTRIBUTION`-sized deposits from Sybil addresses and never vacate them, permanently blocking any legitimate contributor from joining that specific bounty. The attacker's capital is fully refundable at any time (via `withdrawFromOpenBounty`), so the attack costs only gas plus temporarily-locked capital that is never actually at risk unless the bounty is genuinely accepted. **Proof of Concept** Victim creates an open bounty (issuer occupies slot 0). Attacker generates 149 addresses and calls `joinOpenBounty{value: MIN_CONTRIBUTION}(bountyId)` from each, filling `participants[bountyId]` to length 150. Any subsequent `joinOpenBounty` call from a legitimate contributor reverts with `MaxParticipantsReached()`. **Recommendation** Scale the minimum join amount relative to the bounty's current size (e.g. require `msg.value >= bounty.amount / K` for some K), or replace the fixed per-address slot model with aggregate-weight accounting that does not require a hard participant cap. --- ## [3] Issuer can indefinitely block contributor withdrawals via perpetual claim re-submission **Severity**: Medium **Origin**: **[both]** — phase-1 governance checklist; phase-2 corroborated by 7 of 12 independent attacker agents (trust-gap, access-control, economic-security, flow-gap, boundary, periphery, asymmetry) **Location**: `submitClaimForVote()` line 636, `withdrawFromOpenBounty()` lines 468-469 (via `_requireActiveBounty`, lines 251-252) **Description** Joins and withdrawals both revert with `VotingOngoing` whenever `bountyCurrentVotingClaim[bountyId] != 0`. A claim that fails a vote is not marked `accepted`, so the exact same `claimId` can be resubmitted for a new round immediately at the cost of gas alone. A malicious issuer can front-run any contributor's pending `withdrawFromOpenBounty` transaction with a fresh `submitClaimForVote` on the same losing claim, re-locking all contributor funds for another full `votingPeriod` (2 days). This is repeatable indefinitely. Contributors have no forced exit — `claimRefundFromCancelledOpenBounty` requires the issuer to voluntarily cancel first. This directly amplifies Finding 1: every additional forced round is another opportunity for the quorum-drain to succeed against contributors who eventually stop watching. **Proof of Concept** A vote on claim C fails (contributors successfully voted NO). The round clears. Contributor Bob broadcasts `withdrawFromOpenBounty(bountyId)`. The issuer observes Bob's pending transaction and front-runs it with `submitClaimForVote(bountyId, C)` (allowed — C was never marked `accepted`). Bob's withdrawal reverts with `VotingOngoing`. The issuer repeats this indefinitely. **Recommendation** Add a resubmission cooldown per failed claim, or guarantee a minimum forced-open withdrawal window between voting rounds during which `bountyCurrentVotingClaim` cannot be set: ```solidity mapping(uint256 => uint256) public nextSubmitAllowedAt; // in the failure branches of resolveVote / resetVotingPeriod: nextSubmitAllowedAt[bountyId] = block.timestamp + WITHDRAW_WINDOW; // in submitClaimForVote: if (block.timestamp < nextSubmitAllowedAt[bountyId]) revert TooSoon(); ``` --- ## [4] NFT-transfer failure at claim acceptance permanently locks all bounty funds **Severity**: Medium **Origin**: **[both]** — phase-1 ERC-721 checklist (two distinct mechanisms merged: the missing try/catch, and the unverified `mintToEscrow` trust assumption); phase-2 first-principles agent independently traced the same liveness coupling **Location**: `_acceptClaim()` lines 782-827 (specifically line 822, `poidhNft.transferFrom`), interacting with `resolveVote()` line 710, `resetVotingPeriod()` line 732, and `createClaim()` line 610 **Description** `_acceptClaim` finalizes all state and credits `pendingWithdrawals` **before** the single external call `poidhNft.transferFrom(address(this), bountyIssuer, claimId)` at line 822, with no `try/catch`. This ordering is reentrancy-safe (a revert here reverts the whole transaction atomically, leaving no partial state), but it is a **liveness** hazard: if that external call ever reverts — the NFT contract is paused, `bountyIssuer` is blacklisted at the NFT level, or escrow does not actually own `claimId` because `mintToEscrow` (called with no post-condition check in `createClaim`, line 610) misbehaved — the claim can never be accepted. For open bounties this becomes an unrecoverable lock: - `resolveVote` reverts on the failing `transferFrom`. - `resetVotingPeriod` reverts with `VoteWouldPass` (the tally still passes). - `cancelOpenBounty`, `withdrawFromOpenBounty`, and `joinOpenBounty` all revert with `VotingOngoing` via `_requireActiveBounty`, since `bountyCurrentVotingClaim` is still nonzero. Every contributor's ETH for that bounty is then frozen forever with no recovery path. Note this depends on the external, out-of-scope `PoidhClaimNFT` contract misbehaving — under standard ERC-721 semantics with an EOA recipient (issuers are always EOAs, enforced by the `tx.origin` gate), `transferFrom` does not revert, so this is a latent risk gated on the NFT contract's behavior rather than a currently-firing bug. **Proof of Concept** A vote passes on claim C. At resolution, `poidhNft.transferFrom` reverts for the escrowed `claimId` (pause, blacklist, or an ownership mismatch introduced by a misbehaving `mintToEscrow`). `resolveVote` → `_acceptClaim` → line 822 reverts → the whole call reverts. `resetVotingPeriod` also reverts (`VoteWouldPass`); every other active-bounty entrypoint reverts (`VotingOngoing`). No address can move the bounty out of this state. **Recommendation** Decouple ETH settlement from the NFT transfer so a reverting/blocked NFT transfer can never freeze contributor funds: ```solidity mapping(uint256 => address) public claimNftRecipient; // in _acceptClaim, replace the direct transfer: claimNftRecipient[claimId] = bountyIssuer; // record instead of transferring inline // separate, retryable, non-fund-blocking pull function: function claimEscrowedNft(uint256 claimId) external nonReentrant { address to = claimNftRecipient[claimId]; if (to == address(0)) revert ClaimNotFound(); claimNftRecipient[claimId] = address(0); poidhNft.transferFrom(address(this), to, claimId); } ``` Additionally verify the mint post-condition in `createClaim`, so a misbehaving `mintToEscrow` fails fast (recoverable, before any bounty state is touched) rather than surfacing later as a fund-freezing failure at accept time: ```diff poidhNft.mintToEscrow(claimId, uri); + if (poidhNft.ownerOf(claimId) != address(this)) revert InvalidPoidhNft(address(poidhNft)); ``` --- ## [5] `tx.origin == msg.sender` EOA gate excludes smart-contract wallets and is not a reliable guarantee on L2 **Severity**: Low **Origin**: [phase1: evm-audit-access-control / evm-audit-chain-specific / evm-audit-general — 4 convergent agents] **Location**: `createSoloBounty()` line 358, `createOpenBounty()` line 371 **Description** Both bounty-creation entrypoints gate on `msg.sender == tx.origin`, permanently excluding ERC-4337 smart accounts and multisigs (notably Coinbase Smart Wallet, which Base actively promotes for onboarding) from ever creating bounties. The stated design goal — preventing contract issuers from becoming NFT sinks — is already moot: `_acceptClaim` uses plain `transferFrom` (no `onERC721Received` callback required), confirmed safe for any recipient including contracts. Separately, on both Base (OP Stack deposit transactions) and Arbitrum (retryable tickets), L1→L2 messages can produce `msg.sender == tx.origin` at the L2 aliased address even when the true origin on L1 was a contract, so the gate is not a watertight "EOA only" guarantee on either chain — though the worst case there is a bounty/NFT ending up at an alias address rather than any third-party fund loss. **Recommendation** Remove the `tx.origin` gate; `transferFrom` already makes contract issuers safe to support without it. --- ## [6] `everHadExternalContributor` never reset — one transient join permanently forces the slower vote path **Severity**: Low **Origin**: **[both]** — phase-1 governance checklist; phase-2 economic-security agent **Location**: `joinOpenBounty()` line 460, `acceptClaim()` line 774 **Description** The flag is set on any external join and never cleared, even after that contributor fully withdraws. This correctly prevents a vote-bypass attack (a contributor cannot join-then-withdraw to re-open the fast direct-accept path for the issuer to abuse), but it also means a griefer can `joinOpenBounty` with `MIN_CONTRIBUTION` then immediately `withdrawFromOpenBounty`, permanently forcing the issuer onto the 2-day vote path for every future acceptance on that bounty, even though it reverted to issuer-only funding. **Recommendation** Track a live external-contributor count instead of a sticky boolean, and allow direct-accept when the count returns to zero. --- ## [7] Claim NFTs of rejected/cancelled claims are permanently stuck in escrow **Severity**: Low **Origin**: **[both]** — phase-1 ERC-721 checklist; phase-2 access-control and periphery agents **Location**: `createClaim()` line 610 (mint to escrow); only `_acceptClaim()` line 822 ever moves a claim NFT out **Description** Every `createClaim` mints an NFT into escrow. Only the single accepted claim's NFT is ever transferred out. Losing claims, and every claim filed against a bounty that is later cancelled, leave their NFTs permanently stuck — there is no burn, reclaim, or rescue function, and `receive()` reverts so nothing else can interact with the contract to recover them. No user is deprived of a token they personally held (claim NFTs mint directly to escrow), but this is a permanent, unrecoverable-asset design gap worth an explicit decision. **Recommendation** Confirm this is intended. If not, add a guarded reclaim/burn path for a claim's own issuer after their bounty is finalized against them or cancelled. --- ## [8] Constructor sentinel pre-fill loop is unbounded — large `_startClaimIndex` makes deployment infeasible **Severity**: Low **Origin**: [phase1: evm-audit-erc721] **Location**: `constructor()` lines 297-312 **Description** The constructor pushes one full `Claim` struct per reserved sentinel id in `[0, _startClaimIndex)`, to keep the invariant `claims.length == claimCounter`. If PoidhV3 is deployed to share an NFT contract with a prior version that already has many minted tokens, `_startClaimIndex` must be set above the highest existing token id to avoid `mintToEscrow` id collisions — but a large `_startClaimIndex` makes this constructor loop exceed the block gas limit, making deployment itself infeasible. **Recommendation** Track `_startClaimIndex` as a pure offset without materializing sentinel structs, or switch `claims` to a `mapping(uint256 => Claim)` so no pre-fill loop is needed at all. --- ## [9] Pagination view helpers zero-pad fixed-length-10 arrays **Severity**: Low **Origin**: **[both]** — phase-1 general/DoS checklists; phase-2 flow-gap and periphery agents **Location**: `getBounties()` lines 836-844, `getClaimsByBountyId()` lines 846-859, `getBountiesByUser()` lines 861-870, `getClaimsByUser()` lines 872-881 **Description** These helpers always allocate a length-10 array and pad unused trailing slots with default-valued structs (`issuer == address(0)`, `id == 0`), which a naive off-chain integrator could mistake for a real entry ("bounty 0"). No gas-DoS or out-of-bounds risk — the loops are correctly bounded and never underflow, confirmed by multiple independent agents. This is a pure integration-safety concern, not a fund-risk issue. **Recommendation** Return a right-sized array (as `getParticipantsPaged` already correctly does), or clearly document the zero-struct sentinel convention for indexer/frontend authors. --- ## [10] Fee rounds to zero below 40 wei; rounding remainder favors the claimer over the treasury **Severity**: Low / Info **Origin**: **[both]** — phase-1 precision-math checklist; phase-2 math-precision and numerical-gap agents **Location**: `_acceptClaim()` lines 797-798 **Description** `fee = (bountyAmount * 250) / 10_000` truncates to 0 whenever `bountyAmount < 40 wei` (Low — depends entirely on a deploy-time `MIN_BOUNTY_AMOUNT`/`MIN_CONTRIBUTION` unrealistically below that floor; the constructor only checks these are nonzero). Separately, the truncated remainder is always rounded into the claimer's payout rather than the treasury's fee (Info — sub-wei magnitude; `payout + fee == bountyAmount` always holds exactly, so no dust is ever stranded in the contract). The core arithmetic — multiplication before division, and the weighted-vote majority check's integer division — was independently verified sound by both phases with no off-by-one at any boundary. **Recommendation** If a floor matters economically, enforce `MIN_BOUNTY_AMOUNT >= 40` (or a realistic minimum well above dust) in the constructor. Optionally switch to ceiling-division fee math if strict protocol-favoring rounding is desired. --- ## [11] Aggregate `address(this).balance` solvency check is a fragile-but-currently-dead pattern **Severity**: Info **Origin**: [phase2 only] — independently raised and independently confirmed non-exploitable by 4 separate agents (invariant, asymmetry, execution-trace, flow-gap) **Location**: `_acceptClaim()` line 789 **Description** `if (bounty.amount > address(this).balance) revert InsufficientBalance();` compares a single bounty's escrowed amount against the whole contract's pooled balance (every other bounty's ETH plus all pending withdrawals combined), not a per-bounty solvency check. Every agent that examined this traced the full ETH-conservation invariant (`balance == Σ active bounty.amount + Σ pendingWithdrawals`) across every state transition (create/join/withdraw/cancel/refund/accept) and confirmed it holds everywhere in the current contract, so this branch can never legitimately fire today, and force-feeding the contract (`selfdestruct`/coinbase) only makes it pass more easily, never less. Recorded as a design note: this check would offer no real protection the moment a future code change broke the conservation invariant. **Recommendation** No action required for current correctness. If retained, treat it explicitly as a defense-in-depth assertion rather than a real per-bounty solvency guarantee. --- ## Confirmed Sound (verified across both phases, no finding) - Fee formula multiplies before dividing (no premature truncation); the weighted-vote majority check `yes > (no+yes)/2` is mathematically exact — provably equivalent to strict `yes > no` at every parity, with ties correctly failing. - Round-scoped vote-weight snapshotting (`voteWeightSnapshotRound`) correctly prevents stale-weight reuse across rounds and slot-reuse double-counting after a participant withdraws and their slot is recycled; `lastVotedRound` correctly blocks double voting within a round. - `resetVotingPeriod` cannot be used to discard a winning vote (guarded by `VoteWouldPass`); the voting window and resolution window never overlap, so there is no front-running window to lock in an outcome before a late voter. - Every `msg.sender == bounty.issuer` / participant-authority check is sound and unspoofable; `_acceptClaim` is `internal` and reachable only via its two sanctioned external entrypoints. - The pull-payment withdrawal pattern correctly isolates a reverting recipient from blocking any other user's flow; `receive()` reverting creates no denial-of-service on any legitimate path. - The `submitClaimForVote` snapshot loop (bounded at 150 participants) stays well under both Base's and Arbitrum's block gas limits (~7.3M gas worst case) and is issuer-only, so it cannot be forced to run by an attacker. - No inline assembly, no oracle dependence, no `block.number`/randomness usage, no hardcoded chain-specific addresses; all ETH transfers use `.call` rather than `.transfer`/`.send`, avoiding gas-stipend forwarding pitfalls. - `claimId`/`bountyId` monotonic counters guarantee uniqueness across the contract's lifetime; the reserved sentinel claim range is correctly rejected by downstream checks. - All state-changing entrypoints are `nonReentrant`; the claim NFT is moved with `transferFrom` (not `safeTransferFrom`), which was deliberately chosen and verified to prevent any `onERC721Received`-based reentrancy path. --- > ⚠️ This review was performed by AI audit agents (breadth-checklist pass + independent blind attacker pass, reconciled). AI analysis can never verify the complete absence of vulnerabilities and no guarantee of security is given. A team security review, a public bug bounty, and on-chain monitoring are strongly recommended before or alongside mainnet use, particularly given Finding 1's direct fund-theft path.