{"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":"16c65a39-24f4-47d6-a9f5-2140c70791c6","kind":"audit","nodes":[{"acceptedSubmissionHash":"b566194232bd4eb73b52b131b811c752c5279f7ad0371689abdd72617496e594","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":"aef4f33036d826cde9d6b5838d08bb83c5acec254e079a48cd15339d11ee98a5","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":"1ea05c56c2c97dde0383d1363062d86ea2aafb721fc478765e5805aa0f6561cc","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"f043e45a3cacb42af55b2634d1608fbfc2887216e5878c77c641d3fe7589a71d","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":"1211e0c93506ef86dd8b5b522e3ce5504d855f59134438d7f746be1ad01ca6a2","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 the contracts in gimogo/NTTW as they are.","parentJobId":null,"planHash":"4c6a947153d7411f6d67ae48c59af45e6e569d768e69a563a145e9005c89c738","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"16c65a39-24f4-47d6-a9f5-2140c70791c6","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":"52591","feedbackHash":"7488508554fc3df305d3403f13abcf776fb00b3ee5fb5fe2d2fa8cd778afdc92","nodeKey":"audit_economics","submissionHash":"b566194232bd4eb73b52b131b811c752c5279f7ad0371689abdd72617496e594","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52161","feedbackHash":"a7d5533e189e3ace252080978cdfcb7bdedd133b4ff57f3ee61e71b17f8a41d4","nodeKey":"audit_flow","submissionHash":"aef4f33036d826cde9d6b5838d08bb83c5acec254e079a48cd15339d11ee98a5","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52680","feedbackHash":"49ca091cc12ae13a023992e5905e26bf09df13fa088b4f7ee7b4660591a5fc2c","nodeKey":"audit_judge","submissionHash":"1ea05c56c2c97dde0383d1363062d86ea2aafb721fc478765e5805aa0f6561cc","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51346","feedbackHash":"7c46c12e45ee1b9382e75537319df90356f6f5ccbed3c12bc38765fcf3b2db49","nodeKey":"audit_math","submissionHash":"f043e45a3cacb42af55b2634d1608fbfc2887216e5878c77c641d3fe7589a71d","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52175","feedbackHash":"207b39431814422eab52816cbe5342bae8c8acc3653a6309374b35bdf2e1d948","nodeKey":"audit_permissions","submissionHash":"1211e0c93506ef86dd8b5b522e3ce5504d855f59134438d7f746be1ad01ca6a2","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"19103ba41db883e77e5aec8336f9e737a3118ef1afc773ffe69014dd33c61cd7","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b4e34f9d3c724992","findings":[{"citation":"resolved","description":"Access x asymmetry seam: the reward settlement (`_processEpochReset`) is not a separate, winner-triggered action; it is executed by whoever happens to call `stampNote` first after the 7-day boundary, and it pays all 10 leaderboard addresses by push (`PAYMENT_TOKEN.transfer`) with a hard revert on any failure. Every subsequent `stampNote` re-enters the same branch at line 261 because `epochStartTime` is only refreshed when the whole reset succeeds. The contract is ownerless by design and `stampNote` is its only state-changing entry point, so there is no recovery path: no claim function, no skip, no admin. A winner cannot reject plain ERC-20 transfers themselves, but the payment token is an arbitrary immutable constructor parameter (README: 'Any standard ERC-20') and the README names Base Sepolia deployments with other tokens; any token that can refuse a transfer to one address (compliance blocklist like USDC/USDT, pausable token, token that returns false) turns a single blocked leaderboard address into a permanent denial of service for every other user and locks the whole `weeklyRewardPool` plus all future fees. The live NOTES token on chain 4663 (0x03b76ccf7cfeec5198c81c5844b88a78e68a6173) was checked by bytecode: it exposes only the nine standard ERC-20 selectors plus INITIAL_SUPPLY, no owner/pause/blocklist, so the live deployment is not exposed today; severity is medium rather than high for that reason. The hardhat suite's test 'reverts the whole stamp if a reward transfer fails' never actually constructs a failing transfer (its comment admits it), so this path is untested. Fix that preserves the design: keep the automatic payout attempt but make it non-blocking, e.g. wrap the transfer in try/catch (or a low-level call) and on failure credit `unclaimed[winner] += share` with a permissionless `claimReward()`; or switch fully to pull-based claims keyed by epoch and rank. Either way the epoch bump and board clear must not depend on 10 external transfers succeeding.","line":550,"path":"contracts/IMDRetroWall.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.20;\n\nimport \"forge-std/Test.sol\";\nimport {IMDRetroWall} from \"contracts/IMDRetroWall.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\n\n/// @dev A plain ERC-20 (no transfer hooks, 18 decimals, returns bool) with a\n///      USDC-style blocklist: transfers to/from a blocked address revert.\n///      Everything else is standard.\ncontract BlocklistToken {\n    string public name = \"Blocklist Notes\";\n    string public symbol = \"bNOTES\";\n    uint8 public decimals = 18;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    mapping(address => bool) public blocked;\n\n    event Transfer(address indexed from, address indexed to, uint256 value);\n    event Approval(address indexed owner, address indexed spender, uint256 value);\n\n    function setBlocked(address a, bool b) external {\n        blocked[a] = b;\n    }\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n        totalSupply += amount;\n        emit Transfer(address(0), to, amount);\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        emit Approval(msg.sender, spender, amount);\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        _move(msg.sender, to, amount);\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        uint256 allowed = allowance[from][msg.sender];\n        require(allowed >= amount, \"allowance\");\n        if (allowed != type(uint256).max) allowance[from][msg.sender] = allowed - amount;\n        _move(from, to, amount);\n        return true;\n    }\n\n    function _move(address from, address to, uint256 amount) internal {\n        require(!blocked[from] && !blocked[to], \"blocked\");\n        require(balanceOf[from] >= amount, \"balance\");\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        emit Transfer(from, to, amount);\n    }\n}\n\n/// @title One blocked winner permanently bricks stampNote\n/// @notice `_processEpochReset` pushes every winner's reward with a\n///         `transfer` that must succeed, and it runs at the top of every\n///         `stampNote` once the 7-day boundary has passed. If the payment\n///         token refuses a transfer to a single leaderboard address, every\n///         future stamp reverts with `RewardTransferFailed`, the epoch can\n///         never advance, and the wall (ownerless, no other write path) is\n///         dead forever. Expected: an unrelated user can still stamp after\n///         the boundary and the other winners / the pool are unaffected.\ncontract BlockedWinnerBricksWallTest is Test {\n    IMDRetroWall wall;\n    BlocklistToken token;\n\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address carol = makeAddr(\"carol\");\n\n    string constant CONTENT = \"gm\";\n    string constant COLOR = \"#FFF2A5\";\n\n    function setUp() public {\n        token = new BlocklistToken();\n        wall = new IMDRetroWall(IERC20(address(token)));\n        address[3] memory users = [alice, bob, carol];\n        for (uint256 i = 0; i < users.length; i++) {\n            token.mint(users[i], 1_000_000e18);\n            vm.prank(users[i]);\n            token.approve(address(wall), type(uint256).max);\n        }\n    }\n\n    function test_blockedWinnerDoesNotBrickTheWall() public {\n        // Epoch 1: alice is rank 1, bob rank 2. Pool = 2 * 500 = 1000 tokens.\n        vm.prank(alice);\n        wall.stampNote(CONTENT, COLOR, 1, 1, 0);\n        vm.prank(bob);\n        wall.stampNote(CONTENT, COLOR, 2, 2, 0);\n        assertEq(wall.currentTopMinters(0), alice);\n        assertEq(wall.weeklyRewardPool(), 1000e18);\n\n        // The token issuer blocks alice (sanctions-style). Nothing the wall or\n        // carol can do about it.\n        token.setBlocked(alice, true);\n\n        // Past the boundary: carol, who has nothing to do with alice, tries\n        // to stamp. On the current code this reverts with\n        // RewardTransferFailed(alice) and keeps reverting for every caller,\n        // forever: the wall is bricked.\n        vm.warp(block.timestamp + 7 days + 1);\n        vm.prank(carol);\n        wall.stampNote(CONTENT, COLOR, 3, 3, 0);\n\n        // Expected after a fix (pull-based claims or skip-and-credit): the\n        // stamp lands, the epoch advanced, and bob's 15% share is unaffected.\n        assertEq(wall.getTotalNotes(), 3, \"carol's note must be stamped\");\n        assertEq(wall.currentEpoch(), 2, \"epoch must advance\");\n        assertEq(token.balanceOf(bob), 1_000_000e18 - 1000e18 + 150e18, \"bob (rank 2) still gets 15%\");\n    }\n}","reproduction":"State: wall deployed with a standard 18-decimal ERC-20 that has a blocklist (mock in the proof; USDC-style). Alice stamps once (rank 1), Bob stamps once (rank 2); weeklyRewardPool = 1000e18. The token issuer blocks Alice. Warp 7 days + 1 s. Carol calls stampNote('gm', '#FFF2A5', 3, 3, 0). Expected: Carol's note is stamped, currentEpoch becomes 2, Bob receives 150e18 (15%), Alice's 300e18 is held or skipped. Actual: the call reverts inside _processEpochReset at the transfer to Alice (RewardTransferFailed / token revert), getTotalNotes stays 2, currentEpoch stays 1, and every future stampNote from any address reverts the same way. Run: forge test --match-path test/scratch/BlockedWinnerBricksWall.t.sol (fails on current code; passes on a copy patched to credit a failed share to an unclaimed mapping).","severity":"medium","snippet":"                        if (!PAYMENT_TOKEN.transfer(winner, share)) {\n                            revert RewardTransferFailed(winner);","title":"Epoch rollover pushes rewards to up to 10 winners inside stampNote; one failed transfer bricks the wall forever"},{"citation":"resolved","description":"Modify/settle asymmetry: the only caller of `_processEpochReset` is `stampNote`, which first requires the caller to pay TOKEN_FEE (1000e18, half of it burned). There is no permissionless `settleEpoch()`/`rollover()` view-cost entry point. Consequences: (1) once activity stops, the last epoch's winners are never paid and the pool (plus any rolled-over remainder) is locked in the contract forever, since the contract has no sweep and no admin; (2) a winner who wants their payout must themselves pay 1000 NOTES, of which 500 are burned, which is a net loss for every rank whose share of the pool is below 1000 NOTES (rank 10 at 4% needs a pool above 25,000 NOTES before self-triggering is rational); (3) `timeUntilEpochEnd()` reports 0 indefinitely while `currentEpoch` does not move, so the frontend shows an epoch that 'ended' but never settles. Fix preserving the design: add an external `settleEpoch()` that runs the same boundary check and `_processEpochReset()` without a stamp or fee (anyone, including winners, can call it); keep the lazy rollover inside `stampNote` as well.","line":261,"path":"contracts/IMDRetroWall.sol","reproduction":"State: fresh wall; user A stamps once in epoch 1 (pool = 500e18, A at rank 1). Nobody stamps again. Warp 30 days. Expected: A (or anyone) can settle epoch 1 and A receives 150e18 (30%). Actual: timeUntilEpochEnd() == 0, weeklyRewardPool() == 500e18, currentEpoch() == 1, and no function exists that moves the epoch or pays A unless someone first pays 1000e18 to stampNote. Shown by test_settlement_requires_paid_stamp in test/scratch/Explore.t.sol.","severity":"low","snippet":"        if (block.timestamp >= epochStartTime + EPOCH_DURATION) {","title":"Reward settlement can only be triggered by a paid stamp: winners of the last active epoch cannot collect without paying 1000 NOTES, and the final pool is stranded"},{"citation":"resolved","description":"The data URI is emitted with raw (non percent-encoded, non base64) JSON. Every token's JSON contains '#' twice: in the name ('IMD Note #<id>', line 371) and in the color trait whenever the documented '#RRGGBB' format is used (the frontend palette uses '#'). Per RFC 3986 and the WHATWG URL spec, '#' starts the fragment, so a consumer that parses the URI before decoding (browsers, `new URL()`, most marketplace/indexer fetchers, viem's data-URI handling) receives only `application/json,{\"name\":\"IMD Note ` as the payload, which is not valid JSON; a '%' in note content ('100% gm') is additionally percent-decoded into garbage. The hardhat test strips the prefix with string replace and never exercises a URL parser, so it passes. Expected (ERC-721 metadata convention, OpenZeppelin Base64 pattern): `data:application/json;base64,<base64(json)>` or a percent-encoded body. Fix: base64-encode the JSON (OpenZeppelin `Base64.encode`, already in the vendored library) and emit `data:application/json;base64,`.","line":423,"path":"contracts/IMDRetroWall.sol","reproduction":"Input: stampNote('100% gm', '#FFF2A5', 1, 1, 0) then tokenURI(0). Actual output begins `data:application/json,{\"name\":\"IMD Note #0\",...,{\"trait_type\":\"Color\",\"value\":\"#FFF2A5\"},{\"trait_type\":\"Content\",\"value\":\"100% gm\"},...`. Parsing it with Python urllib.parse.urlparse gives path `application/json,{\"name\":\"IMD Note ` and fragment `0\",\"description\":...`; json.loads of the path body raises 'Unterminated string'. Expected: a URI whose payload survives URL parsing and decodes to the full JSON. Shown by test_tokenURI_hash_in_color_breaks_url_parsing in test/scratch/Explore.t.sol (logs the URI).","severity":"low","snippet":"        return string(abi.encodePacked(\"data:application/json,\", json));","title":"tokenURI returns an unencoded data: URI; the '#' in every token name and color truncates the JSON for any RFC 3986 URL parser"},{"citation":"resolved","description":"NatSpec (line 126) says 'One epoch lasts exactly 7 days'. Actual: an epoch lasts 7 days plus the idle time until the next stamp, and the new epoch's end time is set by the stamper who happened to trigger the rollover, not by a fixed schedule (`epochStartTime += EPOCH_DURATION * k`). Multiple missed weeks collapse into a single rollover, so a leaderboard from week 1 can be paid in week 4 and the whole intervening pool accrual belongs to that single rollover. After the boundary the board is frozen (every stamp triggers the reset first), so this is not exploitable to change winners; it is a schedule/expectation mismatch for the frontend and for anyone timing their stamps to a calendar week. Fix if calendar alignment is wanted: advance `epochStartTime` by whole multiples of EPOCH_DURATION (and bump `currentEpoch` by the same count, or document that only one settlement happens).","line":541,"path":"contracts/IMDRetroWall.sol","reproduction":"State: deploy at T. Stamp at T+1 (epoch 1). No stamps until T+20 days. Stamp at T+20d: epoch 1 is settled 13 days late and epoch 2 now ends at T+27d instead of T+14d. Expected per NatSpec: epoch boundaries at T+7d, T+14d, T+21d. Actual: boundaries at T+7d and T+27d.","severity":"info","snippet":"        epochStartTime = block.timestamp;","title":"Epoch clock restarts at the triggering stamp's timestamp, so epochs are not fixed 7-day weeks and their schedule is chosen by whoever stamps first after a boundary"},{"citation":"resolved","description":"Entry-point map (Trail of Bits classification). Public, unrestricted, state-changing: `stampNote` (the only project-defined write) and the inherited ERC-721 `approve`, `setApprovalForAll`, `transferFrom`, `safeTransferFrom` (owner/operator-gated by OpenZeppelin 5). No `receive`/`fallback`, no initializer, no proxy, no owner, no role grants in the constructor; the constructor only pins `PAYMENT_TOKEN` and `epochStartTime`. Internal `_processEpochReset` and `_updateLeaderboard` are reachable only through `stampNote`, so there is no weaker-guarded writer of `weeklyRewardPool`, `currentTopMinters`, `currentEpoch` or `epochStartTime`. Reentrancy: the only callback is `_safeMint`'s `onERC721Received`, called after every effect and both fee pulls; a re-entering stamp is a fresh, fully consistent stamp. A 200-run fuzz over 60 random stamps across 14 users with one mid-sequence rollover (test/scratch/Explore.t.sol) confirmed the board stays sorted, duplicate-free, with no off-board user outranking a board member, and that contract balance >= weeklyRewardPool. Trust assumptions the requester should state: (1) PAYMENT_TOKEN is immutable and must be a non-hooked, non-fee, non-blocklisting ERC-20 that returns a bool; the live NOTES token (chain 4663, 0x03b76ccf7cfeec5198c81c5844b88a78e68a6173) satisfies this by bytecode (selectors: name, symbol, decimals, totalSupply, balanceOf, allowance, approve, transfer, transferFrom, INITIAL_SUPPLY; totalSupply 1e9 * 1e18; no owner()/paused()). (2) The sequencer controls block.timestamp and ordering around the boundary. (3) Economics are intentionally ordering-sensitive: ranks are decided by count with first-to-reach tie-break, so the last blocks before a boundary are a snipe window (a stamper who sees top count c and pool P profits by stamping c+1 times when 0.3*(P+500*(c+1)) > 1000*(c+1), i.e. P > ~2833*(c+1) NOTES, taking the 30% share from the sitting leader), and empty slots plus rolled-over remainders are a shared bounty that sybil addresses can fill (one stamp per address buys a 4%-15% slot). Tokens sent directly to the wall are unrecoverable since there is no sweep. None of these are permission bypasses; they are the documented ownerless design and are listed so they can be accepted explicitly.","line":253,"path":"contracts/IMDRetroWall.sol","reproduction":"Illustration of the snipe window: epoch with 20 honest stampers x 5 notes (pool 50,000 NOTES, top count 5). In the last block before the boundary an attacker stamps 6 times (cost 6,000 NOTES). Expected under 'fair weekly race': leader keeps 30% = 15,000. Actual: attacker ranks 1 and receives 0.3 * 53,000 = 15,900 (net +9,900); the previous leader drops to 15% = 7,950. Verified arithmetic only; no code defect.","severity":"info","snippet":"    function stampNote(","title":"Permission model inventory and trust assumptions (no defect): one unrestricted write entry point, immutable payment token, ordering-sensitive leaderboard economics"},{"citation":"resolved","description":"The README points users at 0x515F8f72B62e22c957D34Ba87b312133eFcAC939 on chain 4663 as the audited wall. Compiling contracts/IMDRetroWall.sol (current and previous revision) with solc 0.8.20, optimizer 200 runs, evmVersion paris (the README's settings) yields 15,127 bytes of runtime code after stripping CBOR metadata; the live runtime code is 15,147 bytes after the same stripping, and the two diverge from byte 590 onward even after masking the immutable token address. The live contract does answer TOKEN_FEE() = 1000e18, PAYMENT_TOKEN() = NOTES, currentEpoch() = 1, getTotalNotes() = 0, so it is the same economy, but the exact source could not be reproduced from this tree. This is outside the permission audit and may be explained by a different solc build (hardhat solc-js) or a local source edit; it is reported so the requester can verify the source on the explorer before pointing users at it. No defect in the repository code is claimed.","line":167,"path":"README.md","reproduction":"Steps: eth_getCode(0x515F8f72B62e22c957D34Ba87b312133eFcAC939) via https://rpc.mainnet.chain.robinhood.com -> 15,200 bytes. forge build --use 0.8.20 --optimize --optimizer-runs 200 --evm-version paris on contracts/IMDRetroWall.sol -> deployedBytecode 15,180 bytes. Expected: identical runtime code modulo metadata hash and the immutable PAYMENT_TOKEN word. Actual: 20-byte length difference and thousands of differing bytes.","severity":"info","snippet":"| **IMDRetroWall** (ERC-721 wall, fee = 1000 NOTES) | `0x515F8f72B62e22c957D34Ba87b312133eFcAC939` | deploy tx `0xe611b6eff706a1b2f0235e7c6177e1b5033ecff921dff7ef184b2eeed55958ed` |","title":"Live Robinhood Chain deployment bytecode does not match a build of the audited source with the documented compiler settings (unverified)"}],"hash":"1211e0c93506ef86dd8b5b522e3ce5504d855f59134438d7f746be1ad01ca6a2","nodeId":"9080e33d-920d-4cef-9452-6c5782911902","outcome":"completed","summary":"The review is complete. Findings are in `.imd-findings.json` at the repository root, the proof test is under `test/scratch/`, and no other files were touched (git reports a clean tree; the findings file and scratch directory are gitignored).\n\n**Scope covered.** The single contract `contracts/IMDRetroWall.sol` was audited against the Access Control, Trust Gap and Asymmetry guides first, then the Trail of Bits entry-point inventory and the general checklist. I also verified the live NOTES token on Robinhood Chain by bytecode and fuzzed the leaderboard and pool-backing invariants with Foundry.\n\n**Result: one medium, two low, three informational findings. No permission bypass exists.** The contract has exactly one unrestricted write entry point, `stampNote`, plus inherited ERC-721 transfers, no admin, no initializer, no fallback. The leaderboard sort, tie-break, and pool conservation held under a 200-run fuzz.\n\nFindings, most severe first:\n\n- **Medium, line 550:** epoch rollover pushes rewards to all ten winners inside `stampNote` and reverts on any failure. Because the rollover branch re-runs on every later stamp, one blocked winner address bricks the wall permanently with no recovery path. The live NOTES token has no blocklist or pause, so the current deployment is not exposed today, but the token is an arbitrary constructor parameter. A Foundry proof that fails now and passes with a credit-and-claim fix is attached.\n- **Low, line 261:** settlement is only reachable through a paid stamp. Once activity stops, the last pool is stranded, and a winner must burn 500 NOTES to collect.\n- **Low, line 423:** `tokenURI` emits raw JSON in a `data:` URI. The `#` in every token name and color truncates the payload for any standards-compliant URL parser.\n- **Info:** epoch clock drifts to the triggering stamper's timestamp; a trust-assumption and entry-point inventory with concrete snipe-window arithmetic; and the live deployment bytecode could not be reproduced from this source with the documented settings, left as unverified.\n\n**Not done or untestable.** No Slither or Mythril ran. I could not run the project's hardhat suite because no node modules are installed; its reward-failure test was reviewed by reading and does not exercise a failing transfer. The bytecode mismatch may be a compiler build difference rather than a source change, and the requester should check explorer verification before relying on it.","treeHash":null,"usage":{"cachedInputTokens":1413226,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":39674,"runtime":"claude","turns":30,"wallClockMs":571642}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d7e6877fd9e388bb","findings":[{"citation":"resolved","description":"_processEpochReset is reachable only from stampNote and pays all ten leaderboard addresses by push with PAYMENT_TOKEN.transfer, reverting on a false return (lines 550-552) and bubbling any revert from the token. epochStartTime and the board are only updated inside the same call, so if a single transfer fails, every later stampNote from any address re-enters the same branch (line 261) and fails again. The contract is ownerless with no claim, skip or sweep function, so there is no recovery: the wall is permanently unusable and the whole weeklyRewardPool plus every future fee is locked. The live NOTES token on Robinhood Chain (0x03b76ccf...6173) was checked over RPC and is a plain ERC-20 with no blocklist or pause, so the current deployment is not exposed today; severity is medium because the constructor accepts any ERC-20 (README: 'Any standard ERC-20'), the Base Sepolia path is not pinned to that token, and the common USDC/USDT-style blocklist or pausable behaviour turns one blocked winner into a permanent denial of service for everyone. The hardhat test named 'reverts the whole stamp if a reward transfer fails' never constructs a failing transfer, so this path has no coverage. Merged from audit_permissions and audit_economics. Fix that keeps the economics: credit a failed share to an unclaimed[winner] mapping (try/catch or low-level call) with a permissionless claimReward(), or move fully to pull-based claims; the epoch bump and board clear must not depend on ten external transfers succeeding.","line":550,"path":"contracts/IMDRetroWall.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.20;\n\nimport \"forge-std/Test.sol\";\nimport {IMDRetroWall} from \"contracts/IMDRetroWall.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\n\n/// @dev A plain ERC-20 (no transfer hooks, 18 decimals, returns bool) with a\n///      USDC-style blocklist: transfers to/from a blocked address revert.\n///      Everything else is standard.\ncontract BlocklistToken {\n    string public name = \"Blocklist Notes\";\n    string public symbol = \"bNOTES\";\n    uint8 public decimals = 18;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    mapping(address => bool) public blocked;\n\n    event Transfer(address indexed from, address indexed to, uint256 value);\n    event Approval(address indexed owner, address indexed spender, uint256 value);\n\n    function setBlocked(address a, bool b) external {\n        blocked[a] = b;\n    }\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n        totalSupply += amount;\n        emit Transfer(address(0), to, amount);\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        emit Approval(msg.sender, spender, amount);\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        _move(msg.sender, to, amount);\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        uint256 allowed = allowance[from][msg.sender];\n        require(allowed >= amount, \"allowance\");\n        if (allowed != type(uint256).max) allowance[from][msg.sender] = allowed - amount;\n        _move(from, to, amount);\n        return true;\n    }\n\n    function _move(address from, address to, uint256 amount) internal {\n        require(!blocked[from] && !blocked[to], \"blocked\");\n        require(balanceOf[from] >= amount, \"balance\");\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        emit Transfer(from, to, amount);\n    }\n}\n\n/// @title One blocked winner permanently bricks stampNote\n/// @notice `_processEpochReset` pushes every winner's reward with a\n///         `transfer` that must succeed, and it runs at the top of every\n///         `stampNote` once the 7-day boundary has passed. If the payment\n///         token refuses a transfer to a single leaderboard address, every\n///         future stamp reverts with `RewardTransferFailed`, the epoch can\n///         never advance, and the wall (ownerless, no other write path) is\n///         dead forever. Expected: an unrelated user can still stamp after\n///         the boundary and the other winners / the pool are unaffected.\ncontract BlockedWinnerBricksWallTest is Test {\n    IMDRetroWall wall;\n    BlocklistToken token;\n\n    address alice = makeAddr(\"alice\");\n    address bob = makeAddr(\"bob\");\n    address carol = makeAddr(\"carol\");\n\n    string constant CONTENT = \"gm\";\n    string constant COLOR = \"#FFF2A5\";\n\n    function setUp() public {\n        token = new BlocklistToken();\n        wall = new IMDRetroWall(IERC20(address(token)));\n        address[3] memory users = [alice, bob, carol];\n        for (uint256 i = 0; i < users.length; i++) {\n            token.mint(users[i], 1_000_000e18);\n            vm.prank(users[i]);\n            token.approve(address(wall), type(uint256).max);\n        }\n    }\n\n    function test_blockedWinnerDoesNotBrickTheWall() public {\n        // Epoch 1: alice is rank 1, bob rank 2. Pool = 2 * 500 = 1000 tokens.\n        vm.prank(alice);\n        wall.stampNote(CONTENT, COLOR, 1, 1, 0);\n        vm.prank(bob);\n        wall.stampNote(CONTENT, COLOR, 2, 2, 0);\n        assertEq(wall.currentTopMinters(0), alice);\n        assertEq(wall.weeklyRewardPool(), 1000e18);\n\n        // The token issuer blocks alice (sanctions-style). Nothing the wall or\n        // carol can do about it.\n        token.setBlocked(alice, true);\n\n        // Past the boundary: carol, who has nothing to do with alice, tries\n        // to stamp. On the current code this reverts with\n        // RewardTransferFailed(alice) and keeps reverting for every caller,\n        // forever: the wall is bricked.\n        vm.warp(block.timestamp + 7 days + 1);\n        vm.prank(carol);\n        wall.stampNote(CONTENT, COLOR, 3, 3, 0);\n\n        // Expected after a fix (pull-based claims or skip-and-credit): the\n        // stamp lands, the epoch advanced, and bob's 15% share is unaffected.\n        assertEq(wall.getTotalNotes(), 3, \"carol's note must be stamped\");\n        assertEq(wall.currentEpoch(), 2, \"epoch must advance\");\n        assertEq(token.balanceOf(bob), 1_000_000e18 - 1000e18 + 150e18, \"bob (rank 2) still gets 15%\");\n    }\n}","reproduction":"State: wall deployed with an 18-decimal ERC-20 whose transfer reverts for a blocked address (mock in the proof; USDC-style). alice stamps once (rank 1), bob stamps once (rank 2), weeklyRewardPool = 1000e18. The token issuer blocks alice. Warp 7 days + 1 s. carol calls stampNote('gm', '#FFF2A5', 3, 3, 0). Expected: carol's note is stamped, currentEpoch becomes 2, bob receives 150e18, alice's share is held or skipped. Actual: the call reverts inside _processEpochReset at the transfer to alice ('blocked'), getTotalNotes stays 2, currentEpoch stays 1, and every future stampNote from any caller reverts the same way. Reproduced: forge test --match-path test/scratch/BlockedWinnerBricksWall.t.sol fails on this code with 'blocked' and passes on a copy patched to credit the failed share to an unclaimed mapping.","severity":"medium","snippet":"                        if (!PAYMENT_TOKEN.transfer(winner, share)) {","title":"Reward payout is a push loop inside the only write entry point: one winner the token refuses to pay bricks stampNote forever"},{"citation":"resolved","description":"tokenURI concatenates 'data:application/json,' with raw JSON: no base64 and no percent-encoding. A non-base64 data URI is a URL (RFC 2397 / WHATWG URL): '#' starts the fragment and '%' begins a percent-escape. The header at line 371 hard-codes '\"name\":\"IMD Note #<id>\"' and the Color attribute carries the '#RRGGBB' string the frontend always sends, so every token's URI contains a raw '#' and standards-compliant consumers (browsers, fetch, new URL(), viem/ethers data-URI helpers, marketplace indexers) receive only '{\"name\":\"IMD Note ' as the body, which is not valid JSON. A '%' in note content ('100% gm') is additionally percent-decoded into different text. The on-chain self-describing metadata, the headline feature of the ERC-721, is therefore unrenderable for all tokens. The hardhat test strips the prefix with String.replace and never runs the URI through a URL parser, which is why it passes. Merged from audit_permissions, audit_economics, audit_flow and audit_math. Fix: emit 'data:application/json;base64,' + Base64.encode(bytes(json)) (OpenZeppelin Base64 is in the vendored v5 library), or percent-encode '#', '%', '\"', whitespace and non-ASCII bytes in the body.","line":423,"path":"contracts/IMDRetroWall.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.20;\n\nimport \"forge-std/Test.sol\";\nimport {IMDRetroWall} from \"contracts/IMDRetroWall.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\n\n/// Minimal standard ERC-20 (returns true, no hooks) so stampNote's fee pull works.\ncontract MockNotes {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n/// tokenURI returns a `data:application/json,` URI whose body is neither\n/// base64- nor percent-encoded. The fixed header `\"name\":\"IMD Note #<id>\"`\n/// and the `#RRGGBB` color put a raw `#` into every URI, which any RFC 3986 /\n/// WHATWG URL parser treats as the start of the fragment: the JSON body is\n/// truncated and the metadata is unrenderable. A raw `%` in note content is\n/// likewise mis-decoded. The test asserts the URI body is URL-safe (no raw\n/// `#`, every `%` followed by two hex digits), which holds for either fix\n/// (base64 body or percent-encoding) and fails on the code as it is.\ncontract TokenURIDataUriTest is Test {\n    IMDRetroWall wall;\n    MockNotes token;\n    address alice = address(0xA11CE);\n\n    function setUp() public {\n        token = new MockNotes();\n        wall = new IMDRetroWall(IERC20(address(token)));\n        token.mint(alice, 1_000_000e18);\n        vm.prank(alice);\n        token.approve(address(wall), type(uint256).max);\n    }\n\n    function _isHex(bytes1 c) internal pure returns (bool) {\n        return (c >= \"0\" && c <= \"9\") || (c >= \"a\" && c <= \"f\") || (c >= \"A\" && c <= \"F\");\n    }\n\n    function _assertUrlSafe(string memory uri) internal pure {\n        bytes memory b = bytes(uri);\n        bytes memory prefix = bytes(\"data:application/json\");\n        for (uint256 i = 0; i < prefix.length; i++) {\n            assertEq(b[i], prefix[i], \"unexpected scheme\");\n        }\n        for (uint256 i = prefix.length; i < b.length; i++) {\n            assertTrue(b[i] != \"#\", \"raw '#' in data URI body: URL parsers truncate here\");\n            if (b[i] == \"%\") {\n                assertTrue(i + 2 < b.length && _isHex(b[i + 1]) && _isHex(b[i + 2]), \"raw '%' in data URI body\");\n            }\n        }\n    }\n\n    function test_tokenURI_isUrlSafe_plainNote() public {\n        vm.prank(alice);\n        wall.stampNote(\"gm from the corkboard!\", \"#FFF2A5\", 1, 1, 0);\n        _assertUrlSafe(wall.tokenURI(0));\n    }\n\n    function test_tokenURI_isUrlSafe_percentInContent() public {\n        vm.prank(alice);\n        wall.stampNote(\"100% sure\", \"#FFF2A5\", 1, 1, 0);\n        _assertUrlSafe(wall.tokenURI(0));\n    }\n}","reproduction":"Input: stampNote('100% gm', '#FFF2A5', 1, 1, 0) then tokenURI(0). Actual return (forge log): data:application/json,{\"name\":\"IMD Note #0\",\"description\":...,{\"trait_type\":\"Color\",\"value\":\"#FFF2A5\"},{\"trait_type\":\"Content\",\"value\":\"100% gm\"},...}. Node 22: new URL(uri).pathname == 'application/json,{\"name\":\"IMD Note ', new URL(uri).hash starts with '#0%22,%22description', and (await fetch(uri)).text() returns exactly '{\"name\":\"IMD Note '; JSON.parse of it throws 'Unterminated string in JSON at position 18'. Expected: a URI whose body survives URL parsing and decodes to the full attributes JSON. Reproduced: forge test --match-path test/scratch/TokenURIDataUri.t.sol fails on this code with \"raw '#' in data URI body\" and passes on a copy that base64-encodes the body.","severity":"medium","snippet":"        return string(abi.encodePacked(\"data:application/json,\", json));","title":"tokenURI returns an unencoded data: URI; the raw '#' in every token name truncates the metadata for any URL-compliant consumer"},{"citation":"resolved","description":"_processEpochReset is private and its only caller is stampNote, which pulls TOKEN_FEE (1000e18, 500e18 of it burned) from the caller. There is no permissionless settleEpoch() and no claim function, so once stamping stops the final epoch's winners are never paid and the balance sits in the ownerless contract with no exit. A winner who wants their payout must pay 1000 NOTES (500 burned) to trigger it, which is a net loss for every rank whose share is below 1000 NOTES (rank 1 needs a pool above ~3333 NOTES, rank 10 above 25000), and the trigger's own 500 NOTES then seed the next pool which has the same problem. During the gap timeUntilEpochEnd() reports 0 while currentEpoch, the board and weeklyRewardPool still describe the finished epoch. The README promises distribution 'enforced entirely on-chain, no off-chain payout runner', but it actually needs a new paying customer. Merged from audit_permissions, audit_economics and audit_math. Fix that preserves the economics: add an external settleEpoch() that reverts before the boundary and otherwise calls _processEpochReset(), keeping the lazy rollover in stampNote.","line":261,"path":"contracts/IMDRetroWall.sol","reproduction":"State: fresh wall at T; alice stamps once in epoch 1 (pool = 500e18, alice at rank 1, owed 150e18). Nobody stamps again. Warp T + 30 days. Expected: alice (or anyone) can settle epoch 1 at gas cost and alice receives 150e18. Actual (test/scratch/Explore.t.sol::test_settlement_requires_paid_stamp): timeUntilEpochEnd() == 0, currentEpoch() == 1, weeklyRewardPool() == 500e18, alice's balance unchanged; no function in the ABI moves the epoch or pays alice without a 1000e18 stampNote. When alice self-triggers she pays 1000e18, receives 150e18 (net -850e18 for that call) and the pool becomes 850e18, again uncollectable without another paid stamp.","severity":"low","snippet":"        if (block.timestamp >= epochStartTime + EPOCH_DURATION) {","title":"Epoch settlement runs only inside a paid stampNote: a quiet wall never pays its last winners and the pool is stranded"},{"citation":"resolved","description":"The NatSpec promises the slice [offset, min(offset + limit, total)) and the code clamps end to total, but offset + limit is computed in checked arithmetic before the clamp. Any caller that passes a very large limit (the natural 'everything from offset' request, e.g. type(uint256).max) with a non-zero offset gets Panic(0x11) instead of the clamped slice, while offset == 0 works, so the behaviour is inconsistent across offsets. No funds are at risk; it is a public read-API defect that breaks third-party readers and indexers. Merged from audit_flow and audit_math. Fix: uint256 end = limit > total - offset ? total : offset + limit;","line":330,"path":"contracts/IMDRetroWall.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.20;\n\nimport \"forge-std/Test.sol\";\nimport {IMDRetroWall} from \"contracts/IMDRetroWall.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\n\ncontract MockNotes2 {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n/// getNotes documents \"Slice [offset, min(offset + limit, total))\" and clamps\n/// `end` to `total`, but computes `offset + limit` in checked arithmetic first,\n/// so a large `limit` (a natural \"give me everything from offset\" request)\n/// panics with 0x11 instead of returning the clamped slice.\ncontract GetNotesOverflowTest is Test {\n    IMDRetroWall wall;\n    MockNotes2 token;\n    address alice = address(0xA11CE);\n\n    function setUp() public {\n        token = new MockNotes2();\n        wall = new IMDRetroWall(IERC20(address(token)));\n        token.mint(alice, 1_000_000e18);\n        vm.startPrank(alice);\n        token.approve(address(wall), type(uint256).max);\n        wall.stampNote(\"one\", \"#FFF2A5\", 1, 1, 0);\n        wall.stampNote(\"two\", \"#FFF2A5\", 2, 2, 0);\n        wall.stampNote(\"three\", \"#FFF2A5\", 3, 3, 0);\n        vm.stopPrank();\n    }\n\n    function test_getNotes_largeLimitIsClamped() public view {\n        IMDRetroWall.Note[] memory page = wall.getNotes(1, type(uint256).max);\n        assertEq(page.length, 2);\n        assertEq(page[0].id, 1);\n        assertEq(page[1].id, 2);\n    }\n}","reproduction":"State: three notes stamped. getNotes(0, type(uint256).max) returns 3 notes. getNotes(1, type(uint256).max): expected a 2-element page [note 1, note 2]; actual revert with panic 0x11 (arithmetic overflow). Reproduced: forge test --match-path test/scratch/GetNotesOverflow.t.sol fails on this code with 'panic: arithmetic underflow or overflow (0x11)' and passes on a copy with the clamp computed before the addition.","severity":"low","snippet":"        uint256 end = offset + limit;","title":"getNotes panics on offset + limit overflow instead of clamping the page"},{"citation":"resolved","description":"Rank is decided purely by per-epoch stamp count per msg.sender, the board (getCurrentTopMinters) and the boundary (timeUntilEpochEnd) are public, ties favour the earlier arrival, and ten addresses fill every paying slot. There is no per-address cap, no minimum participation, no sybil cost beyond the fee and no randomisation, so an actor who waits until the last second of the epoch knows the exact count needed per sybil (max honest count + 1) to out-rank every honest entry and collect 30+15+15+8+8+8+4+4+4+4 = 100% of the pool: the 500-NOTES pool share of every honest stamp plus any rolled-over remainder from quieter weeks. Profit condition for a full-board capture is 500*H > 10000*(c+1), with H honest stamps and c the highest honest count; for rank 1 alone 150*H > 850*(c+1). This is the agreed by-count design rather than a permission bypass, so it is recorded as a low-severity economic risk the requester should accept explicitly or redesign; any change (pro-rata by stamps, per-address cap, minimum distinct minters, fixed schedule) alters the stated economics and is a scoped decision. Merged from audit_economics, audit_flow and the audit_permissions trust-assumption note.","line":464,"path":"contracts/IMDRetroWall.sol","reproduction":"State: 100 distinct honest users each stampNote once in epoch 1 (pool = 50_000e18, every honest count = 1). At epochStartTime + 7 days - 1 s an attacker with 10 fresh addresses stamps twice from each (20_000e18 spent; pool = 60_000e18; board = the 10 sybils with count 2). At epochStartTime + 7 days the attacker stamps once more (1_000e18), which runs _processEpochReset. Expected under 'reward for the top stampers of the week': honest participation is rewarded. Actual (test/scratch/Explore.t.sol::test_snipe_full_board, logs 'spent: 21000', 'received: 60000'): the 10 sybils receive 60_000e18 in total for 21_000e18 spent, net +39_000e18; every honest user's balance stays at initial - 1_000e18.","severity":"low","snippet":"            if (holder == address(0) || noteCountPerEpoch[currentEpoch][holder] < newCount) {","title":"Last-second sybil entries can take 100% of the weekly pool, including every honest stamper's contribution"},{"citation":"resolved","description":"_isValidHexColor accepts an alternate 8-byte '0xRRGGBB' form, but the stored string is used verbatim as a CSS color by the board (src/components/StickyNote.tsx line 94: background: note.color) and emitted verbatim in the tokenURI Color trait. '0xFFF2A5' is not a CSS color, so a note stamped with it renders with no paper background for every viewer, and marketplaces show a meaningless Color trait. The README and the Note.color comment (line 143) document '#RRGGBB' only, and the UI palette is not a guard because anyone can call the contract directly. The hardhat test 'accepts 0x-prefixed and uppercase hex colors' codifies the behaviour. From audit_flow. Fix: accept only the 7-byte '#RRGGBB' form, or normalise '0x' to '#' before storing.","line":683,"path":"contracts/IMDRetroWall.sol","reproduction":"Call stampNote('hi', '0xFFF2A5', 100, 100, 0) directly. Expected: InvalidColor revert, or a stored '#FFF2A5'. Actual (test/scratch/Explore.t.sol::test_0x_color_accepted): the call succeeds and getNote(0).color == '0xFFF2A5'; the board sets background: '0xFFF2A5', which CSS ignores, and tokenURI contains {\"trait_type\":\"Color\",\"value\":\"0xFFF2A5\"}.","severity":"low","snippet":"        } else if (b.length == 8 && b[0] == \"0\" && b[1] == \"x\") {","title":"stampNote accepts '0xRRGGBB' colors that the frontend and metadata cannot render"},{"citation":"resolved","description":"The composer gates the stamp button and the counter on JavaScript String.length (UTF-16 code units) against MAX_CONTENT_LENGTH = 280 (src/lib/board.ts line 19), but stampNote checks bytes(_content).length > 280 (UTF-8 bytes, contracts/IMDRetroWall.sol line 268). Non-ASCII characters are 2 to 4 bytes each, so a note the UI shows as e.g. 200/280 can be 400 bytes; the UI submits it (after the approve step) and the paid transaction reverts with ContentTooLong, so the user sees an opaque error after signing. The README advertises '280 characters max', which the contract does not implement. Merged from audit_flow and audit_math. Fix: count new TextEncoder().encode(trimmed).length in the UI and state the limit as bytes (or document the byte limit in the contract NatSpec and README).","line":96,"path":"src/components/NoteComposer.tsx","reproduction":"Input: 200 x 'é' (U+00E9). JS: 'é'.repeat(200).length == 200 <= 280, so overLimit is false, canStamp is true and the counter shows 200/280; TextEncoder gives 400 bytes. Contract: stampNote with that 400-byte content reverts ContentTooLong(400) (test/scratch/Explore.t.sol::test_utf8_bytes_vs_chars, expectRevert matched). Expected: the composer blocks or warns about anything the contract rejects. Same for 140 pushpin emoji: length 280, bytes 560.","severity":"low","snippet":"  const overLimit = trimmed.length > MAX_CONTENT_LENGTH;","title":"Composer enforces 280 UTF-16 code units while the contract enforces 280 UTF-8 bytes; valid-looking notes revert on-chain after signing"},{"citation":"resolved","description":"stampNote checks only the byte length of _content. _jsonEscape is documented as making 'any valid UTF-8 note' legal JSON, but nothing ensures the input is valid UTF-8: a lone 0xFF or an unterminated multi-byte sequence is stored, emitted in NoteCreated and copied byte-for-byte into the tokenURI JSON. JSON (RFC 8259) must be UTF-8, so that token's metadata fails to decode in strict decoders and the frontend's event decoding substitutes replacement characters. Only the author's own token is affected, which keeps this low. From audit_flow. Fix: validate UTF-8 on-chain (a byte walk over at most 280 bytes), or base64-encode the metadata and document content as opaque bytes.","line":268,"path":"contracts/IMDRetroWall.sol","reproduction":"Input: stampNote with content = the single byte 0xff and color '#FFF2A5'. Expected: revert (invalid UTF-8) or JSON-safe output. Actual (test/scratch/Explore.t.sol::test_invalid_utf8_accepted): the stamp succeeds and tokenURI(0) contains the raw 0xFF byte inside the Content value (forge prints it as a replacement character); decoding the body as UTF-8 JSON fails in strict decoders.","severity":"low","snippet":"        if (len > MAX_CONTENT_BYTES) revert ContentTooLong(len);","title":"Content is not validated as UTF-8, so invalid bytes produce unparseable on-chain JSON metadata"},{"citation":"resolved","description":"The contract imports five OpenZeppelin files, but package.json lists no @openzeppelin/contracts and pnpm-lock.yaml contains no openzeppelin entry (hardhat-toolbox does not pull it in). The documented 'npm install && npm run compile / npm test' path therefore fails at import resolution and the hardhat suite never runs from a clean checkout. The code also requires the v5 API (_update(address,uint256,address) and _increaseBalance(address,uint128) overrides, ERC721Enumerable hooks), yet no major version is pinned anywhere, so the library revision the live deployment was built with is unrecorded. Merged from audit_flow and audit_math. Fix: add \"@openzeppelin/contracts\": \"5.0.2\" (or the exact version used for the deployment) to devDependencies and regenerate the lockfile.","line":4,"path":"contracts/IMDRetroWall.sol","reproduction":"State: checkout at d063100. grep -c openzeppelin pnpm-lock.yaml prints 0 and package.json has no @openzeppelin entry, while contracts/IMDRetroWall.sol lines 4-8 import @openzeppelin/contracts/... . Expected: npm install provides the import and npm run compile builds artifacts. Actual: hardhat fails at import resolution (HH404/HH411 'library @openzeppelin/contracts ... is not installed'). For this review the contract was compiled only by supplying OpenZeppelin v5.0.2 from outside the repository.","severity":"low","snippet":"import {ERC721} from \"@openzeppelin/contracts/token/ERC721/ERC721.sol\";","title":"@openzeppelin/contracts is imported but not declared, so a fresh clone cannot compile or test the contract"},{"citation":"resolved","description":"NatSpec (line 126) says 'One epoch lasts exactly 7 days' and the README says 'Time is split into 7-day epochs', but the rollover sets epochStartTime to the triggering block's timestamp and bumps currentEpoch by exactly one regardless of how many weeks elapsed. An epoch therefore lasts 7 days plus the idle time until the next stamp, several missed weeks collapse into one settlement, and during the gap currentEpoch, getCurrentTopMinters, weeklyRewardPool and noteCountPerEpoch describe a finished epoch while timeUntilEpochEnd() returns 0. After the boundary the board is frozen (every stamp runs the reset first), so this cannot change winners; it is a documented-versus-actual mismatch for the frontend and for anyone timing stamps to a calendar week. Merged from audit_permissions, audit_economics and audit_flow. Fix if alignment is wanted: epochStartTime += EPOCH_DURATION * ((block.timestamp - epochStartTime) / EPOCH_DURATION) and advance currentEpoch by the same count, or document the lazy semantics.","line":541,"path":"contracts/IMDRetroWall.sol","reproduction":"State: deploy at T; alice stamps at T; nobody stamps until T + 20 days; alice stamps. Expected per NatSpec: boundaries at T+7d, T+14d, T+21d. Actual (test/scratch/Explore.t.sol::test_epoch_drift): currentEpoch() == 2, epochStartTime() == T + 20 days and timeUntilEpochEnd() == 7 days, so epoch 2 runs T+20d..T+27d; between T+7d and T+20d currentEpoch() == 1 while timeUntilEpochEnd() == 0.","severity":"info","snippet":"        epochStartTime = block.timestamp;","title":"Lazy rollover re-anchors the epoch to the triggering stamp, so epochs are not fixed 7-day weeks and view functions go stale after the boundary"},{"citation":"resolved","description":"Lines 238-241 document TOKEN_FEE_BURN_SHARE and TOKEN_FEE_POOL_SHARE as '50 tokens', left over from an earlier fee schedule. The constants at lines 107-115 are 500e18 each (1000-token fee), which matches the README, the frontend and the live deployment. Stale documentation on the only paid path misleads integrators who read the ABI docs. From audit_flow. Fix: change both to 500.","line":238,"path":"contracts/IMDRetroWall.sol","reproduction":"Read lines 238-241 against lines 107-115: TOKEN_FEE = 1000e18, TOKEN_FEE_BURN_SHARE = TOKEN_FEE / 2 = 500e18, TOKEN_FEE_POOL_SHARE = 500e18; the comment says 50 tokens for each. Expected: NatSpec matches the constants.","severity":"info","snippet":"     *           - `TOKEN_FEE_BURN_SHARE` (50 tokens) is pulled from","title":"stampNote NatSpec states the burn and pool shares are 50 tokens; the constants are 500"},{"citation":"resolved","description":"weeklyRewardPool is an internal counter incremented only by stampNote; payouts are computed from the counter and PAYMENT_TOKEN.balanceOf(address(this)) is never read. Any NOTES transferred straight to the contract (a user confusing 'send 1000 NOTES' with approving, an airdrop, a tip) is excluded from every distribution and, with no owner, no sweep and no donate()/syncPool() function, can never leave. Nothing else is harmed because payouts are bounded by the counter. This is a consequence of the intended ownerless design and is recorded as a trust assumption; merged from audit_economics and audit_flow. If unwanted: a permissionless syncPool() folding the surplus into weeklyRewardPool, or documenting that direct transfers are burned.","line":165,"path":"contracts/IMDRetroWall.sol","reproduction":"State: alice calls PAYMENT_TOKEN.transfer(wall, 100e18), then stamps once; warp 7 days; alice stamps again (rollover pays alice 150e18). Expected (naive user): the 100e18 is in the pool or recoverable. Actual (test/scratch/Explore.t.sol::test_direct_transfer_stranded): balanceOf(wall) - weeklyRewardPool() == 100e18 after the rollover and no function in the ABI can move it.","severity":"info","snippet":"    uint256 public weeklyRewardPool;","title":"Tokens sent directly to the wall are untracked and permanently stranded (ownerless design, no sweep)"},{"citation":"resolved","description":"The test's name claims coverage of the RewardTransferFailed path (contract lines 550-552), but its body (lines 598-609) only stamps alice and bob, warps, stamps carol with the always-succeeding mock, and asserts the epoch advanced; its own comment admits the failing case was not built. Deleting the revert check or making transfer return false would not fail this test. The TokenTransferFailed branches (lines 294-302) are likewise never reached through a false return, and _safeMint to a non-receiver contract, reentrancy from onERC721Received, a rollover whose trigger is itself a winner, and a settlement with no later stamp are untested. Merged from audit_economics and audit_flow. Fix: give the in-memory mock a blocked/returns-false flag and assert RewardTransferFailed with unchanged epoch, pool and board, plus the permanent failure on later stamps; or rename the test to what it checks.","line":593,"path":"test/IMDRetroWall.ts","reproduction":"Read test/IMDRetroWall.ts lines 593-610: no transfer is made to fail anywhere in the suite. Expected: a test with that name makes PAYMENT_TOKEN.transfer return false or revert and asserts RewardTransferFailed. Actual: the body performs a normal successful rollover and checks currentEpoch == epochBefore + 1.","severity":"info","snippet":"    it(\"reverts the whole stamp if a reward transfer fails\", async function () {","title":"Hardhat test 'reverts the whole stamp if a reward transfer fails' never induces a failing transfer; the revert and brick path is untested"},{"citation":"resolved","description":"Entry-point inventory: the only project-defined state-changing function is stampNote (unrestricted); inherited ERC-721 approve/setApprovalForAll/transferFrom/safeTransferFrom are owner/operator-gated by OpenZeppelin v5; there is no receive, fallback, initializer, proxy, owner or role. The constructor pins PAYMENT_TOKEN and epochStartTime and only rejects address(0). TOKEN_FEE = 1000e18 and every share constant assume an 18-decimal, non-hooked, non-fee, non-blocklisting ERC-20 that returns a bool, as the NatSpec at lines 100-102 says, but nothing enforces decimals() == 18 or the other properties. The live NOTES token (chain 4663, 0x03b76ccf...6173) returns decimals() == 18 over RPC and exposes only the standard ERC-20 selectors, so the pinned deployment satisfies the assumption; this is recorded for any redeploy. Merged from audit_math and the audit_permissions inventory; no permission bypass was found. Suggested guard: require IERC20Metadata(_paymentToken).decimals() == 18 in the constructor, or derive TOKEN_FEE from decimals().","line":226,"path":"contracts/IMDRetroWall.sol","reproduction":"State: deploy IMDRetroWall with a 6-decimal ERC-20. stampNote then requires an allowance and balance of 1000e18 base units = 10^15 whole tokens, so every stamp reverts on transferFrom for any realistic holder. Expected per NatSpec: the constructor refuses a token whose decimals() != 18. Actual: the constructor only checks address(0) (lines 222-226).","severity":"info","snippet":"        PAYMENT_TOKEN = _paymentToken;","title":"Trust assumptions of the ownerless design: PAYMENT_TOKEN is immutable and its decimals are not checked against the hardcoded 18-decimal fee"}],"hash":"1ea05c56c2c97dde0383d1363062d86ea2aafb721fc478765e5805aa0f6561cc","nodeId":"c8f39f2b-b09d-4320-9e11-b9311afe4124","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds 14 merged, reproduced findings; no repository files were changed beyond that file and the scratch tests.\n\n**Method.** The repo has no Foundry config and no vendored OpenZeppelin, so I compiled with OpenZeppelin v5.0.2 and forge-std fetched outside the tree and pointed at through environment variables (solc 0.8.20, paris, 200 runs). All three specialist proofs fail on the current code for the stated reason and pass on a patched copy I built outside the repo, so they are attached. Everything else I re-ran in my own scratch test or checked directly.\n\n**Kept, ranked, with duplicates merged across the four specialists:**\n- **Medium, line 550:** push payout inside the only write path bricks the wall forever if one winner transfer fails. Proof attached. The live NOTES token has no blocklist, so medium rather than high.\n- **Medium, line 423:** unencoded data URI; Node's URL parser and fetch truncate every token's metadata at the `#` in the name. Proof attached.\n- **Low, line 261:** settlement only runs inside a paid stamp, so a quiet wall never pays its last winners.\n- **Low, line 330:** getNotes panics on offset plus limit overflow. Proof attached.\n- **Low, line 464:** last-second sybil capture takes 100% of the pool (reproduced: 21,000 spent, 60,000 received). Rated low as an economic design risk, not a bypass.\n- **Low:** 0xRRGGBB colors accepted but unrenderable, composer counts UTF-16 units against a byte limit, invalid UTF-8 accepted into metadata, OpenZeppelin dependency undeclared.\n- **Info:** epoch drift after idle periods, stale 50-token NatSpec, stranded direct transfers, the misnamed hardhat test, and the immutable-token and decimals trust assumptions.\n\n**Dropped.** The live-bytecode-mismatch finding: my native-solc build also differs from the on-chain code, but one specialist reports a match with solc-js, the result depends on toolchain rather than this source, and it is not a code defect. The permission-inventory finding was folded into the trust-assumption entry since it reported no defect.\n\n**Severity adjustments.** The sybil-snipe and push-payout items were rated medium by one specialist and low by another; I settled on low and medium respectively with the reasoning stated in each description.","treeHash":null,"usage":{"cachedInputTokens":1474563,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":37074,"runtime":"claude","turns":26,"wallClockMs":432925}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ca075d17c94a854b","findings":[{"citation":"resolved","description":"tokenURI builds `data:application/json,` + raw JSON with no base64 and no percent-encoding (RFC 2397 requires the body to be URL-safe). The header at line 371 hard-codes `\"name\":\"IMD Note #<id>\"` and the Color attribute carries the validated `#RRGGBB` string, so EVERY tokenURI contains a raw `#`. Per RFC 3986 / the WHATWG URL standard `#` starts the fragment, so any standards-compliant consumer (browsers, viem/ethers helpers that use `new URL`, marketplace indexers) sees the body `{\"name\":\"IMD Note ` and fails to parse the JSON. A raw `%` in note content is additionally decoded as a percent-escape (`%41` becomes `A`, `%ff` becomes an invalid UTF-8 byte). The project's own test only strips the prefix with `String.replace` and never runs the URI through a URL parser, which is why it passes. Impact: the NFT's self-describing on-chain metadata, the headline feature of the ERC-721, is unrenderable for all tokens. Fix: emit `data:application/json;base64,` + Base64(json) (OpenZeppelin `Base64.encode`), or percent-encode `#`, `%`, `\"`, whitespace and all non-ASCII bytes in the body.","line":423,"path":"contracts/IMDRetroWall.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.20;\n\nimport \"forge-std/Test.sol\";\nimport {IMDRetroWall} from \"contracts/IMDRetroWall.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\n\n/// Minimal standard ERC-20 (returns true, no hooks) so stampNote's fee pull works.\ncontract MockNotes {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n/// tokenURI returns a `data:application/json,` URI whose body is neither\n/// base64- nor percent-encoded. The fixed header `\"name\":\"IMD Note #<id>\"`\n/// and the `#RRGGBB` color put a raw `#` into every URI, which any RFC 3986 /\n/// WHATWG URL parser treats as the start of the fragment: the JSON body is\n/// truncated and the metadata is unrenderable. A raw `%` in note content is\n/// likewise mis-decoded. The test asserts the URI body is URL-safe (no raw\n/// `#`, every `%` followed by two hex digits), which holds for either fix\n/// (base64 body or percent-encoding) and fails on the code as it is.\ncontract TokenURIDataUriTest is Test {\n    IMDRetroWall wall;\n    MockNotes token;\n    address alice = address(0xA11CE);\n\n    function setUp() public {\n        token = new MockNotes();\n        wall = new IMDRetroWall(IERC20(address(token)));\n        token.mint(alice, 1_000_000e18);\n        vm.prank(alice);\n        token.approve(address(wall), type(uint256).max);\n    }\n\n    function _isHex(bytes1 c) internal pure returns (bool) {\n        return (c >= \"0\" && c <= \"9\") || (c >= \"a\" && c <= \"f\") || (c >= \"A\" && c <= \"F\");\n    }\n\n    function _assertUrlSafe(string memory uri) internal pure {\n        bytes memory b = bytes(uri);\n        bytes memory prefix = bytes(\"data:application/json\");\n        for (uint256 i = 0; i < prefix.length; i++) {\n            assertEq(b[i], prefix[i], \"unexpected scheme\");\n        }\n        for (uint256 i = prefix.length; i < b.length; i++) {\n            assertTrue(b[i] != \"#\", \"raw '#' in data URI body: URL parsers truncate here\");\n            if (b[i] == \"%\") {\n                assertTrue(i + 2 < b.length && _isHex(b[i + 1]) && _isHex(b[i + 2]), \"raw '%' in data URI body\");\n            }\n        }\n    }\n\n    function test_tokenURI_isUrlSafe_plainNote() public {\n        vm.prank(alice);\n        wall.stampNote(\"gm from the corkboard!\", \"#FFF2A5\", 1, 1, 0);\n        _assertUrlSafe(wall.tokenURI(0));\n    }\n\n    function test_tokenURI_isUrlSafe_percentInContent() public {\n        vm.prank(alice);\n        wall.stampNote(\"100% sure\", \"#FFF2A5\", 1, 1, 0);\n        _assertUrlSafe(wall.tokenURI(0));\n    }\n}","reproduction":"Deploy with any ERC-20; alice calls stampNote(\"gm from the corkboard!\", \"#FFF2A5\", 1, 1, 0). tokenURI(0) returns `data:application/json,{\"name\":\"IMD Note #0\",...\"value\":\"#FFF2A5\"...}`. Node: `new URL(uri).pathname` is `application/json,{\"name\":\"IMD Note ` and `.hash` is `#0%22,...`; JSON.parse of the body throws. Expected: a URI whose body survives URL parsing (no raw `#`, `%` only as a valid escape). Actual: truncated at the `#` in the name. Foundry proof: test/scratch/TokenURIDataUri.t.sol fails with \"raw '#' in data URI body\".","severity":"medium","snippet":"        return string(abi.encodePacked(\"data:application/json,\", json));","title":"tokenURI returns an unencoded data URI; the fixed '#' in the name and color truncate every token's metadata"},{"citation":"resolved","description":"The natspec promises the slice `[offset, min(offset + limit, total))` and the code clamps `end` to `total`, but `offset + limit` is evaluated in checked arithmetic before the clamp. Any caller passing a large `limit` (the natural way to request \"everything from offset\", e.g. type(uint256).max) with a non-zero offset gets a Panic(0x11) rather than the clamped slice. No funds at risk; it is a view-contract-API defect that breaks third-party readers and indexers. Fix: `uint256 end = limit > total - offset ? total : offset + limit;`.","line":330,"path":"contracts/IMDRetroWall.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.20;\n\nimport \"forge-std/Test.sol\";\nimport {IMDRetroWall} from \"contracts/IMDRetroWall.sol\";\nimport {IERC20} from \"@openzeppelin/contracts/token/ERC20/IERC20.sol\";\n\ncontract MockNotes2 {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amount) external {\n        balanceOf[to] += amount;\n    }\n\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount;\n        return true;\n    }\n\n    function transfer(address to, uint256 amount) external returns (bool) {\n        balanceOf[msg.sender] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n/// getNotes documents \"Slice [offset, min(offset + limit, total))\" and clamps\n/// `end` to `total`, but computes `offset + limit` in checked arithmetic first,\n/// so a large `limit` (a natural \"give me everything from offset\" request)\n/// panics with 0x11 instead of returning the clamped slice.\ncontract GetNotesOverflowTest is Test {\n    IMDRetroWall wall;\n    MockNotes2 token;\n    address alice = address(0xA11CE);\n\n    function setUp() public {\n        token = new MockNotes2();\n        wall = new IMDRetroWall(IERC20(address(token)));\n        token.mint(alice, 1_000_000e18);\n        vm.startPrank(alice);\n        token.approve(address(wall), type(uint256).max);\n        wall.stampNote(\"one\", \"#FFF2A5\", 1, 1, 0);\n        wall.stampNote(\"two\", \"#FFF2A5\", 2, 2, 0);\n        wall.stampNote(\"three\", \"#FFF2A5\", 3, 3, 0);\n        vm.stopPrank();\n    }\n\n    function test_getNotes_largeLimitIsClamped() public view {\n        IMDRetroWall.Note[] memory page = wall.getNotes(1, type(uint256).max);\n        assertEq(page.length, 2);\n        assertEq(page[0].id, 1);\n        assertEq(page[1].id, 2);\n    }\n}","reproduction":"Stamp 3 notes. Call getNotes(1, type(uint256).max). Expected: 2 notes (ids 1 and 2). Actual: revert with panic 0x11 (arithmetic overflow). getNotes(0, type(uint256).max) returns all 3, so the behaviour is inconsistent across offsets. Foundry proof: test/scratch/GetNotesOverflow.t.sol.","severity":"low","snippet":"        uint256 end = offset + limit;","title":"getNotes panics on offset+limit overflow instead of clamping the page"},{"citation":"resolved","description":"The color validator accepts an alternate `0xRRGGBB` form, but the stored string is used verbatim as a CSS color by the frontend (src/components/StickyNote.tsx:94 `background: note.color`) and emitted verbatim in the tokenURI Color attribute. `0xFFF2A5` is not a CSS color, so a note stamped with it renders with no paper background on the public board for every viewer, and marketplaces show a meaningless Color trait. The README and the `Note.color` comment document `#RRGGBB` only. Anyone can call the contract directly, so the UI's palette restriction is not a guard. Fix: accept only the 7-byte `#RRGGBB` form, or normalise `0x` to `#` before storing.","line":683,"path":"contracts/IMDRetroWall.sol","reproduction":"Call stampNote(\"hi\", \"0xFFF2A5\", 100, 100, 0) directly (cast/etherscan). Expected: InvalidColor revert, or a stored `#FFF2A5`. Actual: tx succeeds; getNote(id).color == \"0xFFF2A5\"; the board renders the note transparent and tokenURI contains {\"trait_type\":\"Color\",\"value\":\"0xFFF2A5\"}. The project's own test 'accepts 0x-prefixed and uppercase hex colors' codifies this.","severity":"low","snippet":"        } else if (b.length == 8 && b[0] == \"0\" && b[1] == \"x\") {","title":"stampNote accepts '0xRRGGBB' colors that the frontend and metadata cannot render"},{"citation":"resolved","description":"The composer gates the stamp button on JavaScript string length (UTF-16 code units) against MAX_CONTENT_LENGTH = 280, but stampNote checks `bytes(_content).length > 280` (UTF-8 bytes). Any non-ASCII content is 2-4 bytes per character, so a note the UI shows as e.g. `200/280` can be 400 bytes and the paid transaction reverts with ContentTooLong after the user has signed it (and after the approve step). The README also advertises '280 characters'. Fix: count `new TextEncoder().encode(trimmed).length` in the UI and state the byte limit; alternatively document the byte limit in the contract natspec and README.","line":96,"path":"src/components/NoteComposer.tsx","reproduction":"Type 100 pushpin emoji (U+1F4CC) into the composer: `trimmed.length` is 200 so the counter shows 200/280 and the button is enabled; the content is 400 UTF-8 bytes. Expected: UI blocks or warns. Actual: stampNote reverts ContentTooLong(400) and the user sees a raw error after signing. Same for 200 'é' characters (200 code units, 400 bytes).","severity":"low","snippet":"  const overLimit = trimmed.length > MAX_CONTENT_LENGTH;","title":"Frontend enforces 280 UTF-16 code units while the contract enforces 280 UTF-8 bytes; valid-looking notes revert on-chain"},{"citation":"resolved","description":"stampNote only checks the byte length. `_jsonEscape` is documented as making 'any valid UTF-8 note' legal JSON, but nothing ensures the input is valid UTF-8: a lone 0xFF or an unterminated multi-byte sequence is stored, emitted in NoteCreated and copied into the tokenURI JSON. JSON (RFC 8259) must be UTF-8, so the resulting metadata fails to decode and the frontend's event decoding (viem decodes `string` as UTF-8) substitutes replacement characters. Only the note's own author is affected on the board, but the NFT metadata is permanently broken for that token. Fix: validate UTF-8 on-chain (a small byte-walk over at most 280 bytes), or base64-encode the metadata and document that content is opaque bytes.","line":268,"path":"contracts/IMDRetroWall.sol","reproduction":"Call stampNote with calldata content = 0xff (1 byte) and color \"#FFF2A5\". Expected: revert (invalid UTF-8) or JSON-safe output. Actual: stamp succeeds; tokenURI(id) contains the raw byte 0xFF inside the Content value; decoding the body as UTF-8 JSON fails (Node: `JSON.parse(Buffer.from(body,'latin1').toString('utf8'))` yields a replacement char, strict decoders throw).","severity":"low","snippet":"        if (len > MAX_CONTENT_BYTES) revert ContentTooLong(len);","title":"Content is not validated as UTF-8, so invalid bytes produce unparseable on-chain JSON metadata"},{"citation":"resolved","description":"The contract imports OpenZeppelin, but package.json lists no `@openzeppelin/contracts` and pnpm-lock.yaml contains no openzeppelin entry (hardhat-toolbox does not pull it in). `npm install && npm run compile` on a clean checkout fails with HH404 for this import, and `npm test` cannot run. The code also depends on the OpenZeppelin v5 API (`_update`, `_increaseBalance` overrides and `ERC721Enumerable` hooks) and would not compile against v4, yet no major version is pinned anywhere, so the artifact is not reproducible from the repository alone. Fix: add `\"@openzeppelin/contracts\": \"^5.0.2\"` (compiling this source against v5.0.2 with solc 0.8.20/paris/200 runs reproduces the live Robinhood Chain bytecode exactly, apart from the inlined immutable token address) to devDependencies and regenerate the lockfile.","line":4,"path":"contracts/IMDRetroWall.sol","reproduction":"git clone; npm install; npm run compile. Expected: artifacts compiled. Actual: `Error HH404: File @openzeppelin/contracts/token/ERC721/ERC721.sol, imported from contracts/IMDRetroWall.sol, not found.` (grep -c openzeppelin pnpm-lock.yaml == 0).","severity":"low","snippet":"import {ERC721} from \"@openzeppelin/contracts/token/ERC721/ERC721.sol\";","title":"@openzeppelin/contracts is not a declared dependency, so a fresh clone cannot compile or test the contract"},{"citation":"resolved","description":"Lines 238-241 document `TOKEN_FEE_BURN_SHARE (50 tokens)` and `TOKEN_FEE_POOL_SHARE (50 tokens)`, left over from an earlier fee schedule. The constants at lines 111-115 are 500e18 each (1000-token fee), which matches the README, the frontend and the live deployment. Stale natspec on the only payable path misleads integrators who read the ABI docs. Fix: change both to 500.","line":238,"path":"contracts/IMDRetroWall.sol","reproduction":"Read lines 238-241 versus 107-115: TOKEN_FEE = 1000e18, TOKEN_FEE_BURN_SHARE = 500e18. Live wall at 0x515F...C939 returns TOKEN_FEE_BURN_SHARE() = 500e18.","severity":"info","snippet":"     *           - `TOKEN_FEE_BURN_SHARE` (50 tokens) is pulled from","title":"stampNote natspec states the burn and pool shares are 50 tokens; the constants are 500"},{"citation":"resolved","description":"The README states 'Time is split into 7-day epochs', but the rollover sets epochStartTime to the triggering block's timestamp and bumps currentEpoch by exactly one, regardless of how many weeks elapsed. After an idle stretch the epoch number undercounts and every subsequent boundary shifts. During the gap, timeUntilEpochEnd() reports 0 while currentEpoch and the leaderboard still show the old epoch, so off-chain displays of 'this week's leaders' diverge from what the contract will actually pay. This is a documented-vs-actual mismatch, not a fund-safety issue; the pool is still settled correctly. If weekly alignment is wanted: `epochStartTime += ((block.timestamp - epochStartTime) / EPOCH_DURATION) * EPOCH_DURATION` and advance currentEpoch by the same multiple (or document the drift).","line":541,"path":"contracts/IMDRetroWall.sol","reproduction":"Deploy at T. No stamps until T + 20 days; then stampNote. Expected under the README: epoch 3 or 4 in progress, boundaries at T+7d, T+14d, T+21d. Actual: currentEpoch == 2 and epochStartTime == T+20d, so epoch 2 runs T+20d..T+27d. Between T+7d and T+20d timeUntilEpochEnd() == 0 while currentEpoch == 1.","severity":"info","snippet":"        epochStartTime = block.timestamp;","title":"Lazy rollover re-anchors the epoch to the triggering stamp, so epochs are not 7-day aligned and can be much longer than a week"},{"citation":"resolved","description":"Economic trust assumption, recorded as requested by the review guide; it is the agreed design, not a code defect. Leaderboard rank is keyed by msg.sender with no identity cost beyond the stamp fee, and undistributed shares roll over without bound. Because each stamp returns 500 of its 1000 NOTES to the pool, an actor who controls the whole board gets 100% of the pool back, i.e. 50% of their own fees plus everything carried over from previous epochs. Whenever the carried-over balance exceeds the cost of out-stamping organic users, filling the board is strictly profitable, and the 'top 10 stampers of the week' prize degenerates into a race to self-fund. Mitigation options (all design changes): cap carry-over, burn or time-lock undistributed shares, or weight shares by distinct-day activity.","line":172,"path":"contracts/IMDRetroWall.sol","reproduction":"Epoch 1: Alice alone stamps 20 notes (pool 10,000 NOTES). First stamp of epoch 2 pays Alice 3,000 and rolls 7,000 over. Epoch 2: Bob stamps once from each of 10 fresh addresses (cost 10,000: 5,000 burned, 5,000 to pool -> pool 12,000) and nobody else stamps. First stamp of epoch 3 pays Bob's 10 addresses 12,000 NOTES in total for a 10,000 outlay: +2,000 net, funded by Alice's rolled-over share.","severity":"info","snippet":"    address[10] public currentTopMinters;","title":"Ranking is per address, so one actor can fill all ten slots and capture the rolled-over pool"},{"citation":"resolved","description":"The test's name claims coverage of the `RewardTransferFailed` path (contract lines 550-552), but its body only runs a successful rollover with the happy-path mock and asserts the epoch advanced. Deleting the `if (!PAYMENT_TOKEN.transfer(...)) revert` check, or making `transfer` return false, would not fail this test. The `TokenTransferFailed` branches (lines 294-302) are likewise only reached through mock-token reverts, never through a `false` return, and `_safeMint` to a contract without onERC721Received, reentrancy from onERC721Received, and a rollover whose trigger is itself a winner are untested. Fix: add a mock token whose `transfer`/`transferFrom` return false for a chosen recipient and assert `RewardTransferFailed` / `TokenTransferFailed` with unchanged epoch, pool and board.","line":593,"path":"test/IMDRetroWall.ts","reproduction":"Replace the two `if (!PAYMENT_TOKEN.transfer(winner, share)) revert RewardTransferFailed(winner);` lines with a bare `PAYMENT_TOKEN.transfer(winner, share);` and run `npm test`: the suite, including this test, still passes. Expected: at least one test fails.","severity":"info","snippet":"    it(\"reverts the whole stamp if a reward transfer fails\", async function () {","title":"Test 'reverts the whole stamp if a reward transfer fails' never induces a failing transfer and would pass if the revert were removed"},{"citation":"resolved","description":"Intentional consequence of the ownerless design, documented as a trust assumption. The pool is an internal counter incremented by 500e18 per stamp; the contract's actual NOTES balance is never read. NOTES (or any other ERC-20) transferred straight to the contract address, e.g. by a user who confuses 'send 1000 NOTES to the wall' with approving it, are permanently stranded: there is no sweep and no admin, and no distribution path reads the balance. Nothing else is harmed because payouts are bounded by the counter. If this is unwanted, distributing `PAYMENT_TOKEN.balanceOf(address(this))` at rollover (instead of the counter) would fold such transfers into the next epoch's rewards.","line":165,"path":"contracts/IMDRetroWall.sol","reproduction":"Alice calls NOTES.transfer(wall, 1000e18). Expected (naive user): the transfer funds a stamp or the pool. Actual: weeklyRewardPool unchanged, no note minted, and the 1000 NOTES can never leave the contract; after a rollover the contract balance stays 1000e18 above the pool counter forever.","severity":"info","snippet":"    uint256 public weeklyRewardPool;","title":"No recovery path for tokens sent directly to the wall; weeklyRewardPool tracks only fee accruals"}],"hash":"aef4f33036d826cde9d6b5838d08bb83c5acec254e079a48cd15339d11ee98a5","nodeId":"54328101-0262-4ae3-9756-1c431ff169f7","outcome":"completed","summary":"The review is complete and the findings file is in place. Summary of what I did and found:\n\n**Scope covered.** The single contract `contracts/IMDRetroWall.sol` was traced end to end along the three named guides: execution flow of `stampNote`, the lazy epoch rollover and the bounded insertion-sort leaderboard, the helper and encoder periphery, and the implicit assumptions behind each. I confirmed the live Robinhood Chain deployment is byte-identical to this source apart from the inlined immutable token address, and that the live NOTES token exposes only the ten standard ERC-20 selectors, which closes the reward-transfer-DoS and fee-on-transfer leads. The core economics are sound: checks-effects-interactions holds on every write path, the rank shares sum to exactly 100%, the leaderboard ordering and tie-breaker are correct under every sequence I traced, and the `_safeMint` callback reenters only after all state and transfers are settled.\n\n**Findings written to `.imd-findings.json`** (11 total, no critical or high):\n\n- **Medium:** `tokenURI` emits an unencoded `data:application/json,` URI. The fixed `IMD Note #<id>` name and the `#RRGGBB` color put a raw `#` in every token's metadata, so URL parsers truncate the JSON. Proof test attached.\n- **Low:** `getNotes` panics on `offset + limit` overflow instead of clamping. Proof test attached.\n- **Low:** `0xRRGGBB` colors are accepted but unrenderable by the frontend and metadata.\n- **Low:** the composer counts UTF-16 units while the contract counts UTF-8 bytes, so emoji-heavy notes pass the UI and revert on-chain.\n- **Low:** content is not validated as UTF-8, so a 0xFF byte lands in the metadata JSON (verified with a probe).\n- **Low:** `@openzeppelin/contracts` is not a declared dependency, so a fresh clone cannot compile.\n- **Info:** stale \"50 tokens\" natspec, epoch drift under lazy rollover, Sybil capture of the rolled-over pool as a trust assumption, a misnamed test that never exercises a failing reward transfer, and stranded direct token transfers.\n\n**Verification.** Both proof tests under `test/scratch/` were run with Foundry against the current code and fail for the stated reasons. Every quoted snippet was checked against the tree. No repository files other than `.imd-findings.json` and `test/scratch/` were created or changed, and the build dependencies were installed outside the repository.","treeHash":null,"usage":{"cachedInputTokens":1470492,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":43331,"runtime":"claude","turns":32,"wallClockMs":600266}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"5061b3ff85c1f0c1","findings":[{"citation":"resolved","description":"Rank is decided purely by per-epoch stamp count, the board (`getCurrentTopMinters`) and the boundary (`timeUntilEpochEnd`) are public, and ten addresses are enough to occupy every paying slot. Because the pool is paid to the top 10 by count with no cap per address, no minimum participation, no sybil resistance and no randomisation, an actor who waits until the last second of the epoch knows exactly how many stamps per sybil are needed to out-rank every honest entry (max honest count + 1, since ties favour earlier arrivals). The sybil set then collects 30+15+15+8+8+8+4+4+4+4 = 100% of the pool, i.e. all 500-token pool shares paid by honest stampers plus any rolled-over remainder from quiet weeks. Profit condition for a full-board capture: 500*H - 10_000*(c+1) > 0, i.e. H > 10*(c+1), where H is the number of honest stamps and c the highest honest count. For rank 1 alone: 150*H > 850*(c+1). Honest users who stamped early for position receive nothing; the 'weekly reward for top stampers' is in practice a refund mechanism for whoever is last and best capitalised. The frontend exposes the board live, so this needs no indexer. Fix options (design decision for the requester): pay by a share of stamps instead of rank (pro-rata on noteCountPerEpoch), cap any single address's share, require a minimum number of distinct minters before the pool pays out, or commit the boundary to a fixed schedule so last-second entries can be sandwiched by other participants. Any change alters the stated economics and should be a scoped decision.","line":464,"path":"contracts/IMDRetroWall.sol","reproduction":"State: 100 distinct honest users each call stampNote once in epoch 1 (pool = 50_000e18, every honest count = 1). At epochStartTime + 7 days - 1 s an attacker with 10 fresh addresses calls stampNote twice from each (20 stamps, 20_000e18 spent; pool = 60_000e18; board = the 10 sybils, each count 2 > 1). At epochStartTime + 7 days the attacker calls stampNote once more (1_000e18) which runs _processEpochReset. Expected (README: reward for the top stampers of the week): honest participation is rewarded. Actual: the 10 sybils receive 60_000e18 in total (30/15/15/8/8/8/4/4/4/4 % of 60_000e18); attacker spent 21_000e18, net +39_000e18; all 100 honest users' balances stay at initial - 1_000e18. Verified with test/scratch/Econ.t.sol::test_snipe_full_board_captures_entire_pool (logs: pool 60000, spent 21000, received 60000, net 39000). Break-even for this shape is H = 20 honest single stamps; every rolled-over token from previous epochs is pure extra profit.","severity":"medium","snippet":"            if (holder == address(0) || noteCountPerEpoch[currentEpoch][holder] < newCount) {","title":"Epoch-end leaderboard sniping: a sybil set captures 100% of the weekly pool, including every honest stamper's contribution"},{"citation":"resolved","description":"`_processEpochReset` is private and reachable only from `stampNote`, which pulls 1000e18 tokens (500e18 burned) before anything else. The README promises distribution 'enforced entirely on-chain, no admin key, no off-chain payout runner', but a payout actually requires a new paying customer after the boundary. When activity stops (or is seasonal), the final epoch's winners are never paid and the balance sits in the contract with no other exit: there is no `settleEpoch()`, no claim function and no owner. A winner who wants their reward must spend 1000e18 to get at most 30% of the pool, and 70% of a single-entrant pool rolls into the next epoch, where the same problem recurs. The trigger stamper also pays the settlement gas for up to ten ERC-20 transfers (measured ~544k gas vs ~404k for a plain stamp), a cost pushed onto whoever happens to be first. Fix: add a permissionless, fee-free `settleEpoch()` external function that calls _processEpochReset when `block.timestamp >= epochStartTime + EPOCH_DURATION` (keep the lazy call inside stampNote so behaviour for stampers is unchanged). This preserves the agreed economics.","line":261,"path":"contracts/IMDRetroWall.sol","reproduction":"State: alice calls stampNote three times in epoch 1 (pool 1500e18, alice rank 1, entitled to 450e18). Warp 30 days with no further stamps. Expected: alice's 450e18 is paid or at least claimable once the epoch is over (timeUntilEpochEnd() == 0). Actual: currentEpoch == 1, weeklyRewardPool == 1500e18, alice's balance unchanged; the only way to move the tokens is another stampNote call. If alice makes it herself she pays 1000e18 and receives 450e18 (net -550e18) and the pool becomes 1050e18 + 500e18 = 1550e18, still uncollectable without another paid stamp. Verified with test/scratch/Econ.t.sol::test_settlement_requires_paid_stamp.","severity":"medium","snippet":"        if (block.timestamp >= epochStartTime + EPOCH_DURATION) {\n            _processEpochReset();\n        }","title":"Rewards are only settled by a paid stamp: no fee-free settle path, so a quiet wall never pays its winners and the pool is locked"},{"citation":"resolved","description":"Every stamp after a boundary first executes the full payout loop; a single reverting or false-returning `transfer` aborts the stamp, and since the board is only cleared inside that same function the same winner is retried on every later stamp. With no admin, no pull-claim and no skip path, the contract becomes permanently unusable and the entire pool is locked. The live NOTES token at 0x03b76ccf7cfeec5198c81c5844b88a78e68a6173 on chain 4663 was inspected (public RPC https://rpc.mainnet.chain.robinhood.com): a plain OpenZeppelin v5 ERC-20 with no owner/pause/blocklist selectors, so this is not triggerable in the current deployment. It is reported because the constructor accepts 'any standard ERC-20' (README) and the Base Sepolia / future deployments are not pinned to that token; the design has no fallback for the common blocklist/pausable behaviours. Fix (keeps economics): credit rewards to a `pendingRewards[winner]` mapping in _processEpochReset and expose `claimRewards()` (pull), or wrap the transfer in a low-level call and keep the share in the mapping on failure.","line":550,"path":"contracts/IMDRetroWall.sol","reproduction":"State: wall deployed with an ERC-20 whose transfer reverts for a blocklisted receiver (the scratch mock `MockNotes.block_`). alice stamps once in epoch 1 (rank 1). The token blocks alice. Warp 7 days; bob calls stampNote. Expected: bob's stamp succeeds (alice's share is skipped or made claimable). Actual: stampNote reverts in the payout loop, and still reverts 365 days later for every caller; weeklyRewardPool (500e18) is unreachable. Verified with test/scratch/Econ.t.sol::test_blocked_winner_bricks_wall.","severity":"low","snippet":"                        if (!PAYMENT_TOKEN.transfer(winner, share)) {\n                            revert RewardTransferFailed(winner);\n                        }","title":"Reward payout is a push loop inside the only state-changing entry point: one winner the token refuses to pay bricks the wall forever"},{"citation":"resolved","description":"The pool is an internal counter incremented only by stampNote; payouts are computed from the counter, never from `balanceOf(address(this))`. Any NOTES transferred straight to the contract (a mistaken transfer, an airdrop, a tip meant for the pool) is excluded from every distribution and, with no owner, no sweep and no `donate()` function, can never leave. The repository tests assert 'Contract balance always mirrors the pool', but that holds only in the absence of external transfers. Fix: either add a permissionless `syncPool()` that sets weeklyRewardPool = PAYMENT_TOKEN.balanceOf(this) (folding donations into the next distribution), or document that direct transfers are burned.","line":165,"path":"contracts/IMDRetroWall.sol","reproduction":"State: alice calls PAYMENT_TOKEN.transfer(wall, 100e18), then stamps once; warp 7 days; alice stamps again (rollover pays alice 150e18). Expected: the 100e18 is either in the pool or recoverable. Actual: balanceOf(wall) - weeklyRewardPool() == 100e18 after the rollover and no function in the ABI can move it. Verified with test/scratch/Econ.t.sol::test_direct_transfer_stranded.","severity":"low","snippet":"    uint256 public weeklyRewardPool;","title":"Tokens sent directly to the wall are untracked and permanently stranded; balance == weeklyRewardPool invariant has no recovery path"},{"citation":"resolved","description":"A `data:` URI without `;base64` is a URL: `#` starts the fragment and `%` begins a percent-escape (RFC 2397 / WHATWG URL). The JSON is embedded raw. The name is always 'IMD Note #<id>' and every colour is '#RRGGBB' (the frontend palette only emits '#' colours), so every token's metadata is cut at the first '#' when parsed by a browser, wallet or marketplace that resolves the URI as a URL. Content containing '%' additionally corrupts percent-decoding. The repository test only strips the prefix with string replace and never URL-parses, so it passes. Fix: base64-encode the JSON (`data:application/json;base64,` with OpenZeppelin's Base64 library, available in the vendored OZ v5) or percent-encode '#', '%' and non-ASCII bytes.","line":423,"path":"contracts/IMDRetroWall.sol","reproduction":"Input: stampNote(\"gm\", \"#FFF2A5\", 1, 1, 0) then tokenURI(0). Expected: a URI that decodes to the JSON object. Actual: new URL(tokenURI(0)) in Node/browsers yields pathname 'application/json,{\"name\":\"IMD Note%20' and hash '#0%22,%22attributes%22:...'; JSON.parse of the decoded path fails with 'Unterminated string in JSON at position 18'. Verified with node -e using the exact tokenURI output shape.","severity":"low","snippet":"        return string(abi.encodePacked(\"data:application/json,\", json));","title":"tokenURI emits an unencoded data: URI containing '#': any URL-aware consumer truncates the metadata at the fragment and gets invalid JSON"},{"citation":"resolved","description":"NatSpec says 'One epoch lasts exactly 7 days' and 'Current epoch number ... increments every EPOCH_DURATION', but the boundary is lazy and the next window is anchored to whenever the next stamp lands, not to epochStartTime + 7 days. Any gap after the boundary extends the finished epoch, and during that gap `currentEpoch`, `getCurrentTopMinters`, `weeklyRewardPool` and `noteCountPerEpoch(currentEpoch, x)` describe an epoch that is over, while `timeUntilEpochEnd` returns 0. Off-chain consumers (the frontend counters) cannot tell a live board from a finished one. Fix: either anchor with `epochStartTime += EPOCH_DURATION * ((block.timestamp - epochStartTime) / EPOCH_DURATION)` or document the lazy semantics and expose a `pendingRollover()` view.","line":541,"path":"contracts/IMDRetroWall.sol","reproduction":"State: alice stamps at T; nobody stamps until T + 10 days, then alice stamps. Expected (NatSpec): epoch 1 covers [T, T+7d) and epoch 2 starts at T+7d. Actual: epoch 1 lasted 10 days and epochStartTime == T + 10 days (test/scratch/Econ.t.sol::test_epoch_drift). Between T+7d and T+10d, currentEpoch() == 1 and timeUntilEpochEnd() == 0.","severity":"info","snippet":"        epochStartTime = block.timestamp;","title":"Epochs are not 7-day windows: the new epoch starts at the trigger's timestamp, so epochs stretch and view functions go stale after the boundary"},{"citation":"resolved","description":"The test body performs a normal successful rollover and asserts the epoch advanced; its own comment admits the failing-transfer case was not built. The suite therefore has no coverage of the only path that can permanently disable the wall (see the push-loop finding), nor of sybil/last-second leaderboard behaviour, nor of a settlement with no subsequent stamp. Fix: compile the in-memory mock with a `blocked` flag (as test/scratch/Econ.t.sol does) and assert the revert and the subsequent permanent failure, or rename the test to what it checks.","line":593,"path":"test/IMDRetroWall.ts","reproduction":"Run `npm test`: the test passes while no transfer fails anywhere in the suite. Expected: a test with that name causes `PAYMENT_TOKEN.transfer` to return false/revert and asserts `RewardTransferFailed`. Actual: lines 598-609 only stamp alice, bob, warp, stamp carol and check currentEpoch == 2.","severity":"info","snippet":"    it(\"reverts the whole stamp if a reward transfer fails\", async function () {","title":"Test named 'reverts the whole stamp if a reward transfer fails' never exercises a failing transfer; the revert/brick path is untested"}],"hash":"b566194232bd4eb73b52b131b811c752c5279f7ad0371689abdd72617496e594","nodeId":"6e4465b6-d4d7-4d80-9f5d-f465c1c8fb7b","outcome":"completed","summary":"The leaderboard invariant held across 64 randomized runs of 120 stamps each, so the ranking code itself is sound. The review is complete and the findings file is in place.\n\n**Outcome:** 7 findings written to `.imd-findings.json`, all with exact snippets verified against the tree. No critical or high issues, so no proof files are attached. The scope was the single contract `contracts/IMDRetroWall.sol`, its Hardhat tests, and the live Robinhood Chain deployment.\n\n**Economic findings (the assigned area):**\n- **Medium, epoch-end sniping.** Rank is by stamp count only, the board and boundary are public, and ten addresses fill every paying slot. A scratch test shows a sybil set spending 21,000 NOTES at the last second taking the whole 60,000 pool against 100 honest stampers, net +39,000, while the honest users get nothing. The profit condition is stated in closed form.\n- **Medium, settlement liveness.** Payouts run only inside `stampNote`, so winners are paid only when someone else spends another 1000 NOTES. A sole stamper's 450-token reward sits unreachable for 30 days and costs 1000 to collect. A fee-free `settleEpoch()` would fix it without changing the economics.\n- **Low, push-payment loop.** One winner the token refuses to pay bricks the wall permanently. I inspected the live NOTES token bytecode and it is a plain OpenZeppelin v5 ERC-20 with no pause or blocklist, so this is not triggerable today. It is reported because the constructor accepts any ERC-20 and there is no recovery path.\n- **Low, stranded donations.** Tokens sent directly to the wall are excluded from the pool forever.\n- **Info, epoch drift.** Epochs anchor to the trigger's timestamp, not a 7-day grid, and views go stale after the boundary.\n\n**Outside the economics area:**\n- **Low, broken tokenURI.** The raw `data:` URI contains `#` in every name and colour, so any URL parser truncates the JSON. Confirmed with Node's URL parser.\n- **Info, test coverage.** The test named for a failing reward transfer never makes one fail.\n\n**Checked and found sound:** fee pull atomicity, burn and pool split, rollover rounding and conservation (balance equals pool absent donations), reentrancy via the ERC-721 receiver hook, and leaderboard sortedness under fuzzing. The live wall currently has zero notes and an empty pool, so none of these affect existing users yet.","treeHash":null,"usage":{"cachedInputTokens":1561918,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":36475,"runtime":"claude","turns":31,"wallClockMs":523347}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"78d8eb9b16352a18","findings":[{"citation":"resolved","description":"Boundary: the external consumer of tokenURI (wallets, marketplaces, browsers) parses the returned string as a URL. The code assumes the raw JSON can follow 'data:application/json,' verbatim. Per RFC 3986 / the WHATWG URL spec a non-base64 data URL's body is percent-decoded and anything after the first '#' is the fragment. The header built at line 371 is '{\"name\":\"IMD Note #' followed by the id, so the first '#' lands inside the name string of EVERY token, independent of the note's color (which, as '#RRGGBB', is the only format the frontend sends and adds a second '#'). The result is unterminated JSON; no compliant consumer can render any note's metadata. A second consequence of the missing encoding is that '%XX' sequences inside note content are percent-decoded by the consumer, so stored content and displayed content differ ('%41' renders as 'A'). The Hardhat test only strips the prefix with String.replace and never parses the URI as a URL, which is why it passes.","line":423,"path":"contracts/IMDRetroWall.sol","reproduction":"State: deploy with any ERC-20, alice stamps ('hi', '#FFF2A5', 1, 1, 0). Call tokenURI(0). Returned string (verified with forge on a copy of the contract): data:application/json,{\"name\":\"IMD Note #0\",\"description\":...}. Feed it to a standards-compliant parser (Node 24): new URL(uri).pathname == 'application/json,{\"name\":\"IMD Note%20' and new URL(uri).hash starts with '#0%22,%22description...'; await (await fetch(uri)).text() returns exactly '{\"name\":\"IMD Note ' and JSON.parse of it throws 'Unterminated string in JSON at position 18'. Expected: the full attributes JSON. Second input: content '50%25 off and %41' -> consumer shows '50% off and A'. Fix (keeps on-chain metadata, no design change): return 'data:application/json;base64,' + Base64.encode(bytes(json)) using OpenZeppelin's Base64 library, or percent-encode '#', '%' and all non-ASCII bytes in the body.","severity":"medium","snippet":"        return string(abi.encodePacked(\"data:application/json,\", json));","title":"tokenURI emits an un-encoded data: URI; the '#' in \"IMD Note #<id>\" is a URL fragment, so every token's metadata truncates to '{\"name\":\"IMD Note '"},{"citation":"resolved","description":"Boundary x precision: the NatSpec promises the slice [offset, min(offset + limit, total)), i.e. any limit is clamped. The clamp is applied only after the checked addition, so for offset >= 1 and limit close to type(uint256).max the addition overflows and the call reverts with Panic(0x11) before the clamp runs. offset == 0 does not overflow, which hides the edge in the obvious test. A caller that passes 'max' to mean 'everything from offset' (a common idiom) gets a revert instead of the tail of the wall.","line":330,"path":"contracts/IMDRetroWall.sol","reproduction":"State: three notes stamped. getNotes(0, type(uint256).max) returns 3 notes (no overflow, clamped). getNotes(1, type(uint256).max) -> reverts Panic(0x11) (verified with forge expectRevert(stdError.arithmeticError) on a copy of the contract). Expected: a 2-element page [note1, note2]. Fix: compute the end without the overflowing sum, e.g. uint256 remaining = total - offset; uint256 n = limit < remaining ? limit : remaining; then end = offset + n.","severity":"low","snippet":"        uint256 end = offset + limit;","title":"getNotes reverts with an arithmetic panic instead of clamping when offset + limit overflows uint256"},{"citation":"resolved","description":"Boundary mismatch between two implementations of the same limit. contracts/IMDRetroWall.sol:268 enforces bytes(_content).length <= 280 (UTF-8 bytes). The composer gates the stamp button on JavaScript String.length, which counts UTF-16 code units: 1 unit for 'é' (2 UTF-8 bytes), 2 units for an emoji (4 UTF-8 bytes). Any note whose byte length exceeds 280 while its code-unit length is <= 280 is accepted by the UI, submitted, and reverted on-chain (gas lost; with wallets that skip simulation the user also sees an opaque failure). The README additionally advertises '280 characters max', which the contract does not implement.","line":96,"path":"src/components/NoteComposer.tsx","reproduction":"Input: 200 x 'é' (U+00E9). JS: trimmed.length == 200 <= 280 so canStamp is true and the counter shows 200/280. Contract: bytes length 400 -> stampNote reverts ContentTooLong(400) (verified with forge expectRevert on a copy of the contract; Node: 'é'.repeat(200).length == 200, TextEncoder bytes == 400). Second input: 140 x '📌' -> length 280 (allowed), bytes 560 -> ContentTooLong(560). Expected: the composer blocks what the contract rejects. Fix: gate on new TextEncoder().encode(trimmed).length > MAX_CONTENT_LENGTH and show bytes in the counter (or document the limit as bytes).","severity":"low","snippet":"  const overLimit = trimmed.length > MAX_CONTENT_LENGTH;","title":"Content limit is 280 UTF-8 bytes on-chain but 280 UTF-16 code units in the composer, so non-ASCII notes between 281 and 1120 bytes pass the UI check and revert with ContentTooLong after the user signs"},{"citation":"resolved","description":"Boundary x invariant (numerical-gap seam 3): the invariant 'the pool accrued in epoch N is distributed to its top-10 once the 7-day boundary passes' is enforced only on the stampNote path. There is no permissionless settle function, so at the boundary the invariant is suspended indefinitely: timeUntilEpochEnd() returns 0, currentEpoch stays at N, weeklyRewardPool keeps the full amount, and no RewardPaid is emitted. The only way to release the rewards is for some account to pay TOKEN_FEE (1000 NOTES, 500 of which are burned and 500 of which go into the NEXT pool, not to the waiting winners). For a sole minter with one stamp the owed share is 150 NOTES while the cheapest trigger costs 1000 NOTES, so the winner is better off never claiming and the pool is effectively frozen. This is a consequence of the lazy-rollover design rather than an arithmetic error; the fix below keeps the economics unchanged.","line":261,"path":"contracts/IMDRetroWall.sol","reproduction":"State: alice stamps once in epoch 1 (pool = 500e18, board = [alice]). Warp 30 days with no other stamps. Observed (forge on a copy of the contract): timeUntilEpochEnd() == 0, currentEpoch() == 1, alice's balance is unchanged at (initial - 1000e18), weeklyRewardPool() == 500e18 and nothing has been transferred. Expected: once block.timestamp >= epochStartTime + 7 days the 30% share (150e18) is payable to alice without a 1000e18 payment. Fix: add external function settleEpoch() { if (block.timestamp < epochStartTime + EPOCH_DURATION) revert; _processEpochReset(); } so anyone (including the winners) can settle a due epoch at gas cost only; stampNote keeps its existing lazy rollover.","severity":"low","snippet":"        if (block.timestamp >= epochStartTime + EPOCH_DURATION) {\n            _processEpochReset();\n        }","title":"Epoch settlement only runs inside a paid stampNote; winners of the last active epoch cannot be paid until someone burns 500 NOTES and seeds a new pool"},{"citation":"resolved","description":"The contract depends on OpenZeppelin v5 (the _update(address,uint256,address) and _increaseBalance(address,uint128) override signatures only exist in v5.x), but no version of @openzeppelin/contracts appears in package.json, pnpm-lock.yaml, or anywhere else in the tree (grep over all .json/.yaml/.ts files finds no match). The documented 'npm install && npm run compile / npm test' path therefore fails at import resolution, the test suite in test/IMDRetroWall.ts never runs from a fresh clone, and the exact library revision the live Robinhood Chain deployment (0x515F8f...C939) was built against is unrecorded and unreproducible.","line":4,"path":"contracts/IMDRetroWall.sol","reproduction":"State: fresh clone at d063100. Run: npm install && npm run compile. Expected: artifacts/ built and 'npm test' runs the suite. Actual (reproduced with hardhat 2 in a temp project holding only this contract): 'Error HH411: The library @openzeppelin/contracts, imported from contracts/IMDRetroWall.sol, is not installed. Try installing it using npm.' (package.json devDependencies list hardhat-toolbox, hardhat, ts-node, typescript, dotenv and frontend packages only). Fix: add \"@openzeppelin/contracts\": \"5.0.2\" (or the exact version used for the deployment) to devDependencies and regenerate pnpm-lock.yaml.","severity":"low","snippet":"import {ERC721} from \"@openzeppelin/contracts/token/ERC721/ERC721.sol\";","title":"@openzeppelin/contracts is imported but not declared in package.json or the lockfile, so a clean checkout cannot compile or test the contract"},{"citation":"resolved","description":"Decimal-mismatch check from the Math Precision guide. TOKEN_FEE = 1000e18, the burn/pool halves and all reward shares assume an 18-decimal token, and the NatSpec says the token 'must be ... 18-decimal'. The constructor only rejects address(0). The live token (NOTES at 0x03b76ccf...6173 on Robinhood Chain, verified via RPC: decimals() == 18, symbol() == 'NOTES', a plain ERC-20 with no pause/blacklist selectors) satisfies the assumption, so this is not a defect in the pinned deployment. It is recorded as a trust assumption for any redeploy: with a 6-decimal token the fee would be 10^15 tokens per stamp; with a 24-decimal token it would be 0.001 token.","line":226,"path":"contracts/IMDRetroWall.sol","reproduction":"State: deploy IMDRetroWall with a 6-decimal ERC-20. stampNote requires an allowance and balance of 1000e18 base units = 1,000,000,000,000,000 whole tokens; every stamp reverts on transferFrom for any realistic holder. Expected (per NatSpec) the constructor refuses a token whose decimals() != 18. Fix: in the constructor require IERC20Metadata(_paymentToken).decimals() == 18 (or scale TOKEN_FEE from decimals()).","severity":"info","snippet":"        PAYMENT_TOKEN = _paymentToken;","title":"Constructor does not check PAYMENT_TOKEN.decimals() although every fee and share constant is hardcoded at 18 decimals"}],"hash":"f043e45a3cacb42af55b2634d1608fbfc2887216e5878c77c641d3fe7589a71d","nodeId":"f886a427-6902-439f-92f2-5251d0cc0f7d","outcome":"completed","summary":"The review is complete and the findings file is written. No repository files were changed; all scratch work lives in temporary directories outside the tree.\n\n**Scope covered.** The single production contract, IMDRetroWall.sol, was traced in full against the Math Precision, Boundary and Numerical Gap guides, with the Hardhat suite and the frontend composer read for the limits they enforce. I ran the contract under Foundry in a temporary project with OpenZeppelin v5.0.2 to test each lead, and parsed the live NOTES token on Robinhood Chain over RPC.\n\n**The core arithmetic is sound.** Fee split, basis-point shares, the two-loop distribution, the insertion-sort leaderboard, the tie-break and the epoch boundary all behave as documented. A 30-epoch simulation with partially filled boards confirmed the contract's token balance always equals the recorded pool, so rounding dust never leaks. The live token has 18 decimals and no pause or blacklist code, so the push-payout path cannot be bricked by it.\n\n**Findings written to .imd-findings.json**, ranked by severity:\n\n- **Medium:** every tokenURI is a raw data URI containing `#` inside the name. A standards-compliant parser cuts the body at the fragment, so Node's fetch returns `{\"name\":\"IMD Note ` and JSON parsing fails for all tokens. Percent sequences in content are also decoded. Fix by base64-encoding the body.\n- **Low:** getNotes adds offset and limit before clamping, so a large limit with any non-zero offset reverts with an arithmetic panic instead of returning the tail.\n- **Low:** the composer gates on UTF-16 length while the contract enforces 280 UTF-8 bytes, so a 200-character accented note passes the UI and reverts on-chain after signing.\n- **Low:** rewards are only settled inside a paid stamp. With no permissionless settle, a sole winner owed 150 NOTES must wait for someone to spend 1000 NOTES.\n- **Low:** OpenZeppelin is imported but never declared as a dependency, so a clean clone fails to compile with Hardhat error HH411, reproduced in a temp project.\n- **Info:** the constructor does not check that the token has 18 decimals, recorded as a trust assumption since the live token satisfies it.\n\nNo finding reached critical or high, so no proof test files were required.","treeHash":null,"usage":{"cachedInputTokens":1117224,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":28182,"runtime":"claude","turns":27,"wallClockMs":447977}}],"verification":[]}