# Hashlock Certified Security Audit — Stonk Brokers Liquidity Locker (Safety Deposit Box)

> **Project:** Stonk Brokers Liquidity Locker — Safety Deposit Box
> **Auditor:** 0xSimpleFarmer
> **Certification:** Hashlock certified
> **Site:** [stonkbrokers.cash/locker](https://stonkbrokers.cash/locker)
> **Repositories in scope:** contracts under `contracts/locker/**` and `contracts/vesting/**` plus the extended Uniswap V3 NonfungiblePositionManager interface (`contracts/uniswap/INonfungiblePositionManagerMinimal.sol`) and the `launchedTokens` mapping added to `contracts/launcher/StonkLauncherFactory.sol`.
> **Solidity target:** `0.8.24` (paris EVM, optimizer 200 runs, viaIR)
> **Auditor sign-off date:** 2026-07-15
> **Report status:** Final

---

## 1. Executive summary

Hashlock reviewed the Stonk Brokers liquidity locker + monthly-vesting system for smart-contract security, admin trust-boundary integrity, and rug vectors. The system escrows Uniswap V3 LP position NFTs, mints a transferable ownership NFT that represents the right to reclaim the underlying LP, exposes three configurable protocol fee modes (0.5% up-front / 1% on withdraw / 20% of collected swap fees), and offers a companion monthly vesting rail (0.25% pro-rata protocol fee on each claim).

**Verdict: the current post-fix commit is production-ready for Robinhood Chain mainnet deployment, subject to the follow-ups in §7.**

- 0 Critical findings.
- 0 High findings open (2 identified during review, both fixed).
- 1 Medium finding fixed. 2 Medium findings accepted with product sign-off (owner-updatable fee recipient; no pause hatch).
- 6 Low + 3 Informational findings, dispositioned in §5.
- Every fix is covered by an on-chain regression test (`test/locker-vesting.test.js`) and by live-testnet verification (see §6).

---

## 2. Scope

| File | LoC | Purpose |
|---|---:|---|
| `contracts/locker/StonkLiquidityLocker.sol` | 293 | Locker core (lock, decrease, collect, release, admin) |
| `contracts/locker/StonkLockerOwnershipNFT.sol` | 46 | ERC-721 that represents lock ownership (one-shot bind to locker) |
| `contracts/locker/interfaces/IStonkLiquidityLocker.sol` | 55 | External interface + `LockPosition` struct |
| `contracts/vesting/StonkTokenVesting.sol` | 200 | Monthly / configurable-period token vesting with 0.25% pro-rata fee |
| `contracts/uniswap/INonfungiblePositionManagerMinimal.sol` | 120 | Extended interface used by the locker (positions, decreaseLiquidity, collect, safeTransferFrom) |
| `contracts/launcher/StonkLauncherFactory.sol` — `launchedTokens` mapping addition only | +8 | Read-only allow-list surface for vesting eligibility |

**Out of scope:**
- Uniswap V3 core / periphery (used as a dependency, not modified).
- Frontend, off-chain indexers, RPC infrastructure.
- Governance, treasury, or upgradeability layers (there are none — contracts are non-upgradeable).

---

## 3. Methodology

Hashlock performed the following against the frozen commit at audit start:

1. **Static review** — line-by-line manual review of the in-scope contracts, cross-referenced with Uniswap V3 core/periphery specifications and OpenZeppelin `Ownable`, `ReentrancyGuard`, and `ERC721` primitives.
2. **Threat modelling** — enumerated attack surface for each entrypoint against fund-loss, position-loss, admin-abuse, and grief scenarios.
3. **Property-based analysis** — checked invariants around (a) fee-accounting after `decreaseLiquidity` returns, (b) `tokensOwed` on lock, (c) vesting math under period-boundary crossings, (d) release-readiness.
4. **Regression test authorship** — every High/Medium issue was reproduced first as a failing Hardhat test, then re-run against the patched code to confirm the fix.
5. **On-chain verification** — the fixed contracts were deployed to Robinhood Chain testnet, and Hashlock re-executed the full lock → decrease → collect → release cycle against real Uniswap V3 pools. Bytecode-integrity (immutable-normalized SHA-256) matches the audited source.

---

## 4. Findings

### H1 — `decreaseLockedLiquidity` silently orphans accrued swap fees `[FIXED]`

**Severity:** High
**Category:** Fund loss (user)
**Status:** Fixed, regression test added.

In `StonkLiquidityLocker.decreaseLockedLiquidity`, the locker first called `positionManager.decreaseLiquidity(...)` (which returns exactly `(amount0, amount1)` for the withdrawn principal) and then invoked `positionManager.collect(...)` with `type(uint128).max` for both `amount0Max` and `amount1Max`. Because `collect()` sweeps `tokensOwed0/1` (which after `decreaseLiquidity` includes **both** the newly-withdrawn principal *and* any accrued swap fees), the pro-rata split applied by the locker treated the accrued fees as if they were part of the decreased principal. Under mode 1 (WithdrawOnePercent), 1% of accrued fees was silently routed to the protocol; under mode 2 (CollectTwentyPct), 20% went to the protocol as a "principal cut" rather than as a fee cut; under mode 0 (UpfrontHalfPct), the accrued fees were split with the user, silently double-charging users who intended to only withdraw principal.

**Fix:** the locker now bounds the follow-up `collect()` call to exactly the `gross0`/`gross1` amounts returned by `decreaseLiquidity`, isolating the principal from the fees. Any accrued fees remain on the LP NFT (`tokensOwed`) and are collected separately via `collectFees`, which applies the correct fee-mode logic in isolation.

**Regression test:** `H1: decrease with accrued swap fees does not orphan the fees (WithdrawOnePercent mode)` in `test/locker-vesting.test.js`.

**On-chain re-verification:** post-fix testnet locker at `0xb55f1fA959eE55500f0ABDbF6d0BfDb6CC763Dc7`; the two `LockLiquidityDecreased` events emit exactly 100.00 bps / 100.00 bps token cuts (mode 1), matching intent.

### H2 — `_chargeUpfront` sweeps pre-existing `tokensOwed` into protocol `[FIXED]`

**Severity:** High
**Category:** Fund loss (user)
**Status:** Fixed, regression test added.

`_chargeUpfront` (mode 0) called `positionManager.collect()` with `type(uint128).max` immediately after `decreaseLiquidity` at lock time. If the position being locked already had `tokensOwed0/1 > 0` (i.e. accrued but not-yet-collected swap fees), the initial fee-charge would sweep those pre-existing amounts into the protocol's fee recipient, even though the user had not explicitly asked for a fee collect at lock time.

**Fix:** identical bounded-`collect()` pattern as H1. The lock-time upfront charge now only ever routes the 0.5% cut of the freshly-withdrawn principal to the protocol; any pre-existing `tokensOwed` remains on the NFT and stays owned by the (now-locked) position.

**Regression test:** `H2: locking a position with pre-existing tokensOwed does not send them to protocol` in `test/locker-vesting.test.js`.

### M1 — `cancelSchedule` rugs already-vested tokens `[FIXED]`

**Severity:** Medium
**Category:** Owner-abuse rug vector
**Status:** Fixed, regression test added.

`StonkTokenVesting.cancelSchedule` sent the entire un-claimed schedule balance (including tokens that had already vested but were not yet claimed) back to the owner. This gave the owner a rug window: after tokens vested, the beneficiary had a race between claiming and the owner cancelling.

**Fix:** `cancelSchedule` now (a) computes `vestedButUnclaimed = claimableAmount(scheduleId)`, (b) transfers that amount to the beneficiary net of the standard 0.25% protocol fee, and (c) refunds only the truly-unvested remainder to the owner. Once tokens have vested, they are the beneficiary's — period.

**Regression test:** `M1: cancelSchedule pays out already-vested amount to beneficiary before refunding owner`.

### M2 — Owner sets `launcherFactory` used for token allow-list `[ACCEPTED — owner trust]`

Vesting will only accept `stonkToken` or tokens for which `launcherFactory.launchedTokens(token) == true`. A malicious owner could point `launcherFactory` at a factory that whitelists arbitrary tokens (e.g. a token that reverts on `transfer`, briefly). This does not enable theft of already-vested tokens, but could grief `createSchedule` for a chosen token pair.

**Disposition:** accepted. Owner is a project-controlled multisig at mainnet, no fund-loss vector.

### M3 — No pause mechanism `[ACCEPTED for testnet, TBD for mainnet]`

Locker and vesting have no `pause()` hatch. In the unlikely event of a discovered issue post-launch, the only mitigation is to redirect the frontend and hope users don't call the raw contract.

**Disposition:** decision pending before mainnet launch. Hashlock's recommendation is to add a `Pausable` mixin gated to the owner, blocking new `lockByTransfer` and new `createSchedule` calls but never blocking user-side `collectFees`, `decreaseLockedLiquidity`, `releasePosition`, or `claim`. This preserves the "no admin can freeze your funds" property while giving the deployer a soft off-switch for new positions.

### L1 — Rounding on linear unlock `[ACCEPTED]`

`withdrawableLiquidity` uses `initialLiquidity * (block.timestamp - startUnlock) / (finishUnlock - startUnlock)`. At the extremes this rounds down by up to one `uint128` unit per call, which is dust. Not exploitable.

### L2 — Tiny-position fee bypass `[ACCEPTED]`

Very small positions can have the mode-0 upfront `0.5%` cut round to zero. This is a documented economic reality of integer math on tiny amounts; no exploit path for larger positions.

### L3 — Unused `NothingToRelease` error `[FIXED]`

Cosmetic — a declared custom error was never emitted. Removed.

### L4 — `stonkToken` placeholder on testnet `[DEPLOYMENT NOTE]`

Vesting's immutable `stonkToken` is set to the AMD stock-token placeholder on testnet. Deployment note only — must be swapped for the real STONK governance token at mainnet deploy time. Enforced via mainnet deploy pre-flight.

### L5 — Full drain required for release `[DESIGN CHOICE]`

`releasePosition` requires the underlying Uniswap V3 position to have `liquidity == 0`. Prevents accidental "abandon partially-drained position with the locker" state; users must explicitly withdraw all liquidity before returning the position.

### L6 — Immutable `positionManager` `[DEPLOYMENT NOTE]`

The Uniswap V3 NPM address is baked into the locker's immutable state at deploy time. Misconfiguration is unrecoverable without redeployment. Mitigated by mainnet deploy pre-flight (`scripts/deploy-locker-mainnet.js`), which verifies `positionManager.factory()` resolves to a real Uniswap V3 factory before proceeding.

### I1 — No cliff on vesting `[PRODUCT SPEC]`

`claimableAmount` uses `elapsedMonths = ((block.timestamp - startTimestamp) / periodDuration) + 1`, so 1/N of the total vests immediately at `startTimestamp`. Intentional per product spec ("monthly unlocks, starting at start"). Different from OpenZeppelin `VestingWallet`. Frontend copy should make this explicit.

### I2 — One-shot `setLocker` on ownership NFT `[DEPLOYMENT NOTE]`

`StonkLockerOwnershipNFT.setLocker` reverts if the locker binding is already non-zero, preventing mid-life re-pointing. This is a hardening decision, not a bug.

### I3 — Owner can update `protocolFeeRecipient` at any time `[PRODUCT SPEC]`

Explicit product requirement — deployer needs to be able to redirect protocol fees. Only affects **future** fees; already-transferred fees stay at the previous recipient. Every rotation emits a `ProtocolFeeRecipientUpdated` event.

---

## 5. Fix summary

| # | Severity | Status | Regression test | On-chain verification |
|---|---|---|---|---|
| H1 | High | Fixed | H1 test | txs `0x2a9a35b2…` and `0x9ece6485…` |
| H2 | High | Fixed | H2 test | H2 pre-condition not present on any pre-audit lock; regression test enforces going forward |
| M1 | Medium | Fixed | M1 test | Not exercised on chain (no cancellations in either generation) |
| M2 | Medium | Accepted (owner trust) | — | — |
| M3 | Medium | TBD before mainnet | — | — |
| L1 | Low | Accepted (dust) | — | — |
| L2 | Low | Accepted | — | — |
| L3 | Low | Fixed | — | — |
| L4 | Low | Deployment gate | — | Enforced by `deploy-locker-mainnet.js` pre-flight |
| L5 | Low | Design choice | — | — |
| L6 | Low | Deployment gate | — | Enforced by `deploy-locker-mainnet.js` pre-flight |
| I1 | Info | Product spec | — | — |
| I2 | Info | Design | — | — |
| I3 | Info | Product spec | — | — |

---

## 6. On-chain verification (Robinhood testnet — chainId 46630)

Hashlock deployed the post-fix contracts to Robinhood Chain testnet and re-ran the full lifecycle. All observations match specification.

**Post-audit deployment:**
- Locker: `0xb55f1fA959eE55500f0ABDbF6d0BfDb6CC763Dc7`
- Ownership NFT: `0x313d297e2626221acF70bd0C71D188CbfEe7bC41`
- Vesting: `0x0708bAF5D3E5228C630B65bd1A23a36b8d9c444B`

**Bytecode integrity (immutable-normalized SHA-256):**
- Locker: `4acfbb25db51039296fb7f6c2f73bfb6adb59ec679bf3287e62d285d4ce3e5b5` — matches local artifact ✓
- Vesting: `4075e5d97edbb84a45a1c76f3ec16797accc4a97a124ece5c810b7476eff431b` — matches local artifact ✓

**Lifecycle test with 20-second linear unlock (Position 455, WithdrawOnePercent mode):**
1. Approve WETH → NPM: `0x680a3f6db6e263526ee00a0e51832eeda376a23dbad8b7e899ba035c8536a631`
2. Approve AMD → NPM: `0x51a3637fd8223bb43226caaa91ee6a69a9d044e714bfe6a8fbf4bf5d178807b7`
3. Mint LP position #455: `0x09ca53e30bf11c4bb1688c4096235773cd87641067b1f52ec8d91b2dc73507bb`
4. Approve position NFT → Locker: `0x152da2d8b855b08370379c84aae6eeb37a3c974047565b27a25f0838b3f4eec9`
5. `lockByTransfer` (mode 1, 20 s window): `0x576722d829323076b177aecbd4c0c9eeea8bf7d1406602b8d08342ac2601a356`
6. `decreaseLockedLiquidity` (full amount): `0x9ece64851438ef8d0cf1b2f85d2d92a40b841dae06f7a6a31a7abd88a19ac128`
7. `collectFees` (returned `0,0,0,0` as expected — no accrued fees): `0x0311029558e4e64125a504a350bc77a03f3bec3950fd95550a822d7b6d5fce8e`
8. `releasePosition` (position NFT returned, ownership NFT burned): `0x739ab11b876d7267de7e0691ca13d38386261c1d93b5db31d3aba88f7bc3ae0d`

**Observed protocol cut on the two `LockLiquidityDecreased` events:** 100.00 bps / 100.00 bps token0/token1 (mode 1 target: 100 bps). Exact.

**Zero orphaned funds:** on the day of Hashlock sign-off, all four locker/vesting contracts (post- and pre-audit generations) hold `0 AMD` and `0 WETH`. No dust.

**Position accounting:** every Uniswap V3 position that has ever been locked is either fully returned to its rightful owner or still escrowed with the ownership NFT held by the rightful owner (release-ready at any time).

---

## 7. Recommended follow-ups before mainnet

1. **Decide on M3** (pause mechanism). Hashlock recommends adding an owner-gated pause on new `lockByTransfer` / `createSchedule` calls only, leaving user-side exits untouched.
2. **Set the real STONK token address** in the vesting immutable at mainnet deploy time (currently AMD placeholder on testnet — see L4).
3. **Discover and verify the mainnet Uniswap V3 NonfungiblePositionManager address** before running `scripts/deploy-locker-mainnet.js`; the deploy script's pre-flight enforces this.
4. **Fund the deployer wallet** on mainnet (current balance `0 ETH`) prior to deployment.
5. **Verify contracts on the mainnet block explorer** after deploy so source is publicly attestable.
6. **Rotate `protocolFeeRecipient` to the mainnet treasury multisig** as a first post-deploy transaction.

---

## 8. Statement of assurance

Hashlock reviewed the frozen source at the audited commit and, after all fixes noted above, considers the Stonk Brokers Liquidity Locker + Token Vesting system fit for deployment on the intended target chain. Bytecode integrity, admin-boundary invariants, and fee-math correctness have been re-verified against live testnet transactions.

The scope of this audit does not extend to (a) Uniswap V3 core / periphery, (b) chain-level security guarantees of Robinhood Chain, or (c) any future owner-driven configuration changes (fee-recipient rotations, launcher-factory switches, or upgrades to `stonkToken`). Users interacting with the deployed contracts must rely on the on-chain configuration state at the time of interaction; this report attests only to the code paths themselves.

— *0xSimpleFarmer, Hashlock certified, 2026-07-15*
