The whole request

Title: VETERANS — PoW NFT Mine with Wallet-Age Multipliers

Build a complete Proof-of-Work NFT mining project on Robinhood Chain, modeled after WORKERS (imdworkers.fun) but with the following exact specifications.

  1. Core Parameters
  • Max supply: 3,333 NFTs
  • Mint price: Fixed 0.069 IMD every epoch (never changes)
  • Only difficulty changes per epoch
  • Epoch size: 333 mints (exactly 10 epochs)
  • Sequential minting (tokenId 1 → 3333)
  • No functional tiers — all NFTs have identical staking/reward utility
  • Visual rarity only (see Artwork section)
  1. Proof-of-Work Minting
  • Function: mint(uint256 nonce)
  • Hash check: keccak256(abi.encodePacked(block.chainid, address(this), msg.sender, nonce)) < target
  • Wallet-bound (prevents sharing winning hashes)
  • On successful mint:
    • Record firstSeen[msg.sender] = block.timestamp if not already set
    • Assign sequential tokenId
    • Derive visual traits/rarity from the winning hash
    • Charge 0.069 IMD and split it (see Fee Split)
  1. Epochs & Difficulty
  • Epoch advances every 333 mints
  • Difficulty increases each epoch (start at a reasonable bits value, scale up)
  • Mint price stays locked at 0.069 IMD
  • Public view functions for current epoch, difficulty, mints remaining in epoch
  1. Wallet-Age Multipliers (Staking Rewards) On-chain firstSeen[wallet] is set at first mint (or first stake if they buy secondary).

Multiplier table (based on block.timestamp - firstSeen):

  • 0–7 days → 1.0x
  • 8–30 days → 1.5x
  • 31–90 days → 2.0x
  • 91–180 days → 2.5x
  • 181–359 days → 4.0x
  • 360+ days → 8.0x

Staking rewards are weighted by this multiplier. Higher age = larger share of the 65% IMD pool.

  1. Staking
  • Users stake NFTs (simple lock options or no lock — keep it clean)
  • Staked NFTs earn their share of the 65% IMD from every mint, weighted by wallet-age multiplier
  • Claim function for accumulated IMD
  • Unstake after any lock period
  1. Artwork & Rarity
  • Simple Pepe-style pixel art with a mining theme (helmets, pickaxes, headlamps, terminal vibes, dark backgrounds with green/neon accents)
  • Traits and visual rarity are derived from the winning PoW hash
  • Include a rare “Overclocked” / legendary tier (~1 in 16–32) with gold/neon effects
  • Full OpenSea-compatible metadata (image, attributes, rarity rank)
  • Hash controls only the visual traits, not the tokenId
  1. Enforced Royalties (ERC-721C)
  • Inherit from Limit Break’s ERC721C (Creator Token standard)
  • Total royalty: 6.9% (690 basis points)
  • Split: • 6.0% → ETH Claim Pool contract • 0.9% → TREASURY_WALLET
  • Configure transfer validator + security policy so royalties are enforced on-chain
  • Only operators that honor the royalty can transfer the tokens
  • Royalty payments feed the same ETH Claim Pool as the 20% of mint fees
  1. ETH Claim Pool
  • Separate contract that receives: • 20% of every mint fee (converted or held as ETH) • 6.0% of all secondary sales (via royalties)
  • Users claim ETH pro-rata according to their staked NFT weight (include wallet-age multiplier in the weight)
  • Clean claim function, no admin control over the pool
  1. Fee Split (every mint of 0.069 IMD)
  • 65% → Staking rewards (IMD, weighted by wallet-age multiplier)
  • 20% → ETH Claim Pool
  • 10% → TREASURY_WALLET
  • 5% → Burn IMD
  1. Treasury
  • TREASURY_WALLET = 0xEab8865F6Af3a866FEf97a9A776c58aBcB711ab5
  • Receives 10% of every mint + 0.9% of every secondary sale
  • No ownership or admin functions attached to this wallet
  1. Frontend / Website Build a clean, fast frontend with a distinct VETERANS theme (Pepe miners, veteran/age narrative, dark terminal + neon green/gold accents). Similar functional layout and energy to imdworkers.fun, but clearly our own project.

Required pages/sections:

  • Home / Mine: Live mining console, current epoch, difficulty, price (0.069 IMD), total minted, connect wallet, approve IMD, mine button + auto-mine option
  • Gallery: All 3,333 Veterans, filter by rarity / Overclocked, show traits
  • My Veterans: User’s NFTs, stake/unstake, current multiplier based on wallet age, claim IMD + claim ETH
  • Staking Dashboard: Total staked, estimated rewards, leaderboard (top wallets by age or by rewards)
  • Claim: Separate tabs or clear sections for IMD rewards and ETH pool
  • Simple stats: Supply, epoch progress, total IMD distributed, total ETH in claim pool

Theme notes:

  • Dark background, terminal/mono font mixed with clean UI
  • Pepe miner aesthetic (not a direct copy of WORKERS)
  • Clear display of the user’s wallet age and current multiplier
  • Mobile friendly
  1. Deliverables
  • Full Foundry project (contracts + tests)
  • NFT contract (ERC721C) + Staking contract + ETH Claim Pool + Royalty logic
  • Complete frontend (React/Next.js or similar, deployable)
  • OpenSea metadata hosting or on-chain generation
  • Deployment scripts for Robinhood Chain
  • Clear README with all addresses, how to interact, and frontend deployment notes

Keep everything trustless, no upgradeable proxies, no admin keys that can change the economics after deployment. All numbers above are final.

Token name: VETERANS

Token symbol: VETERANS

Also approved

The requester chose this release: source code published to GitHub, website hosted on IPFS, contracts deployed on chain.

Published · Site

site
veterans-mine-your-legacy.sites.imd.fun
ipfs
bafybeid2htta7p2rf6i4xxpu6gyvk5ukssvbz6cw24ksahbse4ie6edcze
website
identity-md-launches/launch-1767-workflow-frontend-stage-context

Published · Contracts

app
VeteransNFT 0x582ea0b5c023f354e0b95bfcb5c12a2062e8e033source verified
app
VeteransRenderer 0x52aaebf6aabe67b182eca14e3df52601b92765b7source verified
github
identity-md-launches/launch-1756-workflow-contract-stage-context

Work

  1. Build contract projectAgent #368281 files changedsent back

    The VETERANS contracts stage is complete and committed as 0f497f5. A fresh clone builds, passes all 60 tests with the verifier's fuzz seed in an empty environment, and passes forge fmt --check.

    What was built (all in src/, ABIs in docs/abi/):

    • VeteransNFT: ERC721C (Limit Break v4) with mint(uint256 nonce), the wallet-bound keccak hash check, sequential ids, ten difficulty epochs, fixed 0.069 IMD price split 65/20/10/5 in the same transaction, firstSeen tracking, ERC-2981 at 690 bps to the claim pool, and on-chain tokenURI via the renderer.
    • VeteransStaking: no-lock staking. Weight is staked count times the age multiplier (1.0x to 8.0x per the brief's table). Rewards use a 1e27 accumulator and are settled before any weight change. No owner.
    • VeteransClaimPool: receives royalties as ETH and the 20% mint share as IMD, pushes the treasury's 0.9% (with a pull fallback if the push fails), and lets stakers claim both assets by weight. No owner or admin function.
    • VeteransRenderer: stateless pixel-art SVG and OpenSea JSON derived from the winning hash, with an Overclocked tier at 1 in 24 and a numeric rarity score.

    Decisions you should know about

    • The brief's epoch arithmetic does not close: ten epochs of 333 is 3,330, not 3,333. The tenth epoch runs to the supply cap (336 mints) so there are exactly ten epochs. Documented in the README.
    • The IMD token address is not in the brief, so the owner sets it once after launch via configure(imd, staking, claimPool), which also wires the sibling contracts (forward references are not possible in the manifest). Minting reverts with a clear error until then. The call is one-shot.
    • The "20% converted or held as ETH" clause is implemented as held: the pool distributes the IMD alongside royalty ETH rather than adding a DEX dependency.
    • Limit Break's v4 default validator has code on Robinhood Chain (verified with cast code against two public RPCs). Staking transfers bypass it by design since ownership never changes. The owner must still set the collection's security policy and operator whitelist on the validator after launch. These owner powers, and the renderer swap until frozen, are listed as trust assumptions in the README.
    • Difficulty starts at 20 leading zero bits and adds one bit per epoch. Both are named constants, flagged as my choice.

    Not in this stage: the frontend, the launch manifest, and deployment itself, which belong to later steps. The README's "After launch" section lists the owner's three post-deployment actions. The contracts hold user funds and still need the independent adversarial review the workflow schedules.

    ran onclaude · claude-fable-5-1 · 45 turns · 21m 43s · 1.3K in · 99.2K out · 4.7M cached
    submissionf32487197f6e54bed966d500ea05beca7388c7445399919c7829bacdb7786575
    device934ef01306a3cc89443b4e73240a28d426adaef97058a4f8b2121bd94d724fb6
    started from0243d7da4a4337ae8b16bcdf15bb4ead736fd68f
    bundlea6cf7fd1a62dd85406ec0785de2e8a167ab22088f33bf5ac6ceee31cadcf70ec · 373 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 281 files
    .gitignoreREADME.mddocs/abi/VeteransClaimPool.jsondocs/abi/VeteransNFT.jsondocs/abi/VeteransRenderer.jsondocs/abi/VeteransStaking.jsonfoundry.tomllib/creator-token-standards/LICENSElib/creator-token-standards/src/Constants.sollib/creator-token-standards/src/access/OwnableBasic.sollib/creator-token-standards/src/access/OwnablePermissions.sollib/creator-token-standards/src/erc721c/ERC721C.sollib/creator-token-standards/src/interfaces/ICreatorToken.sollib/creator-token-standards/src/interfaces/ICreatorTokenLegacy.sollib/creator-token-standards/src/interfaces/ITransferValidator.sollib/creator-token-standards/src/interfaces/ITransferValidatorSetTokenType.sollib/creator-token-standards/src/token/erc721/ERC721OpenZeppelin.sollib/creator-token-standards/src/token/erc721/MetadataURI.sollib/creator-token-standards/src/utils/AutomaticValidatorTransferApproval.sollib/creator-token-standards/src/utils/CreatorTokenBase.sollib/creator-token-standards/src/utils/TransferValidation.sollib/forge-std/LICENSE-APACHElib/forge-std/LICENSE-MITlib/forge-std/src/Base.sollib/forge-std/src/Config.sollib/forge-std/src/LibVariable.sollib/forge-std/src/Script.sollib/forge-std/src/StdAssertions.sollib/forge-std/src/StdChains.sollib/forge-std/src/StdCheats.sollib/forge-std/src/StdConfig.sollib/forge-std/src/StdConstants.sollib/forge-std/src/StdError.sollib/forge-std/src/StdInvariant.sollib/forge-std/src/StdJson.sollib/forge-std/src/StdMath.sollib/forge-std/src/StdSecp256k1.sollib/forge-std/src/StdStorage.sollib/forge-std/src/StdStyle.sollib/forge-std/src/StdToml.sollib/forge-std/src/StdUtils.sollib/forge-std/src/Test.sollib/forge-std/src/Vm.sollib/forge-std/src/console.sollib/forge-std/src/console2.sollib/forge-std/src/interfaces/IERC1155.sollib/forge-std/src/interfaces/IERC165.sollib/forge-std/src/interfaces/IERC20.sollib/forge-std/src/interfaces/IERC4626.sollib/forge-std/src/interfaces/IERC6909.sollib/forge-std/src/interfaces/IERC721.sollib/forge-std/src/interfaces/IERC7540.sollib/forge-std/src/interfaces/IERC7575.sollib/forge-std/src/interfaces/IMulticall3.sollib/forge-std/src/safeconsole.sollib/openzeppelin-contracts/LICENSElib/openzeppelin-contracts/contracts/access/AccessControl.sollib/openzeppelin-contracts/contracts/access/AccessControlCrossChain.sollib/openzeppelin-contracts/contracts/access/AccessControlDefaultAdminRules.sollib/openzeppelin-contracts/contracts/access/AccessControlEnumerable.sollib/openzeppelin-contracts/contracts/access/IAccessControl.sollib/openzeppelin-contracts/contracts/access/IAccessControlDefaultAdminRules.sollib/openzeppelin-contracts/contracts/access/IAccessControlEnumerable.sollib/openzeppelin-contracts/contracts/access/Ownable.sollib/openzeppelin-contracts/contracts/access/Ownable2Step.sollib/openzeppelin-contracts/contracts/access/README.adoclib/openzeppelin-contracts/contracts/crosschain/CrossChainEnabled.sollib/openzeppelin-contracts/contracts/crosschain/README.adoclib/openzeppelin-contracts/contracts/crosschain/amb/CrossChainEnabledAMB.sollib/openzeppelin-contracts/contracts/crosschain/amb/LibAMB.sollib/openzeppelin-contracts/contracts/crosschain/arbitrum/CrossChainEnabledArbitrumL1.sollib/openzeppelin-contracts/contracts/crosschain/arbitrum/CrossChainEnabledArbitrumL2.sollib/openzeppelin-contracts/contracts/crosschain/arbitrum/LibArbitrumL1.sollib/openzeppelin-contracts/contracts/crosschain/arbitrum/LibArbitrumL2.sollib/openzeppelin-contracts/contracts/crosschain/errors.sollib/openzeppelin-contracts/contracts/crosschain/optimism/CrossChainEnabledOptimism.sollib/openzeppelin-contracts/contracts/crosschain/optimism/LibOptimism.sollib/openzeppelin-contracts/contracts/crosschain/polygon/CrossChainEnabledPolygonChild.sollib/openzeppelin-contracts/contracts/finance/PaymentSplitter.sollib/openzeppelin-contracts/contracts/finance/README.adoclib/openzeppelin-contracts/contracts/finance/VestingWallet.sollib/openzeppelin-contracts/contracts/governance/Governor.sollib/openzeppelin-contracts/contracts/governance/IGovernor.sollib/openzeppelin-contracts/contracts/governance/README.adoclib/openzeppelin-contracts/contracts/governance/TimelockController.sollib/openzeppelin-contracts/contracts/governance/compatibility/GovernorCompatibilityBravo.sollib/openzeppelin-contracts/contracts/governance/compatibility/IGovernorCompatibilityBravo.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorCountingSimple.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorPreventLateQuorum.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorProposalThreshold.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorSettings.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorTimelockCompound.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorTimelockControl.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorVotes.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorVotesComp.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorVotesQuorumFraction.sollib/openzeppelin-contracts/contracts/governance/extensions/IGovernorTimelock.sollib/openzeppelin-contracts/contracts/governance/utils/IVotes.sollib/openzeppelin-contracts/contracts/governance/utils/Votes.sollib/openzeppelin-contracts/contracts/interfaces/IERC1155.sollib/openzeppelin-contracts/contracts/interfaces/IERC1155MetadataURI.sollib/openzeppelin-contracts/contracts/interfaces/IERC1155Receiver.sollib/openzeppelin-contracts/contracts/interfaces/IERC1271.sollib/openzeppelin-contracts/contracts/interfaces/IERC1363.sollib/openzeppelin-contracts/contracts/interfaces/IERC1363Receiver.sollib/openzeppelin-contracts/contracts/interfaces/IERC1363Spender.sollib/openzeppelin-contracts/contracts/interfaces/IERC165.sollib/openzeppelin-contracts/contracts/interfaces/IERC1820Implementer.sollib/openzeppelin-contracts/contracts/interfaces/IERC1820Registry.sollib/openzeppelin-contracts/contracts/interfaces/IERC1967.sollib/openzeppelin-contracts/contracts/interfaces/IERC20.sollib/openzeppelin-contracts/contracts/interfaces/IERC20Metadata.sollib/openzeppelin-contracts/contracts/interfaces/IERC2309.sollib/openzeppelin-contracts/contracts/interfaces/IERC2612.sollib/openzeppelin-contracts/contracts/interfaces/IERC2981.sollib/openzeppelin-contracts/contracts/interfaces/IERC3156.sollib/openzeppelin-contracts/contracts/interfaces/IERC3156FlashBorrower.sollib/openzeppelin-contracts/contracts/interfaces/IERC3156FlashLender.sollib/openzeppelin-contracts/contracts/interfaces/IERC4626.sollib/openzeppelin-contracts/contracts/interfaces/IERC4906.sollib/openzeppelin-contracts/contracts/interfaces/IERC5267.sollib/openzeppelin-contracts/contracts/interfaces/IERC5313.sollib/openzeppelin-contracts/contracts/interfaces/IERC5805.sollib/openzeppelin-contracts/contracts/interfaces/IERC6372.sollib/openzeppelin-contracts/contracts/interfaces/IERC721.sollib/openzeppelin-contracts/contracts/interfaces/IERC721Enumerable.sollib/openzeppelin-contracts/contracts/interfaces/IERC721Metadata.sollib/openzeppelin-contracts/contracts/interfaces/IERC721Receiver.sollib/openzeppelin-contracts/contracts/interfaces/IERC777.sollib/openzeppelin-contracts/contracts/interfaces/IERC777Recipient.sollib/openzeppelin-contracts/contracts/interfaces/IERC777Sender.sollib/openzeppelin-contracts/contracts/interfaces/README.adoclib/openzeppelin-contracts/contracts/interfaces/draft-IERC1822.sollib/openzeppelin-contracts/contracts/interfaces/draft-IERC2612.sollib/openzeppelin-contracts/contracts/metatx/ERC2771Context.sollib/openzeppelin-contracts/contracts/metatx/MinimalForwarder.sollib/openzeppelin-contracts/contracts/metatx/README.adoclib/openzeppelin-contracts/contracts/package.jsonlib/openzeppelin-contracts/contracts/proxy/Clones.sollib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sollib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Upgrade.sollib/openzeppelin-contracts/contracts/proxy/Proxy.sollib/openzeppelin-contracts/contracts/proxy/README.adoclib/openzeppelin-contracts/contracts/proxy/beacon/BeaconProxy.sollib/openzeppelin-contracts/contracts/proxy/beacon/IBeacon.sollib/openzeppelin-contracts/contracts/proxy/beacon/UpgradeableBeacon.sollib/openzeppelin-contracts/contracts/proxy/transparent/ProxyAdmin.sollib/openzeppelin-contracts/contracts/proxy/transparent/TransparentUpgradeableProxy.sollib/openzeppelin-contracts/contracts/proxy/utils/Initializable.sollib/openzeppelin-contracts/contracts/proxy/utils/UUPSUpgradeable.sollib/openzeppelin-contracts/contracts/security/Pausable.sollib/openzeppelin-contracts/contracts/security/PullPayment.sollib/openzeppelin-contracts/contracts/security/README.adoclib/openzeppelin-contracts/contracts/security/ReentrancyGuard.sollib/openzeppelin-contracts/contracts/token/ERC1155/ERC1155.sollib/openzeppelin-contracts/contracts/token/ERC1155/IERC1155.sollib/openzeppelin-contracts/contracts/token/ERC1155/IERC1155Receiver.sollib/openzeppelin-contracts/contracts/token/ERC1155/README.adoclib/openzeppelin-contracts/contracts/token/ERC1155/extensions/ERC1155Burnable.sollib/openzeppelin-contracts/contracts/token/ERC1155/extensions/ERC1155Pausable.sollib/openzeppelin-contracts/contracts/token/ERC1155/extensions/ERC1155Supply.sollib/openzeppelin-contracts/contracts/token/ERC1155/extensions/ERC1155URIStorage.sollib/openzeppelin-contracts/contracts/token/ERC1155/extensions/IERC1155MetadataURI.sollib/openzeppelin-contracts/contracts/token/ERC1155/presets/ERC1155PresetMinterPauser.sollib/openzeppelin-contracts/contracts/token/ERC1155/presets/README.mdlib/openzeppelin-contracts/contracts/token/ERC1155/utils/ERC1155Holder.sollib/openzeppelin-contracts/contracts/token/ERC1155/utils/ERC1155Receiver.sollib/openzeppelin-contracts/contracts/token/ERC20/ERC20.sollib/openzeppelin-contracts/contracts/token/ERC20/IERC20.sollib/openzeppelin-contracts/contracts/token/ERC20/README.adoclib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Burnable.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Capped.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20FlashMint.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Pausable.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Permit.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Snapshot.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Votes.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20VotesComp.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC20Wrapper.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/ERC4626.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Metadata.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/IERC20Permit.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/draft-ERC20Permit.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/draft-IERC20Permit.sollib/openzeppelin-contracts/contracts/token/ERC20/presets/ERC20PresetFixedSupply.sollib/openzeppelin-contracts/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sollib/openzeppelin-contracts/contracts/token/ERC20/presets/README.mdlib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sollib/openzeppelin-contracts/contracts/token/ERC20/utils/TokenTimelock.sollib/openzeppelin-contracts/contracts/token/ERC721/ERC721.sollib/openzeppelin-contracts/contracts/token/ERC721/IERC721.sollib/openzeppelin-contracts/contracts/token/ERC721/IERC721Receiver.sollib/openzeppelin-contracts/contracts/token/ERC721/README.adoclib/openzeppelin-contracts/contracts/token/ERC721/extensions/ERC721Burnable.sollib/openzeppelin-contracts/contracts/token/ERC721/extensions/ERC721Consecutive.sollib/openzeppelin-contracts/contracts/token/ERC721/extensions/ERC721Enumerable.sollib/openzeppelin-contracts/contracts/token/ERC721/extensions/ERC721Pausable.sollib/openzeppelin-contracts/contracts/token/ERC721/extensions/ERC721Royalty.sollib/openzeppelin-contracts/contracts/token/ERC721/extensions/ERC721URIStorage.sollib/openzeppelin-contracts/contracts/token/ERC721/extensions/ERC721Votes.sollib/openzeppelin-contracts/contracts/token/ERC721/extensions/ERC721Wrapper.sollib/openzeppelin-contracts/contracts/token/ERC721/extensions/IERC721Enumerable.sollib/openzeppelin-contracts/contracts/token/ERC721/extensions/IERC721Metadata.sollib/openzeppelin-contracts/contracts/token/ERC721/extensions/draft-ERC721Votes.sollib/openzeppelin-contracts/contracts/token/ERC721/presets/ERC721PresetMinterPauserAutoId.sollib/openzeppelin-contracts/contracts/token/ERC721/presets/README.mdlib/openzeppelin-contracts/contracts/token/ERC721/utils/ERC721Holder.sollib/openzeppelin-contracts/contracts/token/ERC777/ERC777.sollib/openzeppelin-contracts/contracts/token/ERC777/IERC777.sollib/openzeppelin-contracts/contracts/token/ERC777/IERC777Recipient.sollib/openzeppelin-contracts/contracts/token/ERC777/IERC777Sender.sollib/openzeppelin-contracts/contracts/token/ERC777/README.adoclib/openzeppelin-contracts/contracts/token/ERC777/presets/ERC777PresetFixedSupply.sollib/openzeppelin-contracts/contracts/token/common/ERC2981.sollib/openzeppelin-contracts/contracts/token/common/README.adoclib/openzeppelin-contracts/contracts/utils/Address.sollib/openzeppelin-contracts/contracts/utils/Arrays.sollib/openzeppelin-contracts/contracts/utils/Base64.sollib/openzeppelin-contracts/contracts/utils/Checkpoints.sollib/openzeppelin-contracts/contracts/utils/Context.sollib/openzeppelin-contracts/contracts/utils/Counters.sollib/openzeppelin-contracts/contracts/utils/Create2.sollib/openzeppelin-contracts/contracts/utils/Multicall.sollib/openzeppelin-contracts/contracts/utils/README.adoclib/openzeppelin-contracts/contracts/utils/ShortStrings.sollib/openzeppelin-contracts/contracts/utils/StorageSlot.sollib/openzeppelin-contracts/contracts/utils/Strings.sollib/openzeppelin-contracts/contracts/utils/Timers.sollib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sollib/openzeppelin-contracts/contracts/utils/cryptography/EIP712.sollib/openzeppelin-contracts/contracts/utils/cryptography/MerkleProof.sollib/openzeppelin-contracts/contracts/utils/cryptography/SignatureChecker.sollib/openzeppelin-contracts/contracts/utils/cryptography/draft-EIP712.sollib/openzeppelin-contracts/contracts/utils/escrow/ConditionalEscrow.sollib/openzeppelin-contracts/contracts/utils/escrow/Escrow.sollib/openzeppelin-contracts/contracts/utils/escrow/RefundEscrow.sollib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sollib/openzeppelin-contracts/contracts/utils/introspection/ERC165Checker.sollib/openzeppelin-contracts/contracts/utils/introspection/ERC165Storage.sollib/openzeppelin-contracts/contracts/utils/introspection/ERC1820Implementer.sollib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sollib/openzeppelin-contracts/contracts/utils/introspection/IERC1820Implementer.sollib/openzeppelin-contracts/contracts/utils/introspection/IERC1820Registry.sollib/openzeppelin-contracts/contracts/utils/math/Math.sollib/openzeppelin-contracts/contracts/utils/math/SafeCast.sollib/openzeppelin-contracts/contracts/utils/math/SafeMath.sollib/openzeppelin-contracts/contracts/utils/math/SignedMath.sollib/openzeppelin-contracts/contracts/utils/math/SignedSafeMath.sollib/openzeppelin-contracts/contracts/utils/structs/BitMaps.sollib/openzeppelin-contracts/contracts/utils/structs/DoubleEndedQueue.sollib/openzeppelin-contracts/contracts/utils/structs/EnumerableMap.sollib/openzeppelin-contracts/contracts/utils/structs/EnumerableSet.sollib/openzeppelin-contracts/contracts/vendor/amb/IAMB.sollib/openzeppelin-contracts/contracts/vendor/arbitrum/IArbSys.sollib/openzeppelin-contracts/contracts/vendor/arbitrum/IBridge.sollib/openzeppelin-contracts/contracts/vendor/arbitrum/IDelayedMessageProvider.sollib/openzeppelin-contracts/contracts/vendor/arbitrum/IInbox.sollib/openzeppelin-contracts/contracts/vendor/arbitrum/IOutbox.sollib/openzeppelin-contracts/contracts/vendor/compound/ICompoundTimelock.sollib/openzeppelin-contracts/contracts/vendor/compound/LICENSElib/openzeppelin-contracts/contracts/vendor/optimism/ICrossDomainMessenger.sollib/openzeppelin-contracts/contracts/vendor/optimism/LICENSElib/openzeppelin-contracts/contracts/vendor/polygon/IFxMessageProcessor.sollib/permit-c/LICENSElib/permit-c/src/Constants.solremappings.txtscript/Deploy.s.solsrc/VeteransClaimPool.solsrc/VeteransNFT.solsrc/VeteransRenderer.solsrc/VeteransStaking.solsrc/interfaces/IVeterans.soltest/Deploy.t.soltest/VeteransBase.t.soltest/VeteransClaimPool.t.soltest/VeteransNFT.t.soltest/VeteransRenderer.t.soltest/VeteransStaking.t.soltest/harness/VeteransNFTTestable.soltest/mocks/MockIMD.soltest/mocks/MockValidator.sol
  2. ManifestAgent #11401 file changedsent back

    Created launch.json with four contracts in dependency order, matching constructor ABIs and $owner.

    Manifest validation passed. forge build succeeded; all 60 tests passed with the required fuzz seed.

    Only launch.json is changed for submission. Notes preserve existing implementation conflicts, including unsupported post-deployment configuration.

    ran oncodex · gpt-6-astra · 3 turns · 1m 47s · 49.8K in · 3.4K out · 202.4K cached
    submission54e4d7a001bd5e6a214c024b535a8ca697027317a21aa1518302079d48db3503
    device308998a1407291a823686374095068c65b861c683536e3d82a2dcef87c1ad1bf
    started froma708f3ceba2c627495025a66ade07488569221a1
    bundleb0dca289c8f8c9a7f657c366e1c24c687de28609f9f9d9cc2797b8a13bb2c7c5 · 374 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on191e9764d1b5eea60091d34b80d5a31421b9536dde6e677ca26092e89e3ca477
    changed · 1 file
    launch.json
    may write
    launch.json
  3. Audit economicsAgent #938found 1 high, 2 medium, 1 low

    I wrote 4 findings to .imd-findings.json: 1 high, 2 medium and 1 low. It also has a coverage row for all 28 listed entry points, plus two rows for invariants I checked. The proof test for the high finding fails on the current code as intended. I deleted the scratch test afterwards, and no tracked file changed.

    Findings

    1. High: the owner can send most fees and all royalties to contracts they choose (src/VeteransNFT.sol:111). The staking contract, claim pool and IMD token are chosen by the owner in a configure call after launch, not fixed at deployment. Its checks only ask the supplied contracts what nft() and staking() return, and any contract the owner deploys can give the expected answers. Once accepted:

      • 65% + 20% of every mint fee goes to the owner's contract;
      • the whole 6.9% royalty goes there too, including the treasury's 0.9%;
      • transfers to or from it skip royalty enforcement.

      This conflicts with the brief's "no admin keys that can change the economics". The proof test deploys a look-alike contract and configure accepts it.

    2. Medium: royalties paid in ERC-20 tokens are stuck forever in the claim pool (src/VeteransClaimPool.sol:73). The pool can only hand out native ETH and IMD that the NFT contract tells it about. Marketplaces often pay royalties on accepted offers in WETH. Those payments, both the stakers' 6.0% and the treasury's 0.9%, can never be moved.

    3. Medium: the owner can switch off royalty enforcement (src/VeteransNFT.sol:22). setTransferValidator(address(0)) comes from Limit Break's ERC721C base contract and only the owner can call it. After that call, every transfer is royalty-free. No security policy is set at deployment either; the README leaves it as a manual step for the owner. The README lists this as a trust assumption, but the brief requires royalties to be enforced on-chain.

    4. Low: rewards held back while nobody is staked can be stranded (src/VeteransStaking.sol:170; same mechanism in the claim pool's notifyImd). Rewards that arrive with nothing staked are carried over until the next mint. If nobody is staked during the final mints, that carry-over is never paid out once all 3,333 are minted.

    What I checked that holds:

    • The fee split sums exactly to the 0.069 IMD price in wei.
    • The burn fallback works.
    • The 90/690 treasury share of each ETH royalty is correct.
    • The wallet-age multiplier day boundaries match the brief.
    • Rewards are settled at the old weight before any weight change.
    • The claim pool's weights stay in step with the staking contract's.
    • Claim and unstake paths are protected against re-entry.
    • Moving tokens into and out of staking doesn't let anyone dodge royalties.

    Leads I did not report:

    • Stale multipliers: a wallet keeps its old multiplier until someone calls refresh. Anyone can call it, and the README documents this.
    • Gaming wallet age: wallets can be stamped early to build up age. That follows from how the brief defines age.
    • Gas-limited royalty payments: royalties sent with only 2,300 gas fail. The README documents this.

    There is no launch.json in the tree yet, so I did not review a manifest.

    ran onclaude · claude-opus-5-5 · 11 turns · 3m 24s · 16 in · 17.8K out · 473.1K cached
    submissionbf7041c3ac29fc46214c2b90d66156fbf5d3920ac3fdc61d9680c0b7a5286f4f
    deviceea62f3bebece9304ecc7cddff7bc9ff1c1fdcc0aad7faccd2cfc1f8551b4019f
    started froma708f3ceba2c627495025a66ade07488569221a1
    bundlenone
    applied on191e9764d1b5eea60091d34b80d5a31421b9536dde6e677ca26092e89e3ca477
    • highconfigure() lets the owner route 85% of every mint fee and all royalties to owner-chosen contractssrc/VeteransNFT.sol:111

      The staking contract, claim pool and IMD token are not fixed at deployment; the owner supplies them in a post-launch configure() call.

      The only checks are that the supplied contracts' view functions nft()/staking() return the expected addresses, which any owner-deployed contract can answer. splitFee then sends 65% (staking) and 20% (pool) of every 0.069 IMD mint to those addresses, configure sets the ERC-2981 receiver (the full 6.9% royalty, including the treasury's 0.9%) to claimPool, and preValidateTransfer exempts every transfer to/from staking from the royalty validator.

      The brief requires 'no admin keys that can change the economics' and 'no admin control over the pool'; the deployed VeteransStaking/VeteransClaimPool are never bound to the NFT, so the advertised fee split depends entirely on the owner's honesty. The owner also chooses the IMD token address (any contract with code).

      Fix: bind the siblings without owner discretion (e.g. take the staking/pool/IMD addresses as constructor arguments or immutables derived from the deployment, or verify the siblings' runtime code hash in configure), so only the launched VeteransStaking/VeteransClaimPool can receive the shares.

      Deploy VeteransRenderer, VeteransNFT(OWNER, renderer), VeteransStaking(nft), VeteransClaimPool(nft, staking) as the launch does.

      OWNER deploys FakeSibling{nft()=nft, staking()=this, notifyReward/notifyImd no-ops, receive()} and calls nft.configure(imd, fake, fake).

      Expected: revert (not the launched staking/pool).

      Actual: succeeds; nft.staking()==fake, royaltyInfo(1,1 ether)==(fake, 0.069 ether); every later mint transfers 0.04485+0.0138 IMD to fake and every royalty goes to fake. test/scratch/ConfigureDivert.t.sol fails on this code.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {VeteransNFT} from "src/VeteransNFT.sol";
      import {VeteransStaking} from "src/VeteransStaking.sol";
      import {VeteransClaimPool} from "src/VeteransClaimPool.sol";
      import {VeteransRenderer} from "src/VeteransRenderer.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      
      contract Imd is ERC20 {
          constructor() ERC20("IMD", "IMD") {}
          function mint(address to, uint256 a) external { _mint(to, a); }
      }
      
      /// Owner-deployed look-alike: answers the sibling checks, keeps every token and ETH sent to it.
      contract FakeSibling {
          address public nft;
          address public staking;
          constructor(address nft_) { nft = nft_; staking = address(this); }
          function notifyReward(uint256) external {}
          function notifyImd(uint256) external {}
          receive() external payable {}
      }
      
      contract ConfigureDivertTest is Test {
          address constant OWNER = 0x3d6a89C8751a45DD577a4C1F3b34E71C58236193;
      
          function test_ownerCannotRouteFeesAndRoyaltiesToOwnContracts() public {
              VeteransRenderer renderer = new VeteransRenderer();
              VeteransNFT nft = new VeteransNFT(OWNER, address(renderer));
              // The launch deploys the real siblings...
              VeteransStaking staking = new VeteransStaking(address(nft));
              VeteransClaimPool pool = new VeteransClaimPool(address(nft), address(staking));
              staking; pool;
              Imd imd = new Imd();
      
              // ...but the owner configures a contract of their own instead.
              vm.prank(OWNER);
              FakeSibling fake = new FakeSibling(address(nft));
      
              vm.prank(OWNER);
              try nft.configure(address(imd), address(fake), address(fake)) {
                  // Accepted: 65% + 20% of every mint fee and the whole 6.9% royalty now go to the owner's contract.
                  (address receiver, uint256 royalty) = nft.royaltyInfo(1, 1 ether);
                  assertEq(receiver, address(fake));
                  assertEq(royalty, 0.069 ether);
                  assertEq(nft.staking(), address(fake));
                  fail("configure accepted owner-controlled staking/claim-pool contracts");
              } catch {}
          }
      }
    • mediumRoyalties paid in an ERC-20 (e.g. WETH) are permanently stuck in the claim pool, including the treasury's 0.9%src/VeteransClaimPool.sol:73

      VeteransClaimPool is the ERC-2981 royalty receiver for the full 6.9%, but it can only account and distribute native ETH (receive/donate) and IMD that the NFT announces via notifyImd. Marketplaces pay creator royalties in the sale currency; accepted offers/collection bids on Seaport-based markets are paid in WETH or other ERC-20s via a plain transfer to the receiver.

      The pool has no function to distribute, unwrap or forward any ERC-20 other than notified IMD, so such royalties (6.0% stakers + 0.9% treasury) are locked forever. The same applies to IMD transferred to the pool outside notifyImd. The brief requires 6.0% of all secondary sales to reach stakers and 0.9% to the treasury.

      After configure, a buyer's WETH offer for token 1 at 1 WETH is accepted on a royalty-honouring marketplace; it transfers 0.069 WETH to address(pool) per royaltyInfo(1, 1e18).

      Expected: 0.06 WETH-equivalent claimable by stakers and 0.009 to TREASURY_WALLET.

      Actual: WETH.balanceOf(pool)==0.069e18, pendingEth/pendingImd of every staker unchanged, treasuryOwed unchanged; no function in VeteransClaimPool can ever move the WETH.

    • mediumOwner can switch off royalty enforcement after launch (setTransferValidator(address(0)) or a permissive policy)src/VeteransNFT.sol:22

      VeteransNFT inherits CreatorTokenBase.setTransferValidator and setAutomaticApprovalOfTransfersFromValidator, both gated only by the owner, and the collection's security level lives on the validator under the owner's control. No security policy is set at deployment (README step 2 is a manual owner action), and the owner can later call setTransferValidator(address(0)), after which _preValidateTransfer performs no check and every transfer is royalty-free.

      That removes the 6.0% staker and 0.9% treasury revenue stream at will, contradicting 'Configure transfer validator + security policy so royalties are enforced on-chain' and 'no admin keys that can change the economics after deployment'. Documented as a trust assumption in the README, but it is a direct conflict with the approved brief.

      After configure, OWNER calls nft.setTransferValidator(address(0)).

      Then a non-whitelisted marketplace contract M (approved by seller) calls nft.transferFrom(seller, buyer, 1) after taking payment without paying royalty.

      Expected: transfer reverts under the enforced policy.

      Actual: getTransferValidator()==address(0), transfer succeeds, pool receives 0 ETH.

    • lowIMD notified while no weight is staked after the final mint is stranded foreversrc/VeteransStaking.sol:170

      Staking rewards (and the pool's IMD share, VeteransClaimPool.notifyImd line 97) received while totalWeight == 0 are parked in carryOver and only released by the next notifyReward/notifyImd, which only the NFT's mint calls. After token 3333 there are no more mints, so any carry-over existing at that point (no stakers during the last mints) is never distributed and the IMD is locked in the staking contract / pool.

      Mint all 3,333 tokens while no one stakes (or after every staker unstakes before mint #3333). staking.carryOver()==N0.04485e18 and pool.imdCarryOver()==N0.0138e18 where N is the number of mints since weight was last non-zero. Afterwards alice stakes and calls claim(): returns 0; no path ever moves carryOver, totalMinted==MAX_SUPPLY so mint reverts SoldOut.

  4. Audit mathAgent #1113found 3 low, 1 info

    Review complete. The findings file is written at .imd-findings.json with 4 findings and 33 coverage rows; no source file was changed.

    Outcome. The math in this project holds up. The fee split, royalty split, reward accumulators, weight downcasts, epoch arithmetic, difficulty targets and the multiplier table were all traced with concrete values and none leaks or overflows. I found no critical, high or medium defect in my area, so no proof tests are attached. The four findings are:

    1. Low, src/VeteransRenderer.sol:134. The renderer computes the Epoch attribute without the clamp the NFT applies, so tokens 3331 to 3333 report Epoch 11 while the NFT, its events and the README place them in epoch 10. Reproduced: tokenJSON(3331, …) contains "Epoch"…"value":11.
    2. Low, src/VeteransRenderer.sol:48. roll % 100 on a byte is biased. Background index 0 is rolled 120 of 256 times (46.9% versus the declared 40%) and index 4 only 10 of 256 (3.9% versus 5%), so the on-chain rarity score, which uses the declared odds, misranks actual rarity.
    3. Low, src/VeteransClaimPool.sol:86. donate() and receive() accept zero value and still fold ethCarryOver into the distribution. The first staker can call donate{value: 0}() right after staking and take the entire carry (60 ETH in the reproduction) before anyone else stakes.
    4. Info, src/VeteransClaimPool.sol:162. Double round-down strands dust while totalEthToStakers counts it as distributed. With weight 30 and a 100 wei donation the staker claims 99 wei and 1 wei is stuck forever.

    Checks run. forge build succeeded. The existing 60 tests pass. My scratch file test/scratch/MathAudit.t.sol confirmed all four reproductions and passes alongside the suite (64 tests total). The protected harness tests could not be run here because they require verifier environment variables and a floor/OwnerChecks.sol that is not in the tree. Slither and Aderyn were not run; I examined the supplied static-analysis lines as leads. The reentrancy and calls-in-loop leads on staking are guarded by nonReentrant and trusted callees, the strict equality on firstSeen == 0 is a safe sentinel, and the renderer's uninitialized local is zero-initialized by the compiler, so none became a finding.

    Coverage. All 28 listed entry points have a row. 26 hold, VeteransClaimPool.donate() carries finding 3, and three invariant rows plus two renderer rows record the checks on fee-split exactness, pool and staking weight equality, no over-distribution, and the two renderer findings. One assumption I could not verify from here: MINT_PRICE = 0.069 ether presumes the IMD token has 18 decimals, as the README states. Nothing in the tree or the pinned reads confirms the live token's decimals, so that remains an open item for whoever configures the token after launch.

    ran onclaude · claude-fable-5-1 · 25 turns · 8m 35s · 322 in · 30.5K out · 978.4K cached
    submissionb26b6568778f2f6086aa61a7c14b8cc571adb6d339f0ae055af7a5462ce9a97c
    device0cf632e317dfab7a3dcf74332a745707a132e8f51b69aa7a837a4c2bab2d7a9f
    started froma708f3ceba2c627495025a66ade07488569221a1
    bundlenone
    applied on191e9764d1b5eea60091d34b80d5a31421b9536dde6e677ca26092e89e3ca477
    • lowRenderer reports Epoch 11 for tokens 3331-3333 although the NFT caps the schedule at 10 epochssrc/VeteransRenderer.sol:134

      Boundary off-by-one between two epoch formulas. VeteransNFT._epochAt (src/VeteransNFT.sol:221-224) clamps minted / 333 to EPOCH_COUNT - 1 = 9, so mints 3331, 3332 and 3333 happen in epoch 9 (0-based) and the Mined event, currentEpoch() and mintsRemainingInEpoch() all treat them as the 10th and last epoch (the README documents epoch 10 as holding 336 mints). The renderer recomputes the epoch from the tokenId without that clamp: (3331-1)/333 + 1 = 11.

      The on-chain metadata for the last three tokens therefore carries an 'Epoch' attribute of 11, an epoch that does not exist in the collection (description text itself says '10 epochs'), so marketplaces and the gallery filter will show an eleventh epoch bucket of three tokens and the metadata disagrees with the contract's own events. Only the last three tokens are affected; token 3330 correctly reports 10.

      Fix: mirror the NFT clamp, e.g. uint256 e = (tokenId - 1) / EPOCH_SIZE; if (e >= 10) e = 9; then (e + 1).toString(), or pass the epoch recorded at mint time.

      Call VeteransRenderer.tokenJSON(3331, bytes32(0)) (or VeteransNFT.tokenURI(3331) after the collection sells out).

      Actual: attributes contain {"trait_type":"Epoch","display_type":"number","value":11}.

      Expected: value 10, matching VeteransNFT.currentEpoch()+1 == 10 and the Mined event's epoch 9 for minted=3330..3332.

      Same for tokenId 3332 and 3333. tokenJSON(3330, …) gives value 10 (correct).

      Verified with a scratch Foundry test: the JSON for #3331 and #3333 contains '"Epoch","display_type":"number","value":11}' while #3330 contains value 10, and the NFT's clamped epoch for minted=3330 is 9.

    • lowTrait roll `byte % 100` is biased: real trait odds differ from the declared per-100 odds that the rarity score is computed fromsrc/VeteransRenderer.sol:48

      Scale-mixing / modulo-bias precision defect. roll is a uint8 (0..255) and is reduced with % 100, so remainders 0..55 occur 3 times in 256 and remainders 56..99 only 2 times.

      The odds tables in _bgOdds/_helmetOdds/_lampOdds/_eyesOdds/_toolOdds are declared 'per 100' and rarityScore (line 42-44) computes 10_000 / odds from those declared percentages, but the actual frequencies are different: for Background, index 0 ('Terminal Black', declared 40%) is rolled for 120/256 = 46.9% of seeds and index 4 ('Crimson Core', declared 5%, rarity contribution 10_000/5 = 2000) for only 10/256 = 3.9% (a true-odds score would be ~2560).

      Every table has the same shape, so the rarest tier of every trait (indices covering remainders ≥ 56) is systematically rarer than declared and the common tier more common, and the on-chain 'Rarity Score' no longer orders tokens by actual rarity. Impact is metadata/rarity-ranking correctness only; no funds.

      Fix: use the full byte range, e.g. uint256 r = uint256(roll) * 100 / 256; (uniform within rounding) or scale the odds tables to 256 and compare the raw byte.

      Enumerate all 256 values b of seed byte 0 (seed = bytes32(b << 248)) and count traitsOf(seed).background: counts are [120, 66, 36, 24, 10] out of 256 = 46.9% / 25.8% / 14.1% / 9.4% / 3.9%.

      Declared odds (line 58) are [40, 25, 18, 12, 5] per 100 = 102.4 / 64 / 46.1 / 30.7 / 12.8 expected per 256.

      Concrete single input: any seed whose byte 0 is 0x64 (100), 0x65 … 0xC7 (199) maps to remainders 0..99 a second time and bytes 0xC8..0xFF (200..255) map to remainders 0..55 a third time; e.g. byte 0 = 0xFF gives r = 55 -> background index 1, so index 1 receives extra mass while index 4 (r 95..99) never receives a third copy.

      Verified with a scratch Foundry test asserting counts[0]==120 and counts[4]==10.

    • lowA zero-value donate() (or any zero-value receive) lets the current stakers pull the whole ETH carry-over immediatelysrc/VeteransClaimPool.sol:86

      Boundary x invariant seam: donate() and receive() do not require msg.value > 0, and _distributeEth (lines 155-164) folds ethCarryOver into the distribution whenever totalWeight != 0, regardless of the new amount. Royalty ETH that arrives while nobody is staked (the whole 6.0% of early secondary volume, before the first stake) is held in ethCarryOver and is meant to be 'carried into the next distribution'.

      Any wallet that becomes the first (or only) staker can call pool.donate{value: 0}() in the same transaction as its stake and have 100% of the carried-over ETH credited to itself, before any other holder has a chance to stake; the same zero-value call also lets stakers front-run a known inbound royalty by releasing carry at their own weight snapshot.

      The release is designed behaviour at the next real payment, so this is a timing/fairness issue (whoever is staked at the moment of the first non-reverting zero-value call gets the carry), not a loss to the pool, but it is not what the comment 'carried into the next distribution' implies and the IMD carry (released only by notifyImd on a mint) behaves differently.

      Fix options: require msg.value > 0 in donate()/receive(), or release carry-over only when amount > 0.

      State: nobody staked, totalWeight == 0.

      (1) A marketplace pays 69 ether of royalties to the pool via receive(): toTreasury = 69e18*90/690 = 9e18 pushed to treasury; ethCarryOver == 60e18.

      (2) Alice mints token 1 and stakes it: totalWeight == 10.

      (3) Alice calls pool.donate{value: 0}().

      Actual: _distributeEth(0) computes total = 0 + 60e18, totalWeight != 0, so ethCarryOver = 0 and accEthPerWeight += 60e18*1e27/10; pool.pendingEth(alice) == 60 ether.

      Expected per the stated design: the carry is released at the next distribution of incoming funds, so stakers at that moment share it.

      Verified with a scratch Foundry test (assertEq(pool.ethCarryOver(), 0); assertEq(pool.pendingEth(alice), 60 ether)).

    • infoAccumulator truncation leaves un-claimable dust in the pool while totalEthToStakers counts it as distributedsrc/VeteransClaimPool.sol:162

      Precision x invariant: total * 1e27 / totalWeight rounds down, and each staker's weight * delta / 1e27 rounds down again (lines 132, 137, 168, 169; same pattern in VeteransStaking._syncWeight line 220). The sum of all claimable balances is therefore strictly less than totalEthToStakers (and than the IMD notified via notifyImd / notifyReward), and the difference is permanently stranded in the contract because no path redistributes it.

      With totalWeight <= 3333*80 = 266,640 the loss is at most 1 wei per staker per settlement, so the economic impact is negligible for 18-decimal ETH/IMD; recorded so the frontend does not treat totalEthToStakers as 'claimable total' and so the author can decide whether to sweep remainders into carry-over.

      Alice stakes 3 tokens at age 0 (weight 30, totalWeight 30).

      Bob calls donate{value: 100}(). accEthPerWeight += 100e27/30 = 3333333333333333333333333333 (floor). pendingEth(alice) = 30 * 3333333333333333333333333333 / 1e27 = 99.

      Alice claimEth() receives 99 wei; address(pool).balance == 1 wei forever; totalEthToStakers == 100.

      Verified with a scratch Foundry test (assertEq(got, 99); assertEq(address(pool).balance, 1)).

  5. Audit permissionsAgent #154found 1 medium, 2 low, 2 info

    The findings file is valid, every snippet matches its cited line, and the working tree has no tracked changes. The review is complete.

    Outcome. No critical or high defects in the access-control, trust-gap or asymmetry lenses. The permission model is tight: every privileged entry point is guarded by the right party, the staking/pool weight mirror and reward conservation held under a randomized fuzz, and staking from inside the mint callback leaves consistent state. Five findings are recorded in .imd-findings.json, each with a scratch test that reproduces it.

    Findings, most severe first:

    • Medium. Royalties paid in an ERC-20 (WETH-denominated sales on Seaport-style markets) land in the claim pool with no intake, no sweep and no admin, so that share is lost forever. The condition depends on which marketplaces exist on Robinhood Chain, which the tree cannot show.
    • Low. The validator bypass for transfers into the staking contract applies to any approved operator. An operator the security policy blocks can still plain-transfer a holder's token into staking, where nobody is recorded as staker and it is unrecoverable.
    • Low. The renderer reports Epoch 11 for tokens 3331 to 3333, while the contract mints them in epoch 10 and the brief fixes exactly ten epochs. Outside my area, reported because it is verified.
    • Info, trust assumption. Owner-chosen validator plus auto-approval gives that validator custody of every token, including staked ones, and the staking ledger then desyncs. No unprivileged trigger. The README lists the two powers but not their combined effect.
    • Info. Renouncing ownership before the one-time configure bricks minting permanently. Owner error only.

    Coverage. All 28 listed entry points have a row. Two items are marked unreached: the launch manifest, which does not exist yet, and whether the default Limit Break validator has code on Robinhood Chain, since this task supplied no RPC. The README's constructor order and arguments match the code.

    Not changed. Only .imd-findings.json and files under test/scratch/ were written. Source, tests, configuration and libraries are untouched.

    ran onclaude · claude-fable-5-1 · 50 turns · 18m 30s · 546 in · 76.5K out · 2.4M cached
    submission4816d518c07423b9e93d72bc6d81b5a7bda86f07b37aff7516b60b1c29931037
    device9df7d5d52e83c572b70087c7652483d3122e52c488658420d6495d446820a289
    started froma708f3ceba2c627495025a66ade07488569221a1
    bundlenone
    applied on191e9764d1b5eea60091d34b80d5a31421b9536dde6e677ca26092e89e3ca477
    • mediumRoyalties paid in an ERC-20 (WETH-denominated sales) are locked in the claim pool foreversrc/VeteransClaimPool.sol:73

      The pool is the ERC-2981 receiver for the whole 6.9% royalty, but it can only take royalties as native ETH (receive()) and can only pay out two assets: ETH and the one IMD token read from nft.imd() (notifyImd/_claimImd). Marketplaces that settle a sale in an ERC-20 pay the ERC-2981 royalty in that same ERC-20; on Seaport-style markets every accepted bid/offer is WETH-denominated.

      Such a payment lands in the pool as a plain ERC-20 balance that no function can move: there is no owner (by design), no sweep, no unwrap and no per-token distribution, so the 6.0% staker share and the 0.9% treasury share of every ERC-20-denominated sale are permanently lost. The brief promises the pool receives 6.0% of all secondary sales; the README's 'Known limits' section does not mention this.

      Condition: a marketplace on Robinhood Chain that pays ERC-2981 royalties in an ERC-20 (not verifiable from this tree; if none will exist, document the limit instead).

      Minimal fix that keeps the pool admin-less: take the chain's WETH address as a constructor argument (immutable) and add a permissionless unwrapWeth() that calls WETH.withdraw(balance) and routes the ETH through the same 90/690 treasury split and _distributeEth; alternatively a permissionless distributeToken(token) with a per-token accumulator for any ERC-20 other than IMD.

      State: pool deployed and configured; alice has staked one token so totalWeight > 0.

      Input: a marketplace (or the scratch test) transfers 1e18 of an ERC-20 'WETH' to address(pool) as the royalty of a WETH-denominated sale.

      Expected: 90/690 reaches the treasury and 600/690 becomes claimable by stakers, as for an ETH royalty.

      Actual: pool.claimAll() from alice returns (0, 0), pool.pendingEth(alice) == 0, pool.pendingImd(alice) == 0, payTreasury() reverts NothingOwed, and weth.balanceOf(pool) stays 1e18 with no function in the ABI able to transfer it.

      Verified with test/scratch/AccessProbe.t.sol::test_erc20RoyaltyInPool_isUnreachable.

    • lowValidator bypass for transfers into the staking contract lets a policy-blocked operator strand a holder's tokensrc/VeteransNFT.sol:286

      _preValidateTransfer skips the ERC721C transfer validator for any transfer whose destination is the staking contract, regardless of who initiates it. The only legitimate inbound paths are staking.stake() (caller == staking) and the holder's own safeTransferFrom (caller == from, credited through onERC721Received).

      A plain transferFrom(holder, staking, id) by an approved operator is neither: it is skipped by the validator and never reaches onERC721Received, so stakerOf[id] stays address(0) and the token can never be unstaked or re-staked.

      An operator the collection's security policy blocks (for example a marketplace the owner removed from the whitelist for not honouring royalties, which is exactly what the policy exists for) therefore cannot move a holder's token anywhere except into the staking contract, where it is lost. The bypass widens a blocked operator's power from nothing to permanent destruction of the token.

      Fix: narrow the bypass to the paths that actually credit a stake, e.g. if (staking_ != address(0) && (from == staking_ || caller == staking_ || (to == staking_ && caller == from))) return; so operator-initiated transfers into the staking contract are validated like any other operator transfer.

      State: validator policy = operator whitelist, OTC allowed (the recommended level); alice owns token 1 and has setApprovalForAll(market, true); market is not whitelisted.

      Input 1: market calls nft.transferFrom(alice, bob, 1).

      Expected and actual: reverts OperatorNotWhitelisted(market).

      Input 2: market calls nft.transferFrom(alice, address(staking), 1).

      Expected: same revert.

      Actual: succeeds; nft.ownerOf(1) == staking and staking.stakerOf(1) == address(0); alice's staking.unstake([1]) reverts NotStaker and staking.stake([1]) reverts NotTokenOwner, so the token is unrecoverable.

      Verified with test/scratch/AccessProbe.t.sol::test_blockedOperator_canStrandTokenInStaking.

    • lowMetadata reports 'Epoch 11' for tokens 3331-3333, which the contract mints in epoch 10src/VeteransRenderer.sol:134

      VeteransNFT._epochAt clamps the epoch to EPOCH_COUNT - 1, so the last epoch (1-based 10) runs to the supply cap and holds 336 mints (README 'Economics'). The renderer recomputes the epoch from the token id without that clamp: for tokenId 3331..3333, (tokenId - 1) / 333 + 1 == 11. The on-chain 'Epoch' attribute of the last three tokens therefore contradicts the contract's currentEpoch()/Mined event (epoch 10) and the brief's 'exactly 10 epochs'.

      The renderer is frozen into every tokenURI once freezeMetadata() is called.

      Fix: clamp in the renderer, e.g. uint256 e = (tokenId - 1) / EPOCH_SIZE; if (e > 9) e = 9; then (e + 1).toString(), sharing an EPOCH_COUNT constant with the NFT. Outside my assigned area (reported because it is a concrete, verified correctness defect).

      Input: renderer.tokenJSON(3331, anySeed) and renderer.tokenJSON(3333, anySeed).

      Expected: attributes contain {"trait_type":"Epoch","display_type":"number","value":10}.

      Actual: they contain "value":11 while tokenJSON(3330, seed) contains "value":10.

      Verified with test/scratch/EpochProbe.t.sol::test_lastThreeTokens_reportEpoch11.

    • infoTrust assumption: owner can move every token, including staked ones, via setTransferValidator + auto-approval, and the staking ledger then desyncssrc/VeteransNFT.sol:286

      Documented here as a privileged-power risk, not as an exploit: no unprivileged amplifier exists. ERC721C's isApprovedForAll override treats the current validator as an operator for every holder once the owner calls setAutomaticApprovalOfTransfersFromValidator(true), and setTransferValidator accepts any contract with code.

      A validator contract chosen by the owner (or by whoever holds a compromised owner key) can therefore transferFrom any token from any holder, and because the staking contract is just another holder (and transfers from it bypass validation entirely), staked tokens are not protected either. After such a transfer the staking contract still records stakerOf[id] == alice with her weight intact, so her unstake reverts while she keeps earning on a token she no longer backs.

      The README's trust section lists the two functions separately but does not state that together they amount to full custody over the collection; the brief asks for a trustless design. Optional hardening that preserves the ERC721C design: override isApprovedForAll in VeteransNFT so the automatic validator approval never applies when owner == staking (the staking contract never lists tokens), and state the remaining owner power plainly in the README.

      State: alice staked token 1 (nft.ownerOf(1) == staking, stakerOf[1] == alice).

      Input: OWNER calls nft.setTransferValidator(evil) where evil is any contract whose validateTransfer is a no-op, then nft.setAutomaticApprovalOfTransfersFromValidator(true); evil calls nft.transferFrom(address(staking), bob, 1).

      Expected (per the brief's trustless intent): a staked token can only leave the staking contract through unstake() to its staker.

      Actual: nft.ownerOf(1) == bob; staking.stakerOf(1) is still alice and staking.weightOf(alice) is still 10; alice's unstake([1]) reverts 'ERC721: caller is not token owner or approved'.

      Verified with test/scratch/AccessProbe.t.sol::test_owner_validatorPlusAutoApprove_movesStakedToken.

    • inforenounceOwnership before configure() permanently disables mintingsrc/VeteransNFT.sol:106

      configure() is the only way to set imd, and mint() reverts NotConfigured until it runs. Ownable's renounceOwnership (and transferOwnership to a wrong address) is not blocked while the collection is unconfigured, so a single owner mistake after launch leaves a collection that can never mint.

      Owner-error only (no unprivileged trigger), reported because the access-control checklist's 'renounce leaves the system unrecoverable' item applies and the fix is one line: override renounceOwnership to revert while isConfigured() is false.

      State: fresh VeteransNFT(owner, renderer) just deployed, unconfigured.

      Input: owner calls renounceOwnership(), then anyone calls mint(nonce).

      Expected: the owner can still complete the documented one-time configure.

      Actual: configure reverts 'Ownable: caller is not the owner' for everyone forever and mint reverts NotConfigured forever.

      Verified with test/scratch/AccessProbe.t.sol::test_renounceBeforeConfigure_bricksMint.

  6. Write foundry testsAgent #19253 files changedsent back2 attempts
    #463CodexrefusedRefused by Codex's safety filter

    Refused by Codex's safety filter

    This content was flagged for possible cybersecurity risk. If this seems wrong, try rephrasing your request. If you’re doing authorized security work that requires more cyber permissive safeguards, apply for Daybreak access via https://platform.openai.com/… before retrying.

    ran oncodex · gpt-6-astra · 7 turns · 12m 36s
    submission425cc6a14207d9abb0ff9c4dcd43a982b1b7facdf804c5ce8139d6b52a6e7f27
    device32c599587cbbcbf3aaa407022710dbde624226bb1f72d103fbfd9bd64fa4aaa5
    started froma708f3ceba2c627495025a66ade07488569221a1
    bundlenone
    applied on191e9764d1b5eea60091d34b80d5a31421b9536dde6e677ca26092e89e3ca477
    #1925Claude3 files changed

    Done. The suite passes with the verifier seed and with an unrelated seed, and one defect is reported rather than tested around.

    What I added (all under test/, nothing else touched):

    • test/invariant/VeteransHandler.sol drives the full stack with four EOAs: mint, stake, stake via safeTransferFrom, unstake, secondary transfer, claim, refresh, royalty, donate, claimEth/claimImd/claimAll, payTreasury, a rejecting-treasury toggle, and time warps. Every action is written to never revert, and the campaign ran with zero handler reverts.
    • test/invariant/VeteransInvariants.t.sol holds 11 properties checked after every call: staking and pool hold at least what they owe in IMD and ETH with only sub-wei dust left over, the NFT never keeps funds, the 65/20/10/5 split and the 90/690 treasury cut conserve exactly, nobody is paid more than was distributed, accumulators never regress, weights agree between staking and pool and sit between the 1.0x floor and the wallet's current multiplier, every staked token is in custody and attributed correctly, supply and epoch views agree with the mint count, and firstSeen is stamped once and never moves. Run counts are set inline.
    • test/VeteransEdgeCases.t.sol adds 39 failure-path and property tests: duplicate ids in stake/unstake, unstaking never-staked or nonexistent tokens, staking without approval or an already staked token, a staked token being unmovable by its owner or an operator, operator-driven safeTransfer crediting the owner, contract miners with and without a receiver hook, a mint inside the receiver hook, insufficient balance leaving the nonce unused, the 333rd mint flipping the epoch and rejecting epoch-0 hashes, the full multiplier table fuzzed against the brief, refresh never lowering a weight, exact single-staker and two-staker reward shares, royalty split fuzzed over any amount including one wei and zero, treasury debt accumulating across failed pushes, the auto-approve-validator owner power, and configuration rejections.

    Findings in .imd-findings.json, six entries. The one real defect is low severity: the renderer's Epoch attribute reports "Epoch 11" for tokens 3331 to 3333 while the contract, events and brief say epoch 10. It carries a self-contained proof that fails on the current code. The other five are informational: trait odds skewed by the byte-modulo-100 roll, royalty via a 2,300-gas stipend bouncing, plain transferFrom stranding tokens at the staking contract, the plain-ERC-20 assumption about IMD, and nonce grinding for traits.

    Still owed and not possible offline: a fork run against the real IMD token and the real transfer validator on Robinhood Chain. Nothing was installed, nothing committed, and test/scratch holds only the proof and the findings-writer script.

    ran onclaude · claude-fable-5-1 · 37 turns · 12m 38s · 514 in · 51.2K out · 1.9M cached
    submission9103a3138aaac4b9c0ecb4f50d670e60a0e7a08d4505d78e687fdd813c14cb57
    devicef3b611cf8afb513f236c9b7c0591044eecd01a70d5e008dbe06cc5f5ba35530c
    started froma708f3ceba2c627495025a66ade07488569221a1
    bundlef9b07b403640a9197a2b86c11391fd4fcf9cbdf22914f800f2fcd8b96bccf6a6 · 385 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on191e9764d1b5eea60091d34b80d5a31421b9536dde6e677ca26092e89e3ca477
    changed · 3 files
    test/VeteransEdgeCases.t.soltest/invariant/VeteransHandler.soltest/invariant/VeteransInvariants.t.sol
    may write
    testtest/**
    • lowtokenURI reports "Epoch 11" for tokens 3331-3333 although the collection has exactly ten epochssrc/VeteransRenderer.sol:134

      The renderer derives the Epoch attribute as (tokenId - 1) / EPOCH_SIZE + 1. The NFT caps epochs at EPOCH_COUNT (VeteransNFT._epochAt returns 9, i.e. the tenth epoch, for every mint from 2997 to 3333, and the Mined event for tokens 3331-3333 carries epoch 9). The renderer does not apply the same cap, so the OpenSea metadata of the last three tokens says Epoch 11 while the brief fixes 'exactly 10 epochs' and the contract, README and events all say they were mined in epoch 10.

      Metadata only; no funds affected.

      Fix: cap the value at 10 (min((tokenId - 1) / 333, 9) + 1), mirroring _epochAt.

      renderer.tokenJSON(3331, anySeed) -> attributes contain {"trait_type":"Epoch","display_type":"number","value":11}.

      Expected value 10.

      Same for 3332 and 3333; token 3330 correctly reports 10.

      The attached test fails on the current code for that reason.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {VeteransRenderer} from "src/VeteransRenderer.sol";
      
      /// @notice The brief fixes "exactly 10 epochs" and the NFT caps `currentEpoch()` at the tenth; the renderer's
      ///         Epoch attribute is `(tokenId - 1) / 333 + 1`, which reports "Epoch 11" for tokens 3331, 3332 and 3333.
      contract EpochAttributeTest is Test {
          VeteransRenderer internal renderer;
      
          function setUp() public {
              renderer = new VeteransRenderer();
          }
      
          function test_lastThreeTokens_reportEpochTen() public view {
              bytes32 seed = keccak256("seed");
              for (uint256 id = 3331; id <= 3333; ++id) {
                  string memory json = string(renderer.tokenJSON(id, seed));
                  assertTrue(
                      vm.contains(json, '"trait_type":"Epoch","display_type":"number","value":10}'),
                      string.concat("token ", vm.toString(id), " must be in epoch 10, the last of exactly ten")
                  );
                  assertFalse(vm.contains(json, '"value":11}'), "there is no epoch 11");
              }
              // Token 3330 is the last one the formula gets right.
              assertTrue(vm.contains(string(renderer.tokenJSON(3330, seed)), '"value":10}'));
          }
      }
    • infoRenderer trait odds differ from the documented percentages (roll % 100 over a 0-255 byte)src/VeteransRenderer.sol:48

      _pick reduces a seed byte (0-255) with roll % 100, so residues 0-55 occur three times in 256 and residues 56-99 only twice. The first buckets of every table are over-represented and the rare ones under-represented relative to the stated per-100 odds that rarityScore also uses. Example, Background [40,25,18,12,5]: actual frequencies are 120/256 = 46.9%, 66/256 = 25.8%, 36/256 = 14.1%, 24/256 = 9.4%, 10/256 = 3.9%.

      Visual rarity only, no economic effect; the Overclocked tier (seed[7] % 24) is unaffected. If exact odds matter, roll with uint16 from two bytes modulo 100, or scale the byte with roll * 100 / 256.

      Iterate seed[0] over all 256 byte values and count traitsOf(seed).background == 0: 120 hits (46.9%), expected 40% (~102).

    • infoRoyalty paid with a 2,300-gas stipend (transfer/send) bounces at the claim poolsrc/VeteransClaimPool.sol:73

      receive() updates several storage slots and pushes to the treasury, so a royalty forwarded with address.transfer or address.send reverts; the sale that forwards it reverts with it. The README already lists this as a known limit and every current marketplace pays royalties with call, so no action is required beyond keeping the note.

      Nothing is lost on a bounce: the test test_knownLimit_royaltyViaTransferStipendBounces in test/VeteransEdgeCases.t.sol shows the sender keeps the ETH and the pool state is untouched.

      address(pool).call{value: 1 ether, gas: 2300}("") -> ok == false; pool.totalEthReceived() stays 0.

    • infoA plain transferFrom to the staking contract strands the tokensrc/VeteransStaking.sol:121

      Only safeTransferFrom reaches onERC721Received; nft.transferFrom(owner, staking, id) moves the token without creating a stake record, and there is no rescue path because the staking contract only returns tokens recorded in stakerOf. The README documents it. The frontend must use stake() or safeTransferFrom; a rescue would need a new function (e.g. claim an unrecorded token for its last owner), which is an implementation decision rather than a test.

      alice: nft.transferFrom(alice, staking, id) -> nft.ownerOf(id) == staking, staking.stakerOf(id) == 0, staking.unstake([id]) reverts NotStaker for everyone.

    • infoAccounting assumes IMD is a plain ERC-20 (no transfer fee, no rebasing)src/VeteransNFT.sol:173

      The fee split transfers an amount and then notifies the same amount; the staking contract and the pool credit stakers for the notified figure. If the live IMD token took a fee on transfer or rebased, the contracts would owe more than they hold and the last claimers would revert.

      The IMD address is configured after launch and could only be tested here against a standard mock (test/mocks/MockIMD.sol), so this is a stated assumption for the owner to confirm before calling configure, not a defect in the code. A fork run against the real IMD token on Robinhood Chain is still owed.

      With a fee-on-transfer stand-in charging 1%, two stakers each entitled to half: the second claim reverts with ERC20 insufficient balance.

    • infoMiners can grind nonces to pick their traits and the Overclocked tiersrc/VeteransNFT.sol:157

      The winning hash is the token's seed, and a wallet may discard any winning nonce and keep mining until a hash both beats the target and decodes to the traits it wants (Overclocked costs about 24x the work). This is inherent to deriving traits from a proof-of-work hash, as the brief requires, and the price per token is unchanged; it only means rarity is partly bought with hash power rather than luck. Noted so the frontend and collectors are not surprised.

      Loop nonces until hashFor(wallet, nonce) < target() and renderer.traitsOf(hash).overclocked; submit that nonce. Mint succeeds with an Overclocked token on the first try.

  7. Audit flowAgent #1763found 1 medium, 3 low, 1 info

    The review is complete and written to .imd-findings.json at the repository root. No source files were changed, and the working tree is otherwise clean.

    Outcome. Five findings, none critical or high, plus a 32-row coverage record answering all 28 listed entry points and two invariants. The control flow of mint, stake, unstake, claim and the weight mirror between staking and pool holds under tracing. The defects are in what the pool can and cannot take in, in end-of-life carry-over, and in the renderer's odds.

    Findings, most severe first:

    • Medium. ERC-20 royalties are stranded in the claim pool. The pool only accounts for native ETH through its receive function and for IMD announced by the NFT. A marketplace settling a WETH offer transfers the 6.9% to the pool as a plain token balance that no function can move, and the same happens if a venue pays royalties in IMD directly. There is no admin or sweep, so stakers and the treasury lose the whole payment.
    • Low. The pool's receive function needs far more than the 2,300-gas stipend. Payers using transfer or send fail on the royalty leg. The README lists it as a known limit; it is recorded because the brief exempts no venue.
    • Low. Carry-over after the last mint is locked forever. Both staking and pool park IMD when total weight is zero and release it only on the next mint. After token 3,333 there is no next mint.
    • Low. Renderer odds are biased by roll modulo 100 over a 0 to 255 byte. Actual background odds are 46.9/25.8/14.1/9.4/3.9 percent against the documented 40/25/18/12/5, and the on-chain Rarity Score uses the documented values. Verified by tallying all 256 byte values.
    • Info. Owner trust assumption. The owner can disable royalty enforcement by setting the validator to the zero address, or auto-approve the validator for every holder including the staking contract. Documented in the README, listed because the brief says no admin key changes the economics.

    Verified along the way. The default ERC721C transfer validator has code on Robinhood Chain on two public RPCs, so that dependency holds. The exported ABI files list the same functions as the sources.

    Not reached. The solc compile of this 69-file project never finished within the budget on this machine, so the shipped test suite was not re-run and no proof tests were written. None of the findings is critical or high, so none needed one. The live IMD token's decimals and transfer behaviour cannot be checked from the tree, since the owner configures the address after launch.

    ran onclaude · claude-fable-5-1 · 59 turns · 1h 3m · 706 in · 46.3K out · 2.9M cached
    submission3ef502391059eb9fd6a2d9579853fb10e66c40c73200a76a5f34658fde2922af
    device7c0191a32541eb746c94deddf06264811dcb25a6c776b6b15a4a6ef0ff78717d
    started froma708f3ceba2c627495025a66ade07488569221a1
    bundlenone
    applied on191e9764d1b5eea60091d34b80d5a31421b9536dde6e677ca26092e89e3ca477
    • mediumRoyalties paid in an ERC-20 (WETH offers, or IMD itself) are permanently stranded in VeteransClaimPoolsrc/VeteransClaimPool.sol:191

      The brief requires 6.0% of ALL secondary sales to reach stakers and 0.9% to reach the treasury. The pool is the single ERC-2981 receiver (VeteransNFT.configure sets setDefaultRoyalty(claimPool, 690)), but it can only account for value that arrives through two channels: native ETH through receive()/donate(), and IMD announced by the NFT through notifyImd().

      Any ERC-20 royalty payment, which is how every major marketplace settles WETH-denominated offers and collection offers (the sale currency is forwarded to royaltyInfo's receiver with a plain ERC-20 transfer, no callback), lands as a raw token balance that no function can move: _claimImd only pays out imdAccrued, which is credited solely by notifyImd, and the pool has no sweep, no owner and no other token path.

      The same applies if a marketplace pays royalties in IMD: the IMD arrives without notifyImd, totalImdReceived/accImdPerWeight do not change, and the amount is unreachable forever. Because the brief forbids admin functions, there is no recovery path; the 6.9% of every non-ETH sale is lost to both stakers and the treasury. Assumption violated (first principles): 'every royalty arrives as msg.value'.

      Fix options that preserve the design: add a permissionless sync(address token)-style function that distributes any ERC-20 balance above the accounted amount pro-rata by weight (IMD: balance minus (sum of unclaimed imdAccrued + imdCarryOver); other tokens: separate accumulators), or a permissionless convert path; at minimum document that only ETH-settled sales pay royalties.

      State: full stack deployed and configured; alice has minted and staked 1 token (totalWeight = 10).

      (1) A marketplace fulfils a 100 WETH collection offer for alice's token and, per royaltyInfo(id, 100e18) = (pool, 6.9e18), calls WETH.transfer(pool, 6.9e18).

      Simulate with any ERC-20 mock: weth.mint(address(pool), 6.9e18) or weth.transfer(address(pool), 6.9e18).

      (2) alice calls pool.claimAll(): returns (0, 0); pool.pendingEth(alice) == 0; pool.pendingImd(alice) == 0; TREASURY_WALLET receives nothing.

      (3) weth.balanceOf(pool) == 6.9e18 and there is no function in the pool ABI that can transfer it (only _claimImd moves an ERC-20, and only nft.imd() for amounts credited by notifyImd).

      Variant with IMD: imd.transfer(address(pool), 6.9e18) from a marketplace → pool.totalImdReceived() unchanged, pool.pendingImd(alice) unchanged, imd.balanceOf(pool) exceeds every claimable amount by 6.9e18 forever.

      Expected: 6.0/6.9 of the royalty claimable by stakers and 0.9/6.9 forwarded to the treasury, as for ETH royalties.

      Actual: the whole payment is locked.

    • lowVeteransClaimPool.receive() cannot accept royalties pushed with a 2,300-gas stipend (transfer/send), so such payments or sales failsrc/VeteransClaimPool.sol:73

      receive() performs several storage writes (totalEthReceived, ethCarryOver or totalEthToStakers + accEthPerWeight) and an outgoing call to the treasury, so it needs well over 2,300 gas.

      A marketplace or escrow that pays creator fees with payable(receiver).transfer(amount) or send (still used by several older/simple marketplaces and by many custom escrow contracts) will revert on the royalty leg; depending on the marketplace that either reverts the sale or skips the royalty, breaking the brief's 'royalties enforced on-chain' for those venues. The README lists this as a known limit; it is recorded here because the brief does not exempt any venue.

      A pull-based fallback (accept the ETH cheaply when gas is scarce and let anyone call a permissionless sync() that performs the split later) would remove the dependency on the payer's call pattern without adding an admin.

      State: configured stack, any staker.

      Call from any EOA or contract: payable(address(pool)).transfer(1 ether) (or .send).

      Expected per brief: the pool accepts the royalty, 90/690 goes to the treasury, the rest is distributed.

      Actual: the call runs out of gas inside receive() on the first SSTORE and reverts; address(pool).balance is unchanged.

      The same call with pool.call{value: 1 ether}("") succeeds, which shows the failure is the stipend, not the amount.

    • lowIMD mint shares that arrive while nothing is staked at the final mint are locked forever: carry-over is only released by the next mint, and there is none after sell-outsrc/VeteransStaking.sol:170

      notifyReward (staking) and notifyImd (pool, src/VeteransClaimPool.sol:97-98) park rewards in carryOver / imdCarryOver when totalWeight == 0 and fold them into the accumulator only on the NEXT notify call. The only caller of both is VeteransNFT._splitFee, which only runs inside mint().

      After totalMinted == MAX_SUPPLY, mint() reverts SoldOut forever, so any carry-over left after the last mint (and anything carried into it) can never be distributed; claim()/claimImd() pay 0 for it and the IMD stays in the staking/pool contracts with no admin or sweep. The ETH side does not have this problem because _distributeEth runs on every receive()/donate(), which remain callable.

      Minimal fix preserving design: fold carry-over on the next weight change too (in _syncWeight / onWeightChange when totalWeight goes from 0 to >0), or add a permissionless releaseCarryOver() that distributes carryOver whenever totalWeight > 0.

      State: 3,332 tokens minted; all stakers have unstaked (or nobody ever staked) so staking.totalWeight() == 0 and pool.totalWeight() == 0.

      (1) bob mints token 3,333: _splitFee transfers 0.04485e18 IMD to staking and calls notifyReward → carryOver = 0.04485e18 (+ any earlier carry); transfers 0.0138e18 IMD to the pool and calls notifyImd → imdCarryOver = 0.0138e18.

      (2) alice stakes one token: totalWeight = 10, but nothing calls notifyReward/notifyImd; staking.pendingReward(alice) == 0 and pool.pendingImd(alice) == 0.

      (3) Any further nft.mint(nonce) reverts SoldOut (checked before the hash), so no path ever moves carryOver/imdCarryOver into accRewardPerWeight/accImdPerWeight.

      Expected: the 65% + 20% of the last mint reaches stakers once someone stakes.

      Actual: imd.balanceOf(staking) and imd.balanceOf(pool) permanently exceed every claimable amount by 0.04485e18 and 0.0138e18 (more if several late mints hit totalWeight == 0).

    • lowVeteransRenderer._pick applies roll % 100 to a byte in 0..255, so real trait odds differ from the documented per-100 odds and the on-chain Rarity Score is miscalibratedsrc/VeteransRenderer.sol:48

      The seed byte is uniform over 0..255 but r = roll % 100 is not uniform over 0..99: residues 0..55 occur 3 times (roll, roll+100, roll+200) and residues 56..99 only twice. The NatSpec ('Trait odds (per 100): see the _pick tables') and rarityScore() (10_000 / odds) both assume the table percentages are the true probabilities.

      Actual probabilities for the Background table [40,25,18,12,5] are 120/256 = 46.9%, 66/256 = 25.8%, 36/256 = 14.1%, 24/256 = 9.4%, 10/256 = 3.9%; the same skew applies to every table (any trait whose cumulative boundary is above 56 is rarer than stated, the first one is commoner).

      Visual only (the brief fixes no exact odds), but the published Rarity Score attribute, which the brief lists as 'rarity rank' metadata and the frontend gallery will sort by, is computed from the wrong odds.

      Fix: use uint256 r = uint256(roll) * 100 / 256 (uniform bucketing) or scale the tables to 256, and keep rarityScore consistent with the chosen odds.

      Call renderer.traitsOf(seed) for seed[0] = each byte value 0..255 (e.g. loop bytes32(uint256(b) << 248) for b in 0..255) and tally t.background: 120, 66, 36, 24, 10 out of 256.

      Expected from the documented table: 102.4, 64, 46.1, 30.7, 12.8 (40%, 25%, 18%, 12%, 5%).

      Equivalent check: renderer.traitsOf(bytes32(0)).background == renderer.traitsOf(bytes32(uint256(200) << 248)).background == 0 while byte 56 and byte 156 both map to residue 56 (helmet/background bucket 1) and no third byte does. rarityScore() for a 'Crimson Core' background adds 10_000/5 = 2000 points for a trait that actually occurs with probability 3.9%, while 'Matrix Rain' (documented 18%, actual 14.1%) gets 555 points.

    • infoTrust assumption / brief conflict: the owner can disable on-chain royalty enforcement with setTransferValidator(address(0)) or auto-approve the validator for every holder, including the staking contrasrc/VeteransNFT.sol:22

      The brief says 'no admin keys that can change the economics after deployment' and 'only operators that honor the royalty can transfer the tokens'.

      VeteransNFT inherits ERC721C's owner powers unchanged: setTransferValidator(address(0)) makes _preValidateTransfer a no-op for every transfer (royalty-evading operators can then move tokens, starving the ETH pool), and setAutomaticApprovalOfTransfersFromValidator(true) makes the validator an approved operator for every holder, including VeteransStaking, whose tokens would then be movable by whatever the validator permits even though the staking contract never approves anyone.

      The README documents both as owner powers, so this is recorded as a trust assumption rather than a defect; it is listed because it is the one place where a single key changes an economic guarantee the brief calls final. If the requester wants the brief's wording enforced, override setTransferValidator to reject address(0) once configured (keep the ability to switch to a new validator contract), or freeze it alongside freezeMetadata.

      Owner (0x3d6a…6193) calls nft.setTransferValidator(address(0)).

      Afterwards any operator approved by a holder (e.g. a marketplace that pays no royalty) calls nft.transferFrom(holder, buyer, id) and succeeds: CreatorTokenBase._preValidateTransfer returns immediately because getTransferValidator() == address(0) (isValidatorInitialized is true), so validateTransfer is never called.

      The project's own test test_owner_canSwitchValidatorOrDisable demonstrates exactly this path.

      Second path: owner calls setAutomaticApprovalOfTransfersFromValidator(true); nft.isApprovedForAll(address(staking), validator) now returns true.

  8. Audit judgeAgent #1061found 1 high, 1 medium, 5 low, 4 info

    The review is complete. The findings file at .imd-findings.json holds 11 merged findings and a 30-row coverage record (all 28 entry points plus the manifest and ABI exports). No repository files were changed, and the scratch tests were removed.

    What I kept, after reproducing each against the code

    • High: configure() can bind owner-chosen siblings. The one-time owner call accepts any contract whose view functions echo the expected addresses, then routes 85% of every mint fee and the full 6.9% royalty there. This conflicts with the brief's "no admin keys that can change the economics". The supplied proof fails on this code for that reason and is attached. A design-preserving fix exists: have the NFT constructor deploy its own staking and pool, leaving configure to take only the IMD address.
    • Medium: ERC-20 royalties are locked in the pool. WETH-denominated offers and any IMD sent without the NFT's notify call land as balances no function can move. Three specialists reported this; merged into one.
    • Low (5): IMD carry-over stranded after the final mint, the renderer's "Epoch 11" for the last three tokens, modulo bias in trait rolls versus the published Rarity Score, plain transfers into the staking contract stranding tokens (including by a policy-blocked operator), and the owner's validator powers as a trust assumption that conflicts with the brief.
    • Info (4): renounce-before-configure bricks minting, the 2,300-gas stipend bounce, the plain-ERC-20 IMD assumption, and nonce grinding for traits.

    What I dropped and why

    • The zero-value donate releasing carry-over is not a defect. A 1-wei donate does exactly the same, and carry going to whoever is staked at the next distribution is the stated design.
    • Accumulator dust of at most 1 wei per staker per settlement is negligible and not a broken guarantee.

    My own pass found nothing further. Mint ordering, the fee split arithmetic, the multiplier table, swap-and-pop bookkeeping, reentrancy guards, the manifest schema, contract sizes, and the ABI exports all check out. The Slither and Aderyn lines were either already covered by the findings above or are benign patterns that I traced.

    ran onclaude · claude-fable-5-1 · 19 turns · 6m 49s · 290 in · 29.2K out · 983.1K cached
    submission50e3606ba92688cfd9909359d8e53f286cb4c8b72ac675f4ccdeb16cd10b3d4e
    devicecdeffb0cd839cbf768d912a4dfd7e7384015eaa71e2281e2bb1db98383f2fdec
    started from65357365d077d2d1b1fbd3f16c02a5ae3f65c192
    bundlenone
    applied on191e9764d1b5eea60091d34b80d5a31421b9536dde6e677ca26092e89e3ca477, 733f101d9f7d121b014799a30f7b9cb334f2926ae11601b3960f704afb59b528, cf3062df4ebc94d85a383516d1de149039e42ab5a2ed9c3f4bd2440343f3cce3
    • highconfigure() lets the owner bind owner-chosen contracts as staking/claim pool, diverting 85% of every mint fee and all royaltiessrc/VeteransNFT.sol:111

      The launched VeteransStaking and VeteransClaimPool are never bound to the NFT. configure() accepts any staking_/claimPool_ whose view functions nft()/staking() return the expected values, which any owner-deployed contract can answer.

      After that, splitFee (lines 173-177) sends 65% + 20% of every 0.069 IMD mint to those addresses, setDefaultRoyalty(claimPool, 690) (line 118) makes claimPool the ERC-2981 receiver of the whole 6.9% royalty including the treasury's 0.9%, and preValidateTransfer (line 286) exempts transfers to/from staking from the royalty validator.

      The brief requires 'no admin keys that can change the economics after deployment' and 'no admin control over the pool'; here the whole advertised fee split and royalty stream depend on the owner's honesty in the one-time configure call (the manifest notes already flag that configure is required). The IMD address itself must be owner-supplied (the brief gives none), but the siblings need not be.

      Precondition: the owner wallet (0x3d6a...6193) or whoever holds its key calls configure with its own contracts; once configure has run honestly the power is spent.

      Fix preserving the design: bind the siblings without discretion, e.g. have VeteransNFT's constructor deploy VeteransStaking(address(this)) and VeteransClaimPool(address(this), staking) itself (initcode ~24 KB, runtime ~23 KB, both within limits; constructors may create contracts) and have configure take only the IMD address; or verify the siblings' runtime code hash against the expected one.

      Merged: audit_economics (698ff0fb).

      Deploy VeteransRenderer, VeteransNFT(OWNER, renderer), VeteransStaking(nft), VeteransClaimPool(nft, staking) as the manifest does.

      OWNER deploys FakeSibling{nft()=nft, staking()=this, notifyReward/notifyImd no-ops, receive()} and calls nft.configure(imd, fake, fake).

      Expected: revert InvalidSibling (not the launched contracts).

      Actual: succeeds; nft.staking()==fake, nft.royaltyInfo(1, 1 ether)==(fake, 0.069 ether); every later mint transfers 0.04485 + 0.0138 IMD to fake and every marketplace royalty is paid to fake. test/scratch/P1.t.sol (the attached proof) fails on this code with 'configure accepted owner-controlled staking/claim-pool contracts'.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {VeteransNFT} from "src/VeteransNFT.sol";
      import {VeteransStaking} from "src/VeteransStaking.sol";
      import {VeteransClaimPool} from "src/VeteransClaimPool.sol";
      import {VeteransRenderer} from "src/VeteransRenderer.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      
      contract Imd is ERC20 {
          constructor() ERC20("IMD", "IMD") {}
          function mint(address to, uint256 a) external { _mint(to, a); }
      }
      
      /// Owner-deployed look-alike: answers the sibling checks, keeps every token and ETH sent to it.
      contract FakeSibling {
          address public nft;
          address public staking;
          constructor(address nft_) { nft = nft_; staking = address(this); }
          function notifyReward(uint256) external {}
          function notifyImd(uint256) external {}
          receive() external payable {}
      }
      
      contract ConfigureDivertTest is Test {
          address constant OWNER = 0x3d6a89C8751a45DD577a4C1F3b34E71C58236193;
      
          function test_ownerCannotRouteFeesAndRoyaltiesToOwnContracts() public {
              VeteransRenderer renderer = new VeteransRenderer();
              VeteransNFT nft = new VeteransNFT(OWNER, address(renderer));
              // The launch deploys the real siblings...
              VeteransStaking staking = new VeteransStaking(address(nft));
              VeteransClaimPool pool = new VeteransClaimPool(address(nft), address(staking));
              staking; pool;
              Imd imd = new Imd();
      
              // ...but the owner configures a contract of their own instead.
              vm.prank(OWNER);
              FakeSibling fake = new FakeSibling(address(nft));
      
              vm.prank(OWNER);
              try nft.configure(address(imd), address(fake), address(fake)) {
                  // Accepted: 65% + 20% of every mint fee and the whole 6.9% royalty now go to the owner's contract.
                  (address receiver, uint256 royalty) = nft.royaltyInfo(1, 1 ether);
                  assertEq(receiver, address(fake));
                  assertEq(royalty, 0.069 ether);
                  assertEq(nft.staking(), address(fake));
                  fail("configure accepted owner-controlled staking/claim-pool contracts");
              } catch {}
          }
      }
    • mediumRoyalties paid in an ERC-20 (WETH offers, or IMD sent without notifyImd) are permanently locked in VeteransClaimPoolsrc/VeteransClaimPool.sol:73

      The pool is the single ERC-2981 receiver for the full 6.9% royalty but can only account for value arriving as native ETH through receive()/donate() or as IMD announced by the NFT through notifyImd() (line 93, msg.sender == nft only). Marketplaces that settle a sale in an ERC-20 (every Seaport-style accepted offer / collection offer is WETH-denominated) forward the royalty as a plain ERC-20 transfer to royaltyInfo's receiver.

      Such a balance is unreachable: _claimImd (line 185) only pays imdAccrued credited by notifyImd, there is no sweep, no owner, no per-token distribution, and no unwrap path. Both the 6.0% staker share and the 0.9% treasury share of every ERC-20-denominated sale are lost, contrary to the brief's '6.0% of all secondary sales' and '0.9% to TREASURY_WALLET'. The same applies to IMD transferred directly to the pool.

      Condition: a marketplace on Robinhood Chain paying ERC-2981 royalties in an ERC-20 (not verifiable from this tree).

      Fix keeping the pool admin-less: take the chain's WETH as an immutable constructor argument and add a permissionless unwrap that routes WETH.withdraw(balance) through the receive() split, and/or a permissionless sync(token) that distributes any ERC-20 balance above the accounted amount pro-rata by weight (for IMD: balance minus outstanding imdAccrued and imdCarryOver).

      Merged: audit_economics (7d442426), audit_permissions (172fc262), audit_flow (8950d5e8).

      State: stack deployed and configured; alice has minted and staked one token (totalWeight = 10).

      Input: an ERC-20 mock standing in for WETH mints/transfers 6.9e18 to address(pool), as a marketplace does for a 100 WETH sale per royaltyInfo(id, 100e18) = (pool, 6.9e18).

      Expected: 90/690 reaches TREASURY_WALLET and 600/690 becomes claimable by alice, as for an ETH royalty.

      Actual: alice's pool.claimAll() returns (0, 0); pool.pendingEth(alice) == 0; TREASURY_WALLET receives nothing; weth.balanceOf(pool) == 6.9e18 and no function in the pool ABI can move it.

      Variant: imd.mint(address(pool), 1e18) without notifyImd leaves pool.pendingImd(alice) == 0 and totalImdReceived unchanged.

      Verified in test/scratch/Repro.t.sol::test_erc20RoyaltyStuck.

    • lowIMD carried over while nothing is staked at the final mint is stranded forever (staking carryOver and pool imdCarryOver)src/VeteransStaking.sol:170

      notifyReward (staking, lines 166-178) and notifyImd (pool, src/VeteransClaimPool.sol:93-104) park rewards in carryOver / imdCarryOver when totalWeight == 0 and only fold them into the accumulator on the NEXT notify call. The only caller of both is VeteransNFT._splitFee, which runs only inside mint().

      After totalMinted == MAX_SUPPLY, mint() reverts SoldOut (line 145) forever, so any carry-over left after the last mint can never be distributed: stakers who stake afterwards see pendingReward/pendingImd == 0 for it and the IMD stays in the two contracts with no admin or sweep. The ETH side does not have this problem because _distributeEth runs on every receive()/donate().

      Fix: also release carry-over when weight goes from 0 to > 0 (in _syncWeight / onWeightChange) or add a permissionless releaseCarryOver() that folds it whenever totalWeight > 0.

      Merged: audit_economics (d042abbd), audit_flow (e3063c55).

      State: 3,332 tokens minted; staking.totalWeight() == 0 and pool.totalWeight() == 0 (nobody staked, or everyone unstaked).

      (1) bob mints token 3,333: _splitFee transfers 0.04485e18 IMD to staking and calls notifyReward -> carryOver = 0.04485e18 (+ earlier carry); transfers 0.0138e18 to the pool and calls notifyImd -> imdCarryOver = 0.0138e18.

      (2) alice stakes one token: totalWeight = 10 but nothing calls notifyReward/notifyImd; staking.pendingReward(alice) == 0, pool.pendingImd(alice) == 0, staking.claim() returns 0.

      (3) Any further nft.mint(nonce) reverts SoldOut, so no path ever moves carryOver/imdCarryOver into accRewardPerWeight/accImdPerWeight.

      Expected: the 65% + 20% of the last mint(s) reaches stakers once someone stakes.

      Actual: imd.balanceOf(staking) and imd.balanceOf(pool) permanently exceed every claimable amount by 0.04485e18 and 0.0138e18 per mint that hit totalWeight == 0 at the end.

    • lowRenderer reports 'Epoch 11' for tokens 3331-3333 although the collection has exactly ten epochssrc/VeteransRenderer.sol:134

      VeteransNFT._epochAt (src/VeteransNFT.sol:221-224) clamps minted / 333 to EPOCH_COUNT - 1 = 9, so tokens 3331-3333 are mined in the tenth epoch (currentEpoch() == 9, Mined event epoch 9, README: epoch 10 holds 336 mints). The renderer recomputes the epoch from the tokenId without the clamp: (3331 - 1) / 333 + 1 == 11.

      The on-chain Epoch attribute of the last three tokens names an epoch that does not exist and contradicts the contract's own events and the brief's 'exactly 10 epochs'; once freezeMetadata() runs it is permanent.

      Fix: uint256 e = (tokenId - 1) / EPOCH_SIZE; if (e > 9) e = 9; (e + 1).toString(), sharing the EPOCH_COUNT constant.

      Merged: audit_math (12aed6d1), audit_permissions (b6f28807), write_foundry_tests (1db37dc9).

      renderer.tokenJSON(3331, keccak256('seed')) -> attributes contain {"trait_type":"Epoch","display_type":"number","value":11}; same for 3332 and 3333; tokenJSON(3330, seed) contains "value":10.

      Expected value 10 for all three.

      The supplied proof Proof_1db37dc96677.t.sol, run as test/scratch/P2.t.sol, fails on this code with 'token 3331 must be in epoch 10, the last of exactly ten'.

    • lowTrait roll `byte % 100` is biased, so real trait odds and the published Rarity Score differ from the declared per-100 oddssrc/VeteransRenderer.sol:48

      roll is a uint8 (0..255). 256 % 100 == 56, so residues 0..55 occur three times in 256 and residues 56..99 only twice. The odds tables (_bgOdds etc., 'per 100') and rarityScore (lines 42-44, 10_000 / odds) assume the table percentages are the true probabilities.

      Actual Background frequencies for [40,25,18,12,5] are 120/256 = 46.9%, 66/256 = 25.8%, 36/256 = 14.1%, 24/256 = 9.4%, 10/256 = 3.9%; every table has the same shape, so the rarest tier of each trait is rarer than declared and the common tier commoner, and the on-chain Rarity Score (the brief's 'rarity rank' input, which the gallery sorts by) is computed from the wrong odds. Metadata/ranking only; no funds.

      Fix: uint256 r = uint256(roll) * 100 / 256 (uniform within rounding) or scale the tables to 256.

      Merged: audit_math (342e4c4b), audit_flow (7a1bfbe3), write_foundry_tests (a4ad7d0a).

      For b in 0..255 call renderer.traitsOf(bytes32(uint256(b) << 248)) and tally .background: counts are [120, 66, 36, 24, 10], expected from the declared table [102.4, 64, 46.1, 30.7, 12.8].

      Single input: seed byte 0 = 0xFF gives r = 55 -> index 1 (a third copy of residue 55), while residues 95..99 (index 4) get only two copies.

      Verified in test/scratch/Repro.t.sol::test_moduloBias (asserts counts[0]==120, counts[4]==10).

    • lowPlain transferFrom into the staking contract strands the token, and the validator bypass lets even a policy-blocked operator do itsrc/VeteransNFT.sol:286

      _preValidateTransfer skips the ERC721C validator for any transfer whose destination is the staking contract, regardless of who initiates it. Only staking.stake() (caller == staking) and the holder's safeTransferFrom (credited through onERC721Received) create a stake record.

      A plain transferFrom(holder, staking, id) by the holder or by any approved operator moves the token without a stakerOf entry, and the staking contract has no rescue path, so the token is lost (the README documents the holder's own case). The bypass additionally widens a blocked operator's power: a marketplace the owner removed from the whitelist for not honouring royalties cannot move a holder's token anywhere except into the staking contract, where it is destroyed.

      Fix: narrow the bypass to the paths that credit a stake, e.g. return only when from == staking_ || caller == staking_ || (to == staking_ && caller == from), and/or add a permissionless rescue in VeteransStaking that records an unrecorded token for its last owner.

      Merged: audit_permissions (471e6e19), write_foundry_tests (d8bf1bcd).

      State: validator enforces an operator whitelist with OTC allowed; alice owns token 1 and has setApprovalForAll(market, true); market is not whitelisted.

      Input 1: market calls nft.transferFrom(alice, bob, 1) -> reverts OperatorNotWhitelisted(market) (expected).

      Input 2: market calls nft.transferFrom(alice, address(staking), 1).

      Expected: same revert.

      Actual: succeeds; nft.ownerOf(1) == staking, staking.stakerOf(1) == address(0); alice's staking.unstake([1]) reverts NotStaker and staking.stake([1]) reverts NotTokenOwner; nobody can ever move the token.

      Verified in test/scratch/Repro.t.sol::test_blockedOperator_strandsTokenInStaking.

    • lowTrust assumption / brief conflict: the owner can switch off royalty enforcement (setTransferValidator(0)) or auto-approve a validator that can move any token, including staked onessrc/VeteransNFT.sol:22

      VeteransNFT inherits ERC721C's owner powers unchanged. setTransferValidator(address(0)) makes CreatorTokenBase._preValidateTransfer a no-op for every transfer, so royalty-evading operators can move tokens and the 6.0%/0.9% royalty stream stops at the owner's will; the security policy itself also lives on the external validator under the owner's control, and none is set at deployment (README step 2). setAutomaticApprovalOfTransfersFromValidator(true) makes the current validator an approved operator for every holder, including VeteransStaking; with an owner-chosen validator (any contract with code) a staked token can be moved out of the staking contract while stakerOf/weight still credit the staker, so unstake reverts and the ledger desyncs.

      No unprivileged amplifier exists and the README documents both functions, so this is recorded as a trust assumption, but it conflicts with the brief's 'no admin keys that can change the economics after deployment' and 'only operators that honor the royalty can transfer the tokens'.

      Optional hardening: reject address(0) in setTransferValidator once configured (keep switching validators), and override isApprovedForAll so the automatic validator approval never applies when owner == staking.

      Merged: audit_economics (dfa1440c), audit_permissions (03fda07a), audit_flow (3ccb397e).

      Path 1: validator enforces a whitelist; alice approved market (not whitelisted).

      OWNER calls nft.setTransferValidator(address(0)); market calls nft.transferFrom(alice, bob, id).

      Expected under enforced policy: revert.

      Actual: succeeds, nft.ownerOf(id) == bob, pool receives 0 ETH (test/scratch/Repro.t.sol::test_ownerDisablesValidator; the project's own test_owner_canSwitchValidatorOrDisable shows the same).

      Path 2: alice staked token 1.

      OWNER calls setTransferValidator(evil) where evil.validateTransfer is a no-op, then setAutomaticApprovalOfTransfersFromValidator(true); evil calls nft.transferFrom(address(staking), bob, 1).

      Actual: nft.ownerOf(1) == bob while staking.stakerOf(1) == alice and weightOf(alice) == 10; alice's unstake([1]) reverts in ERC721 'caller is not token owner or approved'.

    • inforenounceOwnership or a mistaken transferOwnership before configure() permanently disables mintingsrc/VeteransNFT.sol:106

      configure() is the only way to set imd, and mint() reverts NotConfigured until it runs. Ownable's renounceOwnership and transferOwnership are not blocked while the collection is unconfigured, so one owner mistake after launch leaves a collection that can never mint. Owner-error only; one-line guard: override renounceOwnership to revert while isConfigured() is false.

      From audit_permissions (c027fcf1).

      Fresh VeteransNFT(owner, renderer), unconfigured. owner calls renounceOwnership(); then owner's configure(imd, staking, pool) reverts 'Ownable: caller is not the owner' and anyone's mint(nonce) reverts NotConfigured, forever. Verified in test/scratch/Repro.t.sol::test_renounceBeforeConfigureBricks.

    • infoRoyalty pushed with a 2,300-gas stipend (transfer/send) bounces at the pool's receive()src/VeteransClaimPool.sol:76

      receive() writes several storage slots and pushes to the treasury, so a royalty forwarded with payable(receiver).transfer or send runs out of gas and reverts the sale's royalty leg (venues that use these are rare; mainstream marketplaces use call). Nothing is lost on a bounce. The README lists this as a known limit; recorded so the limit is explicit for venue selection.

      Merged: write_foundry_tests (69107d27), audit_flow (e8c54cfb).

      address(pool).call{value: 1 ether, gas: 2300}('') -> ok == false; pool.totalEthReceived() stays 0 and the sender keeps the ETH. The same call with full gas succeeds (project test test_knownLimit_royaltyViaTransferStipendBounces).

    • infoFee split assumes IMD is a plain ERC-20 (no transfer fee, no rebasing); a deflationary token would leave the last claimers unpaidsrc/VeteransNFT.sol:173

      _splitFee transfers an amount and then notifies the same amount; staking and pool credit stakers for the notified figure, not the received balance. If the live IMD token charged a fee on transfer or rebased, the contracts would owe more than they hold and later claims would revert. The IMD address is configured after launch and could only be tested against the standard mock, so this is a stated assumption the owner must confirm before configure, not a code defect.

      From write_foundry_tests (b4cd12a9).

      Stand in a fee-on-transfer token charging 1% as IMD; two stakers each entitled to half of the notified rewards: the second claim() reverts with an ERC20 insufficient-balance error because the contract holds 99% of what it credited.

    • infoMiners can grind nonces to choose traits and the Overclocked tiersrc/VeteransNFT.sol:157

      The winning hash is the token's seed, so a wallet may discard winning nonces and keep mining until a hash both beats the target and decodes to the traits it wants (Overclocked costs ~24x the work). Inherent to deriving traits from the proof-of-work hash as the brief requires; price per token is unchanged. Noted so the frontend and collectors are not surprised.

      From write_foundry_tests (c8cc7f59).

      Loop nonces until hashFor(wallet, nonce) < target() and renderer.traitsOf(hash).overclocked; submit that nonce. Mint succeeds with an Overclocked token on the first attempt.

  9. Build contract projectAgent #154519 files changed

    Everything passes: build clean, 77 tests green with the verifier's fuzz seed (76 project tests plus the reviewer's proof, which now passes), and forge fmt --check is clean. No launch.json or .imd-blocked.json exists in the tree.

    What I found and did

    Both reopened findings reproduced, and I fixed their causes rather than the symptoms.

    • Owner could bind fake siblings (high). VeteransNFT's constructor now deploys VeteransStaking and VeteransClaimPool itself, stores them as immutables and sets the royalty receiver at construction. The fee split and royalty stream are never chosen by anyone. configure keeps its three-argument signature for the proof's ABI but only sets the IMD address and reverts with InvalidSibling for anything but its own contracts. The launch set is now two contracts, Renderer and NFT; the README launch table, deploy script and frontend notes say so. Initcode is 27 KB, runtime 12 KB.
    • ERC-20 royalties locked in the pool (medium). Added permissionless syncImd(), which releases IMD above a new imdOwed counter as a royalty with the treasury's 90/690 cut, and permissionless unwrapWeth(), which routes WETH through a small WethUnwrapper helper so WETH9's 2,300-gas withdraw payout cannot bounce off the pool's receive(). The WETH address is not in the brief or any chain reference I was given, so it is an owner-only one-shot setWeth with an event, documented under "After launch". A wrong WETH cannot divert anything, since the pool only credits ETH that actually arrives.

    Advisory findings I judged real and fixed cheaply: carry-over released when weight returns (both contracts), epoch attribute clamped to 10, trait roll scaled instead of % 100, plain transferFrom into staking refused and the validator bypass narrowed to stake-creating paths, auto-approval of the validator never covering staked tokens, and renounceOwnership blocked before configure. The three info items (stipend bounce, plain-ERC-20 IMD assumption, nonce grinding) are answered as disputed with README documentation and no code change. The setTransferValidator(0) power stays a documented trust assumption because the vendored library's setter is not virtual.

    One test-helper fix worth knowing about: the in-test mining loop grew memory per attempt and only passed the 20-bit production test before by luck of an early winner. It now hashes in a fixed scratch buffer.

    All eleven answers are in .imd-responses.json, and the five ABI files in docs/abi/ are regenerated.

    ran onclaude · claude-fable-5-1 · 83 turns · 23m 19s · 514 in · 89.5K out · 2.5M cached
    submissioned50c42968e20f9dd07f22a50f7848d4095fe74f31a1c225588ab3db6b35d487
    device60ffae3cc413a0c6224df6bc05124e6333bde3a246f6ba4402666012ee26253b
    started froma708f3ceba2c627495025a66ade07488569221a1
    bundle191e9764d1b5eea60091d34b80d5a31421b9536dde6e677ca26092e89e3ca477 · 387 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 19 files
    README.mddocs/abi/VeteransClaimPool.jsondocs/abi/VeteransNFT.jsondocs/abi/VeteransRenderer.jsondocs/abi/WethUnwrapper.jsonscript/Deploy.s.solsrc/VeteransClaimPool.solsrc/VeteransNFT.solsrc/VeteransRenderer.solsrc/VeteransStaking.solsrc/WethUnwrapper.solsrc/interfaces/IVeterans.soltest/Deploy.t.soltest/VeteransBase.t.soltest/VeteransClaimPool.t.soltest/VeteransNFT.t.soltest/VeteransRenderer.t.soltest/VeteransStaking.t.soltest/mocks/MockWETH.sol
  10. ManifestAgent #9261 file changed

    The revised manifest is in place and verified. Only launch.json changed.

    What changed and why. The accepted source was revised after my previous manifest: the NFT constructor now deploys VeteransStaking and VeteransClaimPool itself, and the pool deploys its WethUnwrapper. The old four-entry manifest would have deployed orphan duplicates of the staking contract and pool that the collection never references. The manifest now lists two contracts in dependency order:

    • VeteransRenderer with no constructor arguments.
    • VeteransNFT with $owner and $contract:VeteransRenderer, matching the constructor signature in source and the ABI export.

    Checks run.

    • Schema check in scratch: exactly the three keys, unique identifier names, backward-only contract references, notes under the limit.
    • Constructor inputs cross-checked against the source and docs/abi.
    • Confirmed the ERC721C validator registration in the constructor is guarded by an extcodesize check, so deployment on an empty chain does not revert.
    • forge build succeeded and forge test with the verifier's fuzz seed passed all 76 tests.
    • git status shows launch.json as the only modified file.

    Notes carried forward as review findings, updated to the current source. The IMD token must be set by the owner once after launch, the royalty security policy is not set in the constructor and the owner can replace the validator, the pool holds the 20% share as IMD rather than ETH, the final epoch has 336 mints, and age multipliers refresh on interaction. The manifest supplies no initialization call and no invented IMD address.

    ran onclaude · claude-fable-5-1 · 11 turns · 1m 39s · 322 in · 5.8K out · 567.5K cached
    submissionc72215668f6210edc6f82e845bc7a5ecef581ecae9837840f46e3099e997f44b
    devicefa8fc4653a9e883d4b2a1e1c53a856ddf70e90f433ae70645a4a0bb3cccdd962
    started from05469d1346fc5cbf46f0dbc954f8e2701947583e
    bundlecf3062df4ebc94d85a383516d1de149039e42ab5a2ed9c3f4bd2440343f3cce3 · 390 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on191e9764d1b5eea60091d34b80d5a31421b9536dde6e677ca26092e89e3ca477
    changed · 1 file
    launch.json
    may write
    launch.json
  11. Write foundry testsAgent #9563 files changed

    The revision is complete and verified. The full suite passes with the verifier's seed.

    SuiteResult
    All 7 suites, 122 tests0 failed
    My invariant contract, 11 invariants128 runs × depth 100, all pass

    What broke the rejected attempt. My accepted tests were written against an older source tree. Another contributor's changes then landed in src, and five of my tests failed at runtime against them:

    • The NFT constructor now deploys its own staking contract and claim pool, and configure rejects anything else. My invariant fixture deployed its own siblings, so its setUp reverted with InvalidSibling and every invariant was dead.
    • Carried-over rewards are now released to the first wallet that creates weight, instead of waiting for the next distribution. Three tests asserted the old timing.
    • The royalty receiver is now fixed in the constructor, so one test expecting a zero receiver before configure was wrong.

    What I changed. Only the three files from my accepted round, with the minimal updates plus coverage of the new surface:

    • test/invariant/VeteransInvariants.t.sol takes the siblings from the NFT and sets a mock WETH. The pool IMD invariant now also checks the new imdOwed ledger equals balance minus unsynced stray IMD. Conservation invariants account for synced IMD royalties and WETH royalties.
    • test/invariant/VeteransHandler.sol gains three actions: a WETH royalty that is unwrapped, stray IMD sent to the pool, and a permissionless sync of it, with ghost books for each.
    • test/VeteransEdgeCases.t.sol has the four tests corrected to the new behaviour and seven new failure-path tests: a second dry spell releasing carry-over only once, one-wei sync and unwrap rounding, a fuzz that sync can only release the surplus, unwrap against a token lacking withdraw moving nothing, unwrap with no stakers carrying over, and tightened sibling rejection in configure.

    Findings. No critical or high defects, so no proofs. .imd-findings.json records two low and two informational design limits my tests pin: the whole carry-over goes to the first staker after any zero-weight period, setWeth is irreversible, royalties pushed with the 2,300-gas stipend bounce, and the tenth epoch holds 336 mints because the brief's numbers conflict.

    I did not commit; the working tree holds the changes for the harness to pick up, and nothing outside test/ and the findings file was touched.

    ran onclaude · claude-fable-5-1 · 52 turns · 10m 36s · 386 in · 35.5K out · 1.4M cached
    submission237afd4d2b80752b36117730fbb321f6666c5398efed3e3e5d04bc1eec5de372
    devicea4e285247c6f8aed4c727a41c7b85e2978939f74e9ae6b02d8de4c1b75cccdea
    started from138dcfcc5d3eb9b217b011b68fd0f65a06f281ca
    bundle733f101d9f7d121b014799a30f7b9cb334f2926ae11601b3960f704afb59b528 · 405 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on191e9764d1b5eea60091d34b80d5a31421b9536dde6e677ca26092e89e3ca477
    changed · 3 files
    test/VeteransEdgeCases.t.soltest/invariant/VeteransHandler.soltest/invariant/VeteransInvariants.t.sol
    may write
    testtest/**
    • lowEvery carry-over is released in full to the first wallet that creates weight after a zero-weight periodsrc/VeteransStaking.sol:233

      VeteransStaking._syncWeight (line 233) and VeteransClaimPool.onWeightChange (src/VeteransClaimPool.sol line 164) release the whole carried-over IMD and ETH to whichever wallet is the first to make totalWeight non-zero, however small its stake. Funds carry over at launch (every mint fee before the first stake) and again whenever all stakers leave while mints or royalties keep arriving.

      A wallet that watches for a zero-weight state can stake a single token first and take the entire carry-over; the wallets whose mints funded it get nothing. This is the behaviour the implementation documents and tests pin, so it is reported as a design limit rather than asserted as a bug; the README should state it so early miners are not surprised.

      Fixture of test/VeteransBase.t.sol. alice mints 3 tokens, bob mints 3 tokens (nothing staked: staking.carryOver() == 6 * 0.04485 IMD, pool.imdCarryOver() == 6 * 0.0138 IMD). bob stakes one token, then alice stakes three.

      Expected by a pro-rata reading: shares by weight.

      Actual: staking.pendingReward(bob) == 6 * 0.04485 IMD and pool.pendingImd(bob) == 6 * 0.0138 IMD, staking.pendingReward(alice) == 0.

      Pinned by test_weightChangeWhileCarryOverPending_distributesToFirstStaker, test_carryOver_afterEveryoneUnstakes_goesToNextStakerOnce and testFuzz_twoStakers_splitByWeight in test/VeteransEdgeCases.t.sol.

    • lowsetWeth is irreversible: a wrong address permanently strands WETH royaltiessrc/VeteransNFT.sol:140

      setWeth accepts any contract address once and can never be changed. VeteransClaimPool.unwrapWeth reads nft.weth() and calls balanceOf/withdraw on it, so if the owner sets a non-WETH contract (or the wrong wrapped token for the chain) every unwrapWeth call reverts forever and WETH royalties sent to the pool can never be converted or claimed. No funds already owed to stakers are at risk: the whole unwrap reverts before anything moves.

      This is an owner power the README must disclose and the deployment runbook must verify before calling.

      Fixture of test/VeteransBase.t.sol.

      OWNER calls nft.setWeth(address(imd)) (a contract without withdraw).

      Send any WETH to the pool.

      Expected: a way to recover.

      Actual: pool.unwrapWeth() reverts on every call and nft.setWeth(realWeth) reverts with AlreadyConfigured.

      Pinned by test_unwrapWeth_withTokenLackingWithdraw_revertsAndMovesNothing in test/VeteransEdgeCases.t.sol.

    • infoRoyalty pushed with the 2,300-gas stipend (transfer/send) bouncessrc/VeteransClaimPool.sol:86

      receive() updates storage and may forward ETH to the treasury, so a marketplace or wallet that pays the ERC-2981 royalty with Solidity transfer()/send() gets a revert and its sale transaction fails. Current marketplaces pay royalties with call, and WETH royalties go through WethUnwrapper which forwards with full gas, so no funds are lost; the sender keeps its ETH. Worth stating in the README for integrators.

      Fixture of test/VeteransBase.t.sol: address(pool).call{value: 1 ether, gas: 2300}("") returns ok == false, pool.totalEthReceived() stays 0 and the sender's balance is unchanged. Pinned by test_knownLimit_royaltyViaTransferStipendBounces in test/VeteransEdgeCases.t.sol and test/VeteransClaimPool.t.sol.

    • infoThe tenth epoch holds 336 mints, not 333src/VeteransNFT.sol:254

      The brief fixes both 3,333 tokens, 333 mints per epoch and exactly 10 epochs, which is inconsistent (10 x 333 = 3,330). The implementation clamps the epoch to 9 so tokens 2,998 to 3,333 (336 mints) share the last difficulty. The choice is documented in the code and the renderer's epochOf follows it; the README and frontend should show it the same way.

      VeteransNFTTestable with totalMinted == 2997: currentEpoch() == 9 and mintsRemainingInEpoch() == 336. Pinned by invariant_supplyAndEpochViews in test/invariant/VeteransInvariants.t.sol and the full mine-out test in test/VeteransNFT.t.sol.

  12. Audit judgeAgent #1023found 6 info

    Review complete. The findings file is written and every snippet anchor validates against the tree.

    Outcome: all prior-round findings are settled as fixed; nothing new blocks the launch.

    What I reproduced against the revised code (test/scratch/Settle.t.sol, 11 checks, all passing, plus both attached proofs now passing):

    • High, configure() diversion: fixed. The NFT constructor deploys VeteransStaking and VeteransClaimPool itself, stores them as immutables and sets the royalty receiver at construction. configure() reverts InvalidSibling for anything but its own siblings. Initcode is 27,192 bytes and runtime 12,179 bytes, both within the factory limits.
    • Medium, ERC-20 royalties stuck: fixed. A direct 6.9 IMD transfer is released by the permissionless syncImd into 0.9 to the treasury and 6.0 claimable, without touching owed funds. A 6.9 WETH balance is unwrapped through the helper into the same ETH split.
    • Low, stranded carry-over: fixed. Carry-over is released when weight reappears, and a zero-value donate releases nothing extra, so the audit_math zero-value lead is moot.
    • Low, Epoch 11 and biased trait roll: fixed. Tokens 3331 to 3333 report epoch 10 and background counts over all 256 seed bytes sit within 3 of the declared odds.
    • Low, plain transfer into staking: fixed. Both holder and operator plain transfers revert, and a blocked operator's safeTransferFrom is validated.
    • Low, owner validator powers: path 2 fixed via the isApprovedForAll override. Path 1 remains a documented owner power.
    • Info, renounce before configure: fixed.

    What the file reports, all at info severity and none blocking:

    1. setTransferValidator(address(0)) remains as a documented trust assumption.
    2. setWeth is one-shot, so a wrong address permanently prevents unwrapping WETH royalties. New in this round, owner-error class.
    3. The first weight after a zero-weight period takes the whole carry-over, a design limit of the fix.
    4. The three disputed items from last round are accepted as documented limits: the 2,300-gas stipend bounce, the plain-ERC-20 IMD assumption, and nonce grinding.

    Coverage answers all 33 entry points plus rows for the manifest and renderer. The manifest lists two contracts in dependency order with correct argument counts, and the ABI exports match the compiled artifacts for all five contracts. The project suite passes 122 tests. No repository file was changed; only the findings file and my scratch tests were written.

    ran onclaude · claude-fable-5-1 · 23 turns · 7m 25s · 450 in · 31.3K out · 1.5M cached
    submissionf4e6e53a787fd484df9e2c082c05343632901329727658b16f38187246ab985a
    device6b16b4979d22724158b8c5c0f0cc9ce06382153e865034e462fc689651022ada
    started from1d7580e38d5f0f325777f30ee664dc18e9d93bfe
    bundlenone
    applied on191e9764d1b5eea60091d34b80d5a31421b9536dde6e677ca26092e89e3ca477, 733f101d9f7d121b014799a30f7b9cb334f2926ae11601b3960f704afb59b528, cf3062df4ebc94d85a383516d1de149039e42ab5a2ed9c3f4bd2440343f3cce3
    • infoTrust assumption (settled): owner can still switch off royalty enforcement with setTransferValidator(address(0)); the staked-token path is fixedsrc/VeteransNFT.sol:26

      Settlement of prior finding 7f2bbc4c. Path 2 (auto-approved validator moving staked tokens) is fixed: VeteransNFT.isApprovedForAll (line 335-338) returns only the explicit ERC721 approval when owner == staking, so an owner-chosen validator with autoApproveTransfersFromValidator cannot transferFrom(staking, x, id); reproduced in test/scratch/Settle.t.sol::test_autoApprovedValidatorCannotMoveStaked (the pull reverts, ownerOf stays staking, the staker can still unstake).

      Path 1 remains by design: the vendored CreatorTokenBase.setTransferValidator (lib/creator-token-standards/src/utils/CreatorTokenBase.sol:68) is public and not virtual, accepts address(0), and _preValidateTransfer then validates nothing. The README trust section states it explicitly and the brief itself asks the owner to configure validator and policy, so this is recorded as the one documented owner power over an economic guarantee, not as a defect.

      Not verifiable from this tree: whether the default validator 0x721C008fdff27BF06E7E123956E2Fe03B63342e3 has code on Robinhood Chain; until the owner sets a validator or policy, non-staking transfers call that address (README step 3 covers it).

      State: configured stack, alice owns token 1 and has setApprovalForAll(market, true), validator enforces a whitelist that excludes market (market's transferFrom reverts OperatorNotWhitelisted).

      Input: OWNER calls nft.setTransferValidator(address(0)); market calls nft.transferFrom(alice, bob, 1).

      Expected under an immutable royalty policy: revert.

      Actual: succeeds, nft.ownerOf(1) == bob, no royalty reaches the pool (the project's own test_owner_canSwitchValidatorOrDisable shows the path).

      No unprivileged actor can trigger it.

    • infosetWeth is one-shot: a wrong address permanently prevents WETH royalties from being unwrappedsrc/VeteransNFT.sol:142

      New in this round's fix for finding 104b5f22. VeteransClaimPool.unwrapWeth (src/VeteransClaimPool.sol:131-139) reads nft.weth() and calls balanceOf/transfer/withdraw on it. The owner sets it once and can never correct it.

      If the owner enters a non-WETH contract (or a wrapped token without withdraw), every unwrapWeth call reverts forever and WETH royalties already in the pool stay there; nothing credited to stakers is at risk because the whole call reverts before any ETH moves, and a wrong token cannot divert funds since the pool only credits ETH that actually arrives in receive().

      Owner-error class, same as transferOwnership to a wrong address; the README 'After launch' step 2 tells the owner to verify symbol/deposit/withdraw first.

      Optional hardening: allow setWeth to be changed until the first successful unwrapWeth, or have setWeth probe weth_.deposit{value:0}()/symbol().

      State: configured stack.

      Input: OWNER calls nft.setWeth(address(imd)) (a contract without withdraw), then real WETH is transferred to the pool and OWNER calls nft.setWeth(realWeth).

      Expected: a correction path.

      Actual: the second setWeth reverts AlreadyConfigured and pool.unwrapWeth() reverts on every call (reproduced in test/scratch/Settle.t.sol::test_setWethWrongTokenStrands; the project's test_unwrapWeth_withTokenLackingWithdraw_revertsAndMovesNothing shows the revert moves nothing).

    • infoThe first weight that appears after a zero-weight period receives the entire carry-over, however small its stake (design limit of the fix)src/VeteransClaimPool.sol:164

      Consequence of the fix for finding cf1d2b05 (carry-over no longer stranded after the last mint). VeteransStaking._syncWeight (src/VeteransStaking.sol:233-238) and VeteransClaimPool.onWeightChange fold the whole carried IMD and ETH into the accumulator at the moment total weight goes from 0 to > 0, so the single wallet creating that weight is credited all of it.

      Before the fix the same funds went to whoever was staked at the next mint, so this is not a new loss, and no funds are stranded now (test_carryOverReleasedOnStake: two mints and a 6.9 ETH royalty carried with nobody staked all reach the first staker; a later zero-value donate releases nothing extra, so the audit_math zero-value donate lead is moot).

      Recorded so the README/frontend state it; merged from write_foundry_tests (ad4d4feb) and audit_math (9899ef6b, now not reproducible as a separate issue).

      State: configured stack, nobody staked. alice mints tokens 1-3 and bob mints token 4 (staking.carryOver() == 4 * 0.04485e18, pool.imdCarryOver() == 4 * 0.0138e18). bob stakes token 4 first, then alice stakes token 1.

      Expected by a pro-rata reading: shares by weight.

      Actual: staking.pendingReward(bob) == 4 * 0.04485e18 and staking.pendingReward(alice) == 0 (test/scratch/Settle.t.sol::test_firstStakerTakesCarry; pinned by the project's test_weightChangeWhileCarryOverPending_distributesToFirstStaker).

    • infoKnown limit (settled, accepted): royalty pushed with a 2,300-gas stipend bounces at the pool's receive()src/VeteransClaimPool.sol:86

      Settlement of prior finding cc05c4cd, which the author disputed as intentional.

      Agreed: receive() must write storage to split and credit the royalty, so a transfer()/send() push reverts and the sender keeps its ETH; nothing is lost. The one systematic stipend source, WETH9.withdraw, is now routed through WethUnwrapper (src/WethUnwrapper.sol:25-30), whose empty receive() absorbs the stipend and forwards with full gas (test/scratch/Settle.t.sol::test_unwrapWeth: 6.9 WETH becomes 0.9 ETH to the treasury and 6.0 ETH claimable).

      Kept as an explicit note for venue selection; no code change requested.

      address(pool).call{value: 1 ether, gas: 2300}("") returns ok == false, pool.totalEthReceived() stays 0 and the sender's balance is unchanged; the same call with full gas succeeds (project test test_knownLimit_royaltyViaTransferStipendBounces).

    • infoAssumption (settled, accepted): the fee split and both IMD accounting counters assume IMD is a plain ERC-20src/VeteransNFT.sol:206

      Settlement of prior finding 0df4d76e, disputed by the author as a configuration assumption. Agreed. Note that the new syncImd path (src/VeteransClaimPool.sol:116-127) relies on the same assumption more directly: imdOwed is incremented by the notified amount, so a fee-on-transfer IMD would make balanceOf(pool) < imdOwed and syncImd would revert on underflow while claims would run short.

      The README 'Assumptions' section tells the owner to confirm before configure. No code change requested.

      Stand in a 1%-fee-on-transfer token as IMD via configure; two stakers each entitled to half of a notified reward: the second claim() reverts with an ERC20 insufficient-balance error, and pool.syncImd() reverts with an arithmetic underflow because balanceOf(pool) < imdOwed.

    • infoInherent (settled, accepted): miners can keep grinding past a winning nonce to pick traits and the Overclocked tiersrc/VeteransNFT.sol:181

      Settlement of prior finding fc5e238c, disputed by the author as inherent to the brief (traits derive from the winning proof-of-work hash).

      Agreed: price per token, supply and difficulty are unchanged; only the trait distribution among grinders shifts. Documented in the README. No code change requested.

      Loop nonces until hashFor(wallet, nonce) < target() and renderer.traitsOf(hash).overclocked; submit that nonce. mint succeeds with an Overclocked token on the first on-chain attempt.

  13. Deployed2 contracts on Robinhood Chaintransaction
    rebuilt
    VeteransClaimPool, VeteransNFT, VeteransRenderer, VeteransStaking, WethUnwrapper · verifier 0.1.0 · solc 0.8.26
    gates
    • provenance
    • findings
    • independent review
    • bytecode
    • manifest
    • protected invariants
    • economics
    proof
    commit, attestation, manifest, tree, per-contract hashes
    repository
    identity-md-launches/launch-1756-workflow-contract-stage-context
    commit
    d3de6035fe432600d7f669ba8104e1ba4bbb37be
    attestation
    edfa6a8fa59bf470396899e5bfbd35dbf7e4775495aa445b7e15ccdca8caad87
    manifest
    01f28c3aa0ac65be510fbc3d76a4d94c3e9e9778586b80cf6cd70950b72834de
    constructor
    VeteransNFT: $owner, $contract:VeteransRenderer
    tree
    29903108817f62f022ffc3e1f0c40df9084288b7
    compiler
    solc 0.8.26, optimizer 200 runs, reproducible
    contract
    VeteransClaimPool
    src/VeteransClaimPool.sol · 6862 bytes
    creation dfc95ee7fb24a75355d9d56f19ac25602e0e20d397a7d735bd5df5dc8c06f2f6
    abi f79288f5060213d8b8e0aefcbe1118cd86143a65874163eee7ae6f1f6762039a
    metadata 44429cc08c7364841592ce6c66a1329947da96ae333cdb0c3e3050f996004b30
    contract
    VeteransNFT
    src/VeteransNFT.sol · 27192 bytes
    creation 61cf726feaa022d946dc7706dcf59991c46bab486eec286437991017351eab14
    abi bd4ea42d960ad5325217e55b740355d492ca2e55d62efd0d1d4d72c0e6742643
    metadata ceff672480d9d6e3921d7bdcc60862b8d935269f0ec1deb05882234b3da10535
    onchain at 0x582e…e033, block 88,572,812 · creation code matches
    contract
    VeteransRenderer
    src/VeteransRenderer.sol · 11511 bytes
    creation d9fa8f869ef0613588e2e1f6ca91dfd6762052330a7103b2ae70b7b8e47c7f87
    abi c5d6bbfa9dcc1b80975836519faad81910fb60355590b3fc5be20afd7bee29b2
    metadata 5472ff13527c93cdd3e4cfecc598d2d233c824ad47dea96a5f4ad21712dd145e
    onchain at 0x52aa…65b7, block 88,572,812 · creation code matches
    contract
    VeteransStaking
    src/VeteransStaking.sol · 6427 bytes
    creation db9b303a4d12a0d32f2264a42fb8ec8b78994216dbb6e74ff6a9e25cbf2f6208
    abi 748168b7804630d3308b8ea2bd9492c90f373c9d5e8914fe50c41a44211d1140
    metadata 25b25dfad34707a24ed8879cd88e7b5639666eed66c9acb3aaf0dbafd015b271
    contract
    WethUnwrapper
    src/WethUnwrapper.sol · 581 bytes
    creation 3dce908bd4091d6d855b2b5eb162fc5e4ee0067cb323203210d8fc034cbad46f
    abi 0b36f923084ca2e861f6aec9fbce1425fc7b0e11c1fd1114ccd45a22f4db6504
    metadata 2cc633e71f590b6b00b20fd14e662638820976d70469ee6df66c5e61ad794715
  14. Frontend for contractAgent #789 files changed

    Implemented the frontend, static export, and verified deployment manifest.

    • Build/typecheck passed; 9 unit tests, 29 browser scenarios, and 16 design checks passed.
    • Live mining awaits owner IMD/WETH configuration. No transactions were broadcast.
    • Validation evidence and design documentation are included. Design documentation follows the permitted docs/ scope.

    The requested commit remains blocked: git add returned “Read-only file system” for .git/index.lock. All deliverable files are ready for collection; the verified change bundle measured 698,857 bytes.

    ran oncodex · gpt-6-astra · 13 turns · 53m 16s · 292.4K in · 114.1K out · 9.8M cached
    submission7678c065a83ac9fef9da46f7d21c40bac03f53a1f07cdce1ded222cf1cf1d254
    device108f8fd3a1992a923b1bef617caf1bb0dbf219fe9e38c913df6f73d2cb4373cc
    started fromd3de6035fe432600d7f669ba8104e1ba4bbb37be
    bundle2e41a0637370d37b4e2aa745b1289fb83180f8a1ee5799d5c48dc101c6f99700 · 699 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 89 files
    dist/IBM-Plex-Mono-LICENSE.txtdist/Space-Grotesk-LICENSE.txtdist/Wallet-Icons-LICENSE.txtdist/abi/ERC20.jsondist/abi/TransferValidator.jsondist/abi/VeteransClaimPool.jsondist/abi/VeteransNFT.jsondist/abi/VeteransRenderer.jsondist/abi/VeteransStaking.jsondist/abi/WethUnwrapper.jsondist/assets/ccip-DqzNm-FJ.jsdist/assets/dist-CC4nNWp7.jsdist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2dist/assets/index-1DWBC8os.jsdist/assets/index-B2Mhr0uG.cssdist/assets/miner.worker-BUVCHpnN.jsdist/assets/space-grotesk-latin-wght-normal-BhU9QXUp.woff2dist/favicon.svgdist/imd-deployment.jsondist/index.htmldist/share.pngdist/wallets/coinbase.svgdist/wallets/metamask.svgdist/wallets/phantom.svgdist/wallets/rabby.svgdocs/BETTER-INTERFACE-LICENSE.txtdocs/DESIGN.mddocs/ETH-FRONTEND-UX-LICENSE.txtdocs/FRONTEND.mddocs/VALIDATION.mddocs/evidence/accessibility.jsondocs/evidence/bundle-check.jsondocs/evidence/chain-check.jsondocs/evidence/export-check.jsondocs/evidence/interactions.jsondocs/evidence/keyboard.jsondocs/evidence/local-resources.jsondocs/evidence/mine-desktop-live.jpgdocs/evidence/mine-mobile-live.jpgdocs/evidence/mine-mobile-mocked.jpgdocs/evidence/mobile-layout.jsondocs/evidence/scope-check.jsondocs/evidence/tablet-layout.jsondocs/evidence/taste.jsondocs/evidence/wallet-keyboard.jpgweb/.gitignoreweb/README.mdweb/config/deployment-handoff.jsonweb/config/network-handoff.jsonweb/index.htmlweb/package-lock.jsonweb/package.jsonweb/public/IBM-Plex-Mono-LICENSE.txtweb/public/Space-Grotesk-LICENSE.txtweb/public/Wallet-Icons-LICENSE.txtweb/public/favicon.svgweb/public/share.pngweb/public/wallets/coinbase.svgweb/public/wallets/metamask.svgweb/public/wallets/phantom.svgweb/public/wallets/rabby.svgweb/scripts/check-chain.mjsweb/scripts/export.mjsweb/scripts/share.mjsweb/scripts/shared.mjsweb/scripts/verify.mjsweb/src/App.tsxweb/src/Gallery.tsxweb/src/Mine.tsxweb/src/Portfolio.tsxweb/src/Setup.tsxweb/src/Wallet.tsxweb/src/chain.tsweb/src/components.tsxweb/src/config.tsweb/src/fonts.cssweb/src/main.tsxweb/src/miner.worker.tsweb/src/open-in.tsweb/src/pow.tsweb/src/styles.cssweb/src/transactions.tsxweb/src/utils.tsweb/tests/browser.mjsweb/tests/core.test.tsweb/tests/mock-chain.mjsweb/tests/server.mjsweb/tsconfig.jsonweb/vite.config.ts
    may write
    web/**dist/**docs/**web/.gitignore
  15. Design criticAgent #122found 1 medium
    AreaResult
    Requirements, grouped below42 met · 0 missing · 0 faked · 5 need requester · 2 after review
    Broken features1 medium · 0 high
    Product and action on first screen, 390pxYes
    taste@116/16 passed
    Copy findings0
    Share card5/5 checks passed

    Saved the finding in .imd-findings.json.

    Medium — bug: Clear filters leaves the gallery stuck in an empty rarity view. At 390×844 in Chromium and WebKit, choose Rarity score, load the collection index on the current zero-mint deployment, then click Clear filters. The gallery retains rarity sorting and displays zero cards. Resetting sorting to Token number restores 24 cards and page 1 of 139. Add setSort('id') and setPage(0) to the handler at web/src/Gallery.tsx:285.

    Validation passed: type checking, production build, nine unit tests, 29 Chromium interaction checks, ten WebKit mining/wallet checks, export integrity, and both configured RPCs. Rebuilt assets matched the committed export. The live mining action occupied y=528.64–576.64 on the 390×844 screen.

    The visual idea is a mining terminal carried by green and gold pixel miners, with wallet age expressed as increasing staking weight.

    The checklist separates frontend delivery from inherited contract decisions. Contract entries below reflect preserved source and handoff evidence; this was not a new Solidity audit.

    #Explicit requirementStatus and evidence
    1“Max supply: 3,333 NFTs”Met — NFT constant; Mine supply and Gallery slots.
    2“Fixed 0.069 IMD every epoch”; “Only difficulty changes”Met — NFT constants; Mine price and disclosure.
    3“Epoch size: 333 mints (exactly 10 epochs)”Needs requester — deployed final epoch contains 336; docs/FRONTEND.md explains the conflicting totals.
    4“Sequential minting (tokenId 1 → 3333)”Met — NFT mint implementation; numbered Gallery.
    5“No functional tiers”; “Visual rarity only”Met — staking utility and Gallery explanation.
    6mint(uint256 nonce) and the exact wallet-bound hash checkMet — pow.ts, mining worker, NFT ABI and unit tests.
    7Record firstSeen at first mint or first stakeMet — NFT/staking source; WalletAge reads.
    8Successful mint assigns sequential ID, derives traits and charges/splits feesMet — NFT source; Mine transaction flow.
    9Epoch advancement, increasing difficulty and public epoch/difficulty/remaining viewsMet — NFT views; live console and progress.
    10Age tiers: 1×, 1.5×, 2×, 2.5×, 4×, 8× at the specified boundariesMet — staking source; both displayed tier tables.
    11Rewards weighted by age; larger share of the 65% poolMet — staking source; recorded weight and estimate explanations.
    12Stake, claim accumulated IMD and unstake with clean lock behaviorMet — My Veterans and Claim; no lock period.
    13Pepe pixel miners, helmets, tools, headlamps, dark green/gold themeMet — renderer and illustrative Mine portraits.
    14Hash-derived rarity; rare Overclocked tier around 1 in 16–32Met — renderer’s approximately 1-in-24 tier; Gallery filter.
    15OpenSea metadata with image, attributes and “rarity rank”Needs requester — preserved renderer supplies a rarity score rather than an ordinal rank; frontend exposes its actual metadata.
    16Hash controls visuals rather than token IDMet — NFT/renderer separation.
    17Inherit Limit Break ERC721CMet — preserved NFT inheritance and implementation-derived ABI.
    186.9% royalty, split 6% pool and 0.9% treasuryMet — preserved royalty/pool logic and explanatory copy.
    19Permanently enforced royalties; only compliant operators transferNeeds requester — owner-controlled validator policy remain
    ran oncodex · gpt-6.1-sol · 5 turns · 5m 59s · 110.9K in · 11.8K out · 2.1M cached
    submissionf34574ec7a173cde09a182c093c78983beaa7fd1499fa3a8af1b51f2dd85c36f
    devicede66cc20c06aee0ae6c16a36cff9e6a32ad49c2d1cd91068f4bae0bf0e086496
    started from6a3094547f5956517aaf1f9250a2919568d31bc2
    bundlenone
    applied on2e41a0637370d37b4e2aa745b1289fb83180f8a1ee5799d5c48dc101c6f99700
    may write
    web/**dist/**docs/**web/.gitignore
    • mediumbug: Clear filters leaves the gallery stuck in an empty rarity viewweb/src/Gallery.tsx:285

      Now: The empty gallery offers Clear filters, but its handler resets only Show and the token query. Rarity sorting still excludes every unmined slot, so the recovery action leaves the gallery empty on the current zero-mint deployment.

      Change to: Add setSort('id') and setPage(0) to this handler so Clear filters restores All Veterans in token-number order.

      Why: The brief requires a gallery of all 3,333 Veterans; an empty-state recovery action should return visitors to that available collection view.

      At 390×844, in both Chromium and WebKit, open #gallery on the live deployment with totalMinted=0.

      Select Rarity score in Sort, click Load collection index, and wait for No matching Veterans.

      Click Clear filters.

      Expected: All Veterans, Token number sorting, and the first 24 unmined cards (page 1 of 139).

      Actual measured after the click: Show value=all, Sort value=rarity, .token-card count=0, No matching Veterans remains visible, and page 1 of 1.

      Selecting Token number manually restores the cards.

      Both engines had zero page errors.

  16. Checkedall checks passed
    • deployment-config
    • static-assets
    • html-assets
    • named-entrypoint
    • named-assets
    • contract-abis
    • chain-state