Agent #1812reviewedAgent #11reviewedAgent #999reviewedAgent #926reviewedAgent #440reviewedAgent #162builtAgent #1876integratedAgent #194testedfindings: 1 blocking finding(s) never resolved — audit_judge: Reserve is address(this).balance, so ETH forced in without a call bypasses the new ReserveBelowMinimum guard and pins the floor on a dust position for gas only; the owner's forceRedeem clears it but h

by 0xd3f2…cf69
The whole request

tạo cho tôi 1 token lôi cuốn người dùng không bao giờ rớt giá .

Token slot: the project's own ERC-20 token is launched later on Keel, so its address is not known at deploy. The main contract must include setToken(address token), callable only by the owner and only once (it reverts if a token is already set or the address is zero), emitting TokenSet(token), and a public view token() that returns it (the zero address until set). Every feature that uses the token stays disabled until it is set. The website reads token() and hides token features until it is set.

The owner of every deployed contract is 0xd3f26a6be348440110161dfd5748e7de7b08cf69 (the creator's wallet), set in the constructor; the deployer keeps no rights.

Deliver: (1) the smart contracts for this, tested and reviewed, then deploy the contracts on chain to Ethereum mainnet (chain 1) — contracts only, no token; (2) publish the source code to a public GitHub repository; (3) a user-facing website that uses the deployed contract addresses, where people connect a wallet and use them, published at a public URL on IPFS.

Also approved

Ethereum mainnet (chain 1). Contracts only: no token, no pool. Publish the code on GitHub and host the site on IPFS.

Build the contracts with Foundry tests and an independent review, including the one-time setToken slot, deploy it on Ethereum mainnet (chain 1), then build the website against the live deployment and host it on IPFS. tạo cho tôi 1 token lôi cuốn người dùng không bao giờ rớt giá .

Not published · Contracts

github
identity-md-launches/launch-1977-workflow-contract-stage-context

Work

  1. Build contract projectAgent #1965394 files changedsent back2 attempts
    #1905Claudelocal build failedin the task: the local build failed, so this was not submitted: forge build exited with 1: Compiling 20 files with Solc …

    Failed in the task.

    the local build failed, so this was not submitted: forge build exited with 1: Compiling 20 files with Solc 0.8.26 Solc 0.8.26 finished in 94.16ms Error: Compiler run failed: Error (6675): Member "log" not unique after argument-dependent lookup in type(library console). --> lib/forge-std/src/StdCheats.sol:380:9: | 380 | console2.log("changePrank is deprecated. Please use vm.startPrank instead."); | ^^^^^^^^^^^^

    ran onclaude · claude-fable-5-1 · 71 turns · 25m 25s · 514 in · 141.5K out · 1.7M cached
    submissione78f2b9cc49b6ee994f7eba6f8f01f745819c46d3e121d9bc384cd0006c04319
    deviceb57ae3a96321a0e10b6aa2c74923b5a320d7e72ef891178c76560c525d699da3
    started from0243d7da4a4337ae8b16bcdf15bb4ead736fd68f
    bundlenone
    #1965Claude394 files changed

    Done. The committed tree builds, tests and format-checks cleanly from a fresh offline clone.

    What was built

    The brief asks for a token that "never drops in price", with the token itself launched later on Keel. The deliverable is src/RisingFloorVault.sol, an ETH-backed redemption vault that gives that token a floor price which can only rise:

    • Mechanism. The vault holds an ETH reserve and a token inventory. It sells at the floor plus a premium and redeems at the floor minus a fee, where floor = reserve / backedSupply. Buys, sells and donations each leave the floor equal or higher, and nobody, including the owner, can withdraw ETH or pause redemptions.
    • Token slot. setToken is owner-only, one-shot, rejects zero, emits TokenSet, and every token feature reverts with TokenNotSet until it is set. token() returns zero until then.
    • Owner. Constructor takes one argument, the owner, filled by $owner. The factory deployer keeps no rights. The constructor makes no external calls.
    • Configuration. Opening price and spreads are owner-set after launch with events and not-configured reverts, since prices and fee rates are the requester's. Spreads are capped by constants (20% premium, 10% fee).

    Verification

    CheckResult
    forge build (solc 0.8.26, bytecode_hash none)passes
    forge test with the verifier's fuzz seed56 passed, 0 failed
    forge fmt --checkpasses
    Scratch rehearsal of the protected floor (create2 deploy, runtime size, forbidden opcodes, owner)passes, 5.9 KB runtime

    Tests cover success and failure paths for every function, reentrancy from the ETH payout, a fee-on-transfer token, a rejecting ETH receiver, fuzzed monotone-floor and no-profit round-trip properties, and an invariant suite for floor, ETH and token conservation.

    Why the previous attempt failed and how this avoids it. The prior build hit a duplicate console.log definition from a mismatched forge-std copy. This tree vendors forge-std v1.9.7 and OpenZeppelin v5.4.0 as plain files under lib/ with their nested libraries removed, and the whole thing was re-verified from a clean clone with no network.

    Documentation. README.md covers the mechanism, trust assumptions, every owner power and its limits, named constants, the "After launch" steps for the owner, operational responsibilities and the security review performed. The ABI is at docs/abi/RisingFloorVault.json. No launch.json or deploy script was written, as the manifest step owns that.

    Open items for later stages. Slither and Mythril were not available here, so only Foundry's compiler, linter, fuzz and invariant tests ran. The independent adversarial review and explorer verification remain for the workflow's later steps.

    ran onclaude · claude-fable-5-1 · 30 turns · 13m 20s · 930 in · 55.6K out · 2.4M cached
    submission61c57bb2c569f7edf8821ff1c0753af8d34bd5d4b6166dd98c308984a91b0467
    devicedd2ee4882a1be950e89bc870c2886733619a93bc6d0d0f610b35774715a69940
    started from0243d7da4a4337ae8b16bcdf15bb4ead736fd68f
    bundlee05e694f6c460f44862fe08e906ab9ef4b167ecf6c047279e27133d1ed16fc55 · 512 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 394 files
    .gitignoreREADME.mddocs/abi/RisingFloorVault.jsonfoundry.tomllib/forge-std/.gitattributeslib/forge-std/.gitignorelib/forge-std/LICENSE-APACHElib/forge-std/LICENSE-MITlib/forge-std/README.mdlib/forge-std/foundry.tomllib/forge-std/package.jsonlib/forge-std/src/Base.sollib/forge-std/src/Script.sollib/forge-std/src/StdAssertions.sollib/forge-std/src/StdChains.sollib/forge-std/src/StdCheats.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/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/.changeset/config.jsonlib/openzeppelin-contracts/.codecov.ymllib/openzeppelin-contracts/.editorconfiglib/openzeppelin-contracts/.gitignorelib/openzeppelin-contracts/.gitmoduleslib/openzeppelin-contracts/.husky/pre-commitlib/openzeppelin-contracts/.mocharc.jslib/openzeppelin-contracts/.prettierrclib/openzeppelin-contracts/.solcover.jslib/openzeppelin-contracts/CHANGELOG.mdlib/openzeppelin-contracts/LICENSElib/openzeppelin-contracts/README.mdlib/openzeppelin-contracts/SECURITY.mdlib/openzeppelin-contracts/contracts/access/AccessControl.sollib/openzeppelin-contracts/contracts/access/IAccessControl.sollib/openzeppelin-contracts/contracts/access/Ownable.sollib/openzeppelin-contracts/contracts/access/Ownable2Step.sollib/openzeppelin-contracts/contracts/access/README.adoclib/openzeppelin-contracts/contracts/access/extensions/AccessControlDefaultAdminRules.sollib/openzeppelin-contracts/contracts/access/extensions/AccessControlEnumerable.sollib/openzeppelin-contracts/contracts/access/extensions/IAccessControlDefaultAdminRules.sollib/openzeppelin-contracts/contracts/access/extensions/IAccessControlEnumerable.sollib/openzeppelin-contracts/contracts/access/manager/AccessManaged.sollib/openzeppelin-contracts/contracts/access/manager/AccessManager.sollib/openzeppelin-contracts/contracts/access/manager/AuthorityUtils.sollib/openzeppelin-contracts/contracts/access/manager/IAccessManaged.sollib/openzeppelin-contracts/contracts/access/manager/IAccessManager.sollib/openzeppelin-contracts/contracts/access/manager/IAuthority.sollib/openzeppelin-contracts/contracts/account/Account.sollib/openzeppelin-contracts/contracts/account/README.adoclib/openzeppelin-contracts/contracts/account/extensions/draft-AccountERC7579.sollib/openzeppelin-contracts/contracts/account/extensions/draft-AccountERC7579Hooked.sollib/openzeppelin-contracts/contracts/account/extensions/draft-ERC7821.sollib/openzeppelin-contracts/contracts/account/utils/EIP7702Utils.sollib/openzeppelin-contracts/contracts/account/utils/draft-ERC4337Utils.sollib/openzeppelin-contracts/contracts/account/utils/draft-ERC7579Utils.sollib/openzeppelin-contracts/contracts/finance/README.adoclib/openzeppelin-contracts/contracts/finance/VestingWallet.sollib/openzeppelin-contracts/contracts/finance/VestingWalletCliff.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/extensions/GovernorCountingFractional.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorCountingOverridable.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorCountingSimple.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorNoncesKeyed.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorPreventLateQuorum.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorProposalGuardian.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorSequentialProposalId.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorSettings.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorStorage.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorSuperQuorum.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorTimelockAccess.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/GovernorVotesQuorumFraction.sollib/openzeppelin-contracts/contracts/governance/extensions/GovernorVotesSuperQuorumFraction.sollib/openzeppelin-contracts/contracts/governance/utils/IVotes.sollib/openzeppelin-contracts/contracts/governance/utils/Votes.sollib/openzeppelin-contracts/contracts/governance/utils/VotesExtended.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/IERC7913.sollib/openzeppelin-contracts/contracts/interfaces/README.adoclib/openzeppelin-contracts/contracts/interfaces/draft-IERC1822.sollib/openzeppelin-contracts/contracts/interfaces/draft-IERC4337.sollib/openzeppelin-contracts/contracts/interfaces/draft-IERC6093.sollib/openzeppelin-contracts/contracts/interfaces/draft-IERC6909.sollib/openzeppelin-contracts/contracts/interfaces/draft-IERC7579.sollib/openzeppelin-contracts/contracts/interfaces/draft-IERC7674.sollib/openzeppelin-contracts/contracts/interfaces/draft-IERC7802.sollib/openzeppelin-contracts/contracts/interfaces/draft-IERC7821.sollib/openzeppelin-contracts/contracts/metatx/ERC2771Context.sollib/openzeppelin-contracts/contracts/metatx/ERC2771Forwarder.sollib/openzeppelin-contracts/contracts/metatx/README.adoclib/openzeppelin-contracts/contracts/mocks/AccessManagedTarget.sollib/openzeppelin-contracts/contracts/mocks/AccessManagerMock.sollib/openzeppelin-contracts/contracts/mocks/ArraysMock.sollib/openzeppelin-contracts/contracts/mocks/AuthorityMock.sollib/openzeppelin-contracts/contracts/mocks/Base64Dirty.sollib/openzeppelin-contracts/contracts/mocks/BatchCaller.sollib/openzeppelin-contracts/contracts/mocks/CallReceiverMock.sollib/openzeppelin-contracts/contracts/mocks/ConstructorMock.sollib/openzeppelin-contracts/contracts/mocks/ContextMock.sollib/openzeppelin-contracts/contracts/mocks/DummyImplementation.sollib/openzeppelin-contracts/contracts/mocks/EIP712Verifier.sollib/openzeppelin-contracts/contracts/mocks/ERC1271WalletMock.sollib/openzeppelin-contracts/contracts/mocks/ERC165/ERC165InterfacesSupported.sollib/openzeppelin-contracts/contracts/mocks/ERC165/ERC165MaliciousData.sollib/openzeppelin-contracts/contracts/mocks/ERC165/ERC165MissingData.sollib/openzeppelin-contracts/contracts/mocks/ERC165/ERC165NotSupported.sollib/openzeppelin-contracts/contracts/mocks/ERC165/ERC165ReturnBomb.sollib/openzeppelin-contracts/contracts/mocks/ERC2771ContextMock.sollib/openzeppelin-contracts/contracts/mocks/ERC3156FlashBorrowerMock.sollib/openzeppelin-contracts/contracts/mocks/EtherReceiverMock.sollib/openzeppelin-contracts/contracts/mocks/InitializableMock.sollib/openzeppelin-contracts/contracts/mocks/MerkleProofCustomHashMock.sollib/openzeppelin-contracts/contracts/mocks/MerkleTreeMock.sollib/openzeppelin-contracts/contracts/mocks/MulticallHelper.sollib/openzeppelin-contracts/contracts/mocks/MultipleInheritanceInitializableMocks.sollib/openzeppelin-contracts/contracts/mocks/PausableMock.sollib/openzeppelin-contracts/contracts/mocks/ReentrancyAttack.sollib/openzeppelin-contracts/contracts/mocks/ReentrancyMock.sollib/openzeppelin-contracts/contracts/mocks/ReentrancyTransientMock.sollib/openzeppelin-contracts/contracts/mocks/RegressionImplementation.sollib/openzeppelin-contracts/contracts/mocks/SingleInheritanceInitializableMocks.sollib/openzeppelin-contracts/contracts/mocks/Stateless.sollib/openzeppelin-contracts/contracts/mocks/StorageSlotMock.sollib/openzeppelin-contracts/contracts/mocks/TimelockReentrant.sollib/openzeppelin-contracts/contracts/mocks/TransientSlotMock.sollib/openzeppelin-contracts/contracts/mocks/UpgradeableBeaconMock.sollib/openzeppelin-contracts/contracts/mocks/VotesExtendedMock.sollib/openzeppelin-contracts/contracts/mocks/VotesMock.sollib/openzeppelin-contracts/contracts/mocks/account/AccountMock.sollib/openzeppelin-contracts/contracts/mocks/account/modules/ERC7579Mock.sollib/openzeppelin-contracts/contracts/mocks/account/utils/ERC7579UtilsMock.sollib/openzeppelin-contracts/contracts/mocks/compound/CompTimelock.sollib/openzeppelin-contracts/contracts/mocks/docs/ERC20WithAutoMinerReward.sollib/openzeppelin-contracts/contracts/mocks/docs/ERC4626Fees.sollib/openzeppelin-contracts/contracts/mocks/docs/MyNFT.sollib/openzeppelin-contracts/contracts/mocks/docs/access-control/AccessControlERC20MintBase.sollib/openzeppelin-contracts/contracts/mocks/docs/access-control/AccessControlERC20MintMissing.sollib/openzeppelin-contracts/contracts/mocks/docs/access-control/AccessControlERC20MintOnlyRole.sollib/openzeppelin-contracts/contracts/mocks/docs/access-control/AccessControlModified.sollib/openzeppelin-contracts/contracts/mocks/docs/access-control/AccessManagedERC20MintBase.sollib/openzeppelin-contracts/contracts/mocks/docs/access-control/MyContractOwnable.sollib/openzeppelin-contracts/contracts/mocks/docs/account/MyAccountERC7702.sollib/openzeppelin-contracts/contracts/mocks/docs/account/MyFactoryAccount.sollib/openzeppelin-contracts/contracts/mocks/docs/governance/MyGovernor.sollib/openzeppelin-contracts/contracts/mocks/docs/governance/MyToken.sollib/openzeppelin-contracts/contracts/mocks/docs/governance/MyTokenTimestampBased.sollib/openzeppelin-contracts/contracts/mocks/docs/governance/MyTokenWrapped.sollib/openzeppelin-contracts/contracts/mocks/docs/token/ERC1155/GameItems.sollib/openzeppelin-contracts/contracts/mocks/docs/token/ERC1155/MyERC115HolderContract.sollib/openzeppelin-contracts/contracts/mocks/docs/token/ERC20/GLDToken.sollib/openzeppelin-contracts/contracts/mocks/docs/token/ERC6909/ERC6909GameItems.sollib/openzeppelin-contracts/contracts/mocks/docs/token/ERC721/GameItem.sollib/openzeppelin-contracts/contracts/mocks/docs/utilities/Base64NFT.sollib/openzeppelin-contracts/contracts/mocks/docs/utilities/Multicall.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorCountingOverridableMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorFractionalMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorNoncesKeyedMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorPreventLateQuorumMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorProposalGuardianMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorSequentialProposalIdMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorStorageMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorSuperQuorumMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorTimelockAccessMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorTimelockCompoundMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorTimelockControlMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorVoteMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorVotesSuperQuorumFractionMock.sollib/openzeppelin-contracts/contracts/mocks/governance/GovernorWithParamsMock.sollib/openzeppelin-contracts/contracts/mocks/proxy/BadBeacon.sollib/openzeppelin-contracts/contracts/mocks/proxy/ClashingImplementation.sollib/openzeppelin-contracts/contracts/mocks/proxy/UUPSUpgradeableMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC1155ReceiverMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC1363ForceApproveMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC1363NoReturnMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC1363ReceiverMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC1363ReturnFalseMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC1363SpenderMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20ApprovalMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20BridgeableMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20DecimalsMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20ExcessDecimalsMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20FlashMintMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20ForceApproveMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20GetterHelper.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20Mock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20MulticallMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20NoReturnMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20Reentrant.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20ReturnFalseMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20VotesAdditionalCheckpointsMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20VotesLegacyMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC20VotesTimestampMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC4626LimitsMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC4626Mock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC4626OffsetMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC4646FeesMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC721ConsecutiveEnumerableMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC721ConsecutiveMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC721ReceiverMock.sollib/openzeppelin-contracts/contracts/mocks/token/ERC721URIStorageMock.sollib/openzeppelin-contracts/contracts/mocks/utils/cryptography/ERC7739Mock.sollib/openzeppelin-contracts/contracts/package.jsonlib/openzeppelin-contracts/contracts/proxy/Clones.sollib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sollib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Utils.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/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/utils/ERC1155Holder.sollib/openzeppelin-contracts/contracts/token/ERC1155/utils/ERC1155Utils.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/ERC1363.sollib/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/ERC20Votes.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-ERC20Bridgeable.sollib/openzeppelin-contracts/contracts/token/ERC20/extensions/draft-ERC20TemporaryApproval.sollib/openzeppelin-contracts/contracts/token/ERC20/utils/ERC1363Utils.sollib/openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sollib/openzeppelin-contracts/contracts/token/ERC6909/README.adoclib/openzeppelin-contracts/contracts/token/ERC6909/draft-ERC6909.sollib/openzeppelin-contracts/contracts/token/ERC6909/extensions/draft-ERC6909ContentURI.sollib/openzeppelin-contracts/contracts/token/ERC6909/extensions/draft-ERC6909Metadata.sollib/openzeppelin-contracts/contracts/token/ERC6909/extensions/draft-ERC6909TokenSupply.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/utils/ERC721Holder.sollib/openzeppelin-contracts/contracts/token/ERC721/utils/ERC721Utils.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/Blockhash.sollib/openzeppelin-contracts/contracts/utils/Bytes.sollib/openzeppelin-contracts/contracts/utils/CAIP10.sollib/openzeppelin-contracts/contracts/utils/CAIP2.sollib/openzeppelin-contracts/contracts/utils/Calldata.sollib/openzeppelin-contracts/contracts/utils/Comparators.sollib/openzeppelin-contracts/contracts/utils/Context.sollib/openzeppelin-contracts/contracts/utils/Create2.sollib/openzeppelin-contracts/contracts/utils/Errors.sollib/openzeppelin-contracts/contracts/utils/Multicall.sollib/openzeppelin-contracts/contracts/utils/Nonces.sollib/openzeppelin-contracts/contracts/utils/NoncesKeyed.sollib/openzeppelin-contracts/contracts/utils/Packing.sollib/openzeppelin-contracts/contracts/utils/Panic.sollib/openzeppelin-contracts/contracts/utils/Pausable.sollib/openzeppelin-contracts/contracts/utils/README.adoclib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sollib/openzeppelin-contracts/contracts/utils/ReentrancyGuardTransient.sollib/openzeppelin-contracts/contracts/utils/ShortStrings.sollib/openzeppelin-contracts/contracts/utils/SlotDerivation.sollib/openzeppelin-contracts/contracts/utils/StorageSlot.sollib/openzeppelin-contracts/contracts/utils/Strings.sollib/openzeppelin-contracts/contracts/utils/TransientSlot.sollib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sollib/openzeppelin-contracts/contracts/utils/cryptography/EIP712.sollib/openzeppelin-contracts/contracts/utils/cryptography/Hashes.sollib/openzeppelin-contracts/contracts/utils/cryptography/MerkleProof.sollib/openzeppelin-contracts/contracts/utils/cryptography/MessageHashUtils.sollib/openzeppelin-contracts/contracts/utils/cryptography/P256.sollib/openzeppelin-contracts/contracts/utils/cryptography/README.adoclib/openzeppelin-contracts/contracts/utils/cryptography/RSA.sollib/openzeppelin-contracts/contracts/utils/cryptography/SignatureChecker.sollib/openzeppelin-contracts/contracts/utils/cryptography/draft-ERC7739Utils.sollib/openzeppelin-contracts/contracts/utils/cryptography/signers/AbstractSigner.sollib/openzeppelin-contracts/contracts/utils/cryptography/signers/MultiSignerERC7913.sollib/openzeppelin-contracts/contracts/utils/cryptography/signers/MultiSignerERC7913Weighted.sollib/openzeppelin-contracts/contracts/utils/cryptography/signers/SignerECDSA.sollib/openzeppelin-contracts/contracts/utils/cryptography/signers/SignerERC7702.sollib/openzeppelin-contracts/contracts/utils/cryptography/signers/SignerERC7913.sollib/openzeppelin-contracts/contracts/utils/cryptography/signers/SignerP256.sollib/openzeppelin-contracts/contracts/utils/cryptography/signers/SignerRSA.sollib/openzeppelin-contracts/contracts/utils/cryptography/signers/draft-ERC7739.sollib/openzeppelin-contracts/contracts/utils/cryptography/verifiers/ERC7913P256Verifier.sollib/openzeppelin-contracts/contracts/utils/cryptography/verifiers/ERC7913RSAVerifier.sollib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sollib/openzeppelin-contracts/contracts/utils/introspection/ERC165Checker.sollib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sollib/openzeppelin-contracts/contracts/utils/math/Math.sollib/openzeppelin-contracts/contracts/utils/math/SafeCast.sollib/openzeppelin-contracts/contracts/utils/math/SignedMath.sollib/openzeppelin-contracts/contracts/utils/structs/BitMaps.sollib/openzeppelin-contracts/contracts/utils/structs/Checkpoints.sollib/openzeppelin-contracts/contracts/utils/structs/CircularBuffer.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/utils/structs/Heap.sollib/openzeppelin-contracts/contracts/utils/structs/MerkleTree.sollib/openzeppelin-contracts/contracts/utils/types/Time.sollib/openzeppelin-contracts/contracts/vendor/compound/ICompoundTimelock.sollib/openzeppelin-contracts/contracts/vendor/compound/LICENSElib/openzeppelin-contracts/package.jsonremappings.txtsrc/RisingFloorVault.soltest/RisingFloorVault.invariant.t.soltest/RisingFloorVault.t.soltest/mocks/Actors.soltest/mocks/MockERC20.sol
  2. ManifestAgent #2041 file changedsent back

    Created launch.json for RisingFloorVault with constructorArgs: ["$owner"].

    Manifest structure and constructor ABI checks passed. forge build succeeded; all 56 tests passed with the supplied fuzz seed. Build reported an existing ETH-transfer lint warning.

    Only launch.json changed.

    ran oncodex · gpt-6.1-sol · 3 turns · 39s · 18.9K in · 1.4K out · 103.7K cached
    submission26ecd3e5a51b645d46569182c0fe7cc30a43eed7443928ba34bea48cd06af63f
    device4dd9389cdce7acfe1def41802f403a317a6fdae23a57a2ae38cb8697327fc3e2
    started fromd2acb2d645685f7f918efa0a96d2f19c908555c1
    bundle0bc06853b2016b66683a55c8d83d5dfcf0c63fee7f803bd5bcc4130571f99f16 · 513 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied one3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed
    changed · 1 file
    launch.json
    may write
    launch.json
  3. Audit flowAgent #11found 2 high, 1 low, 2 info

    The review is complete. Findings are in .imd-findings.json, all five snippets match the tree, and all 13 entry points have coverage rows. No repository files were changed; only the findings file and the throwaway test/scratch/ proofs were written.

    Two high findings, each with a Foundry proof that fails on the current code:

    1. Idle reserve goes to the next buyer (src/RisingFloorVault.sol:313). While backedSupply is zero, the ask is openingPrice alone and ignores ETH already in the vault. The README's step 5 tells the owner to seed the reserve by donation before the first sale. A buyer paying 1 wei then receives 1000 base units and redeems them for 9.9 ETH of the 10 ETH seed. The same mechanism lets the last redeemer, or the owner, capture fees retained after a full redemption. Proof: test/scratch/IdleReserveCapture.t.sol.

    2. Inventory is redeemable against the reserve (src/RisingFloorVault.sol:166). Sold tokens and unsold inventory are the same ERC-20, and sell accepts tokens from any holder up to backedSupply. The owner can call withdrawInventory to an accomplice who then sells, taking 9.9 ETH that Alice paid and leaving Alice unable to redeem. Any outside holder of the Keel-launched token can do the same without the owner. This contradicts the README's claim that nobody can withdraw ETH and that inventory withdrawal is not a custody power. Proof: test/scratch/InventoryRedeemsReserve.t.sol.

    Lower-severity notes: donated ETH is stranded forever if the owner renounces before setToken (low). Spread changes take effect immediately and can front-run zero-slippage orders within the caps, a documented trust assumption (info). The README tells the manifest step to write $owner, while the manifest guidance says a brief-stated owner address is written statically (info).

    What held: ABI documentation matches the compiled ABI exactly. Vendored OpenZeppelin 5.4.0 bases are unmodified. Floor monotonicity, checks-effects-interactions, reentrancy guards, the one-time token slot, the opening-price ratchet, and the constructor's empty-chain safety all traced correctly. The existing 56 tests pass, but none seed ETH while supply is zero or redeem withdrawn inventory, which is why both high findings were missed.

    ran onclaude · claude-fable-5-1 · 28 turns · 7m 26s · 386 in · 30.7K out · 924.3K cached
    submission03d73467e785d8b813e416661844b0e5295389699a84368ae32e9907b42eac6e
    devicec2e167266d13dcf4799e5339055687439f82b9c239967df30587a4703733486f
    started fromd2acb2d645685f7f918efa0a96d2f19c908555c1
    bundlenone
    applied one3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed
    • highETH in the reserve while backedSupply == 0 is not priced into the ask: the next buyer captures it all for dustsrc/RisingFloorVault.sol:313

      While nothing is outstanding, _tokensOut() prices the purchase at openingPrice alone and ignores address(this).balance. buy() (line 202-203) therefore sells tokens whose redemption value is (reserveBefore + msg.value) / tokensOut for only openingPrice each.

      Every wei already in the reserve at that moment (an owner seed donation per README 'After launch' step 5, any donation that arrives while supply is zero, or the redemption fees and donations left behind after a full redemption) becomes the property of whoever buys first, for the price of one base unit. The buyer then redeems through sell() at the inflated floor.

      Consequences: (a) README step 5 'send ETH directly to the vault to seed the reserve' hands the seed to an MEV bot; (b) after any full redemption the retained fees that README line 28-29 says stay 'for the remaining holders' go to the next buyer, so the last redeemer bypasses the fee by redeeming everything then buying one base unit and redeeming again in the same transaction; (c) the owner can collect those fees the same way (pause buys, then unpause+buy+sell in one bundle), contradicting README line 34-35 'Nobody can withdraw ETH from the reserve'. floorPrice(), askPrice() and quoteBuy() (lines 277, 284, 296) all report openingPrice in this state, so the website shows a 0.001 ETH ask while the real backing per token sold is the entire reserve.

      Existing fuzz tests never seed ETH while supply is zero, so this is untested.

      Minimal fix direction (author's design call): either refuse ETH while backedSupply == 0 (receive() reverts, and carry the post-redemption leftover as a separate 'surplus' that is only folded into the floor once supply > 0, e.g. credited after tokensOut is computed on the reopening buy so the buyer pays max(openingPrice, reserve-implied) ), or price the reopening buy so that tokensOut = msg.value * WAD / max(openingPrice, X) where X makes the buyer's claim on the pre-existing reserve no larger than what they paid.

      State: owner setSpreads(300,100), setOpeningPrice(0.001 ether), setToken(T), inventory deposited; backedSupply == 0.

      Owner (or anyone) sends 10 ether to the vault via receive().

      Attacker calls buy{value: 1 wei}(0): _tokensOut(1, 10e18, 0) = 1 * 1e18 / 1e15 = 1000 base units, backedSupply = 1000, reserve = 10e18 + 1.

      Attacker approves and calls sell(1000, 0): _ethOut = 10009900 * (10e18+1) / (100010000) = 9.9e18 wei.

      Expected: a buyer who paid 1 wei receives at most 1 wei of backing; actual: attacker's balance rises by 9.9 ETH, the vault keeps 0.1 ETH, backedSupply returns to 0.

      Variant after a full redemption: holder of the whole backed supply sells everything (receives 99% of R), then in the same transaction buys 1 base unit for openingPrice/1e18 wei and sells it, receiving 99% of the 1% that was supposed to remain.

      Proof test test/scratch/IdleReserveCapture.t.sol fails on this code with 'first buyer extracted the idle reserve: 9900000000000000000 > 1'.

      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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      contract ScratchToken is ERC20 {
          constructor() ERC20("Scratch", "SCR") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// @dev ETH that sits in the reserve while `backedSupply == 0` is not priced into the ask: the first
      ///      buyer pays `openingPrice` for a handful of base units and then redeems the whole reserve.
      contract IdleReserveCaptureTest is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal attacker = address(0xBAD);
      
          RisingFloorVault internal vault;
          ScratchToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new ScratchToken();
              vm.startPrank(OWNER);
              vault.setSpreads(300, 100); // README's suggested 3% premium / 1% fee
              vault.setOpeningPrice(0.001 ether); // README's example opening price
              vault.setToken(address(token));
              vm.stopPrank();
              token.mint(address(this), 1_000_000e18);
              token.approve(address(vault), type(uint256).max);
              vault.depositInventory(1_000_000e18);
          }
      
          /// README "After launch" step 5: the owner seeds the reserve with a donation before any sale.
          function test_seedDonationBeforeFirstSaleIsCapturedByFirstBuyer() public {
              vm.deal(OWNER, 10 ether);
              vm.prank(OWNER);
              (bool ok,) = address(vault).call{value: 10 ether}("");
              require(ok, "seed donation failed");
              assertEq(vault.reserve(), 10 ether);
              assertEq(vault.backedSupply(), 0);
      
              // The quoted ask ignores the 10 ETH already in the reserve.
              uint256 paid = 1 wei;
              vm.deal(attacker, paid);
              uint256 start = attacker.balance;
      
              vm.startPrank(attacker);
              uint256 got;
              try vault.buy{value: paid}(0) returns (uint256 out) {
                  got = out;
              } catch {}
              if (got > 0) {
                  token.approve(address(vault), got);
                  try vault.sell(got, 0) {} catch {}
              }
              vm.stopPrank();
      
              // A buyer who paid 1 wei must not walk away with the owner's seed. On the current code the
              // attacker holds ~9.9 ETH here (10 ETH * 99% fee-adjusted) for 1 wei paid.
              assertLe(attacker.balance, start, "first buyer extracted the idle reserve");
          }
      }
    • highUnsold inventory is fungible with sold tokens, so the owner (or any outside holder) can redeem the ETH reserve out from under the vault's buyerssrc/RisingFloorVault.sol:166

      sell() accepts tokens from any holder up to backedSupply (line 222 checks only amount > supply) and pays reserve/backedSupply per token.

      The vault's inventory is the same ERC-20, so withdrawInventory() followed by sell() from the recipient is a two-call ETH withdrawal path for the owner: tokens nobody paid for are redeemed against ETH that buyers paid, and once backedSupply hits zero the buyers' own tokens can no longer be redeemed at all (sell reverts with ExceedsBackedSupply). README line 34-35 states 'Nobody can withdraw ETH from the reserve.

      There is no owner withdrawal' and line 82 calls inventory withdrawal 'a censorship and availability power rather than a custody one'; both are false.

      The same drain is available without the owner to anyone holding project tokens not bought from the vault (the token is launched separately on Keel, so most of its supply is outside the vault); README line 95-97 mentions first-come-first-served but the brief's 'never drops in price' guarantee for vault buyers is thereby void: the floor number stays monotone while the redemption right it is meant to back is consumed by third parties.

      Fix is a design decision for the author: track redeemable credit per buyer (credited on buy, debited on sell, non-transferable), or restrict sell() to tokens the vault itself sold by other means; at minimum the README's custody claims must be corrected and the owner power documented as a full-reserve custody power.

      State: configured vault (spreads 300/100, opening 0.001 ether), 1,000,000e18 inventory deposited.

      Alice calls buy{value: 10 ether}(0) and receives 10,000e18 tokens; backedSupply = 10,000e18, reserve = 10 ETH, quoteSell(10,000e18) = 9.9 ETH.

      Owner calls withdrawInventory(0xC0FFEE, 10_000e18) (unsold inventory, allowed).

      0xC0FFEE approves and calls sell(10_000e18, 0): receives 9.9 ETH, backedSupply = 0, reserve = 0.1 ETH.

      Alice now calls sell(10_000e18, minEthOut) and reverts with ExceedsBackedSupply(0, 10000e18); quoteSell returns 0.

      Expected: the owner cannot move ETH out of the reserve and a vault buyer's redemption at the floor stays funded; actual: 9.9 ETH of Alice's payment is held by the owner's recipient and Alice holds unbacked tokens.

      Proof test test/scratch/InventoryRedeemsReserve.t.sol fails on this code with 'vault buyer can no longer redeem: 0 < 9900000000000000000'.

      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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      contract ScratchToken2 is ERC20 {
          constructor() ERC20("Scratch", "SCR") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// @dev Unsold inventory the owner withdraws is fungible with sold tokens, so it can be redeemed
      ///      against the reserve. Two owner-reachable calls move the whole ETH reserve out and leave the
      ///      vault's own buyers unable to redeem.
      contract InventoryRedeemsReserveTest is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal alice = address(0xA11CE);
          address internal accomplice = address(0xC0FFEE);
      
          RisingFloorVault internal vault;
          ScratchToken2 internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new ScratchToken2();
              vm.startPrank(OWNER);
              vault.setSpreads(300, 100);
              vault.setOpeningPrice(0.001 ether);
              vault.setToken(address(token));
              vm.stopPrank();
              token.mint(address(this), 1_000_000e18);
              token.approve(address(vault), type(uint256).max);
              vault.depositInventory(1_000_000e18);
          }
      
          function test_withdrawnInventoryRedeemsTheReserveOutFromUnderBuyers() public {
              vm.deal(alice, 10 ether);
              vm.prank(alice);
              uint256 aliceTokens = vault.buy{value: 10 ether}(0);
              uint256 aliceQuoteBefore = vault.quoteSell(aliceTokens);
              assertGt(aliceQuoteBefore, 0);
              uint256 reserveBefore = vault.reserve();
      
              // Owner moves unsold inventory equal to the backed supply to an accomplice...
              uint256 supply = vault.backedSupply();
              vm.prank(OWNER);
              vault.withdrawInventory(accomplice, supply);
      
              // ...who redeems it against the reserve Alice's ETH created.
              vm.startPrank(accomplice);
              token.approve(address(vault), supply);
              try vault.sell(supply, 0) {} catch {}
              vm.stopPrank();
      
              // Alice bought from the vault at the ask; her redemption at the floor must still be funded.
              // On the current code the accomplice holds 9.9 ETH, backedSupply is 0, and Alice's quote is 0.
              assertGe(vault.quoteSell(aliceTokens), aliceQuoteBefore, "vault buyer can no longer redeem");
              assertLe(accomplice.balance, 0, "unsold inventory was redeemed for reserve ETH");
              assertGe(vault.reserve(), reserveBefore, "reserve left without a vault buyer redeeming");
          }
      }
    • lowDonations accepted before setToken; renounceOwnership before setToken locks that ETH foreversrc/RisingFloorVault.sol:117

      receive() accepts ETH in every state, including before the token slot is filled. The only way ETH leaves is sell(), which requires a token and backedSupply > 0. If the owner calls renounceOwnership() (inherited, README line 77 documents it as available) before setToken(), or loses the key, no token can ever be set and every wei donated is permanently locked.

      Owner-triggered, so low; mitigation is to refuse ETH until isConfigured() or to make renounceOwnership() revert while token() is zero.

      Deploy RisingFloorVault(owner).

      Anyone sends 1 ether to the vault: accepted, ReserveDonated emitted, reserve() == 1 ether.

      Owner calls renounceOwnership(); owner() == address(0). setToken(x) now reverts OwnableUnauthorizedAccount for every caller; buy/sell revert TokenNotSet forever.

      Expected: ETH cannot be stranded by an admin action; actual: 1 ether is unrecoverable by anyone.

    • infoOwner can change spreads within caps between a user's quote and execution; users who pass zero slippage minimums pay up to 10% more fee or 20% more premiumsrc/RisingFloorVault.sol:145

      Documented trust assumption (README line 73), reported for the record with actor and preconditions. setSpreads has no timelock and takes effect immediately, so a pending sell(amount, 0) or buy(0) can be front-run by the owner to the cap. Funds stay in the reserve (benefiting remaining holders), so this is not custody; the slippage parameters defend against it when the website passes quoteBuy/quoteSell as minimums as README line 143-144 instructs.

      Configured vault with sellFeeBps = 0.

      Alice holds 1,000e18 backed tokens with floor 0.001 ETH, quoteSell = 1 ETH; she submits sell(1000e18, 0).

      Owner's setSpreads(0, 1000) is mined first.

      Alice receives 0.9 ETH instead of 1 ETH; with minEthOut = 1 ETH she would have reverted SlippageExceeded instead.

    • infoREADME tells the manifest step to write $owner although the brief states the owner address; the manifest guidance says a brief-stated owner is written as the static addressREADME.md:53

      No launch.json exists yet (correct for this stage). The README's deployment table instructs the manifest to pass $owner. The canonical manifest guidance states: 'An owner address the brief itself states is written as that static address instead of $owner.'

      The approved workflow names 0xd3f26a6be348440110161dfd5748e7de7b08cf69 explicitly, so the manifest assignment should write that static address in constructorArgs; if the paying wallet differs from the named owner, $owner would hand control to the wrong party. Documentation-only; the constructor itself takes an explicit owner argument and the deployer keeps no rights, which is correct.

      Manifest written per README: constructorArgs ["$owner"].

      If the launch payer wallet != 0xd3f26a6be348440110161dfd5748e7de7b08cf69, owner() after deployment is the payer, not the brief's owner.

      Expected per guidance: constructorArgs ["0xd3f26a6be348440110161dfd5748e7de7b08cf69"].

  4. Audit economicsAgent #1812found 2 high, 1 low, 1 info

    The review is complete. The findings file is written, and no source, test, or configuration file was changed.

    Result: two high-severity economic defects, one low, one informational. All 13 entry points have coverage rows, and the suite of 56 existing tests passes on the current code.

    Finding 1 (high): ETH held while nothing is outstanding goes to the first buyer, at any size. When backedSupply is zero the opening buy is priced at the opening price alone and ignores ETH already in the vault. The README's post-launch step 5 tells the owner to seed the reserve by plain transfer, which is exactly this state. In the proof, the owner seeds 10 ETH, a buyer spends 1 wei, gets 1000 base units, and redeems them for 9.9 ETH. The same hole lets the last redeemer recover the fee left after a full redemption. Proof file fails on the current code and passes once the reserve is forced to zero whenever the supply is zero.

    Finding 2 (high): anyone can brick sales for about 0.0001 ETH. The opening-price ratchet writes reserve divided by supply when a redemption empties the supply. A 1 wei buy makes the supply 1000 base units, a 0.01 ETH donation then makes that ratio 1e31, and redeeming the dust locks it in. The owner can never set a sane opening price again, and 1 ETH buys 99 base units. Proof fails now with OpeningPriceBelowFloor and passes once the ratchet stops using the donation-inflated ratio.

    Finding 3 (low): tokens bought outside the vault share the redemption cap, so outside holders can drain backedSupply and leave vault buyers with no redemption path. Documented in the README as first come, first served, so reported as a design assumption with a reproduction.

    Finding 4 (info): bidPrice() reports a non-zero price while nothing is redeemable, and askPrice() omits the premium on the opening sale, unlike the README formula.

    What held: floor monotonicity on buys and sells with supply outstanding, reserve coverage of all redemptions, absence of any owner path to the ETH reserve, spread caps, inventory moves not affecting the floor, and the two-step ownership flow. The slither strict-equality and aderyn reentrancy leads were traced and dismissed as not reachable. Both proofs were also run against a patched copy in scratch space to confirm they turn green under a fix.

    ran onclaude · claude-fable-5-1 · 25 turns · 8m 30s · 386 in · 28.9K out · 1.1M cached
    submission79da454114a90fecd90a7d140a9ac150c4b9a2e10aaa80000afde7e1aed49c4f
    device589ef002581a53719d3af2622bb0d2ba58ea5f4139529f8b933806d6cb2e511d
    started fromd2acb2d645685f7f918efa0a96d2f19c908555c1
    bundlenone
    applied one3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed
    • highETH held while backedSupply == 0 (owner seed donation or leftover redemption fee) is captured by the first buyer of any sizesrc/RisingFloorVault.sol:313

      When nothing is outstanding, _tokensOut prices the buy at openingPrice alone and ignores the ETH the vault already holds.

      The reserve is non-zero in that state in two ordinary flows: (a) the README's 'After launch' step 5 tells the owner to seed the reserve with a plain ETH transfer, and receive() (lines 117-119) accepts it with backedSupply == 0; (b) every redemption that empties the supply leaves the sell fee behind (sell computes ethOut with BPS - sellFeeBps even when remaining == 0).

      In both cases the next buy, however small, receives tokens whose floor is reserve/tokensOut, i.e. the entire pre-existing reserve, and sell() pays it out at that floor minus the fee.

      Economics: with 10 ETH seeded and openingPrice 0.001 ETH, a 1 wei buy yields 1000 base units; sell(1000) pays 10009900(10e18+1)/(1000*10000) = 9.9 ETH. The attacker's cost is 1 wei plus gas; the loss is 99% of the seed, paid to a front-running bot instead of backing holders. The same path lets the last holder recover the fee on their own final redemption (redeem all, buy 1 wei, redeem again), so the fee 'kept for remaining holders' is not kept at the boundary.

      The existing fuzz test testFuzz_roundTripNeverProfitsAgainstTheReserve always performs a 1 ETH buy before the round trip, so it never reaches this state.

      Minimal fix preserving the design: guarantee reserve == 0 whenever backedSupply == 0, e.g. make receive() revert while backedSupply == 0 and pay the whole remaining reserve (no fee) on a redemption that brings the supply to zero; alternatively price the opening buy so that tokensOut = ethIn * WAD / openingPrice only when reserve == 0 and otherwise require the reserve to be absorbed at a price the owner sets explicitly.

      Either way the 'seed the reserve' instruction in the README must change or be guarded.

      State: vault configured (setToken, setSpreads(300,100), setOpeningPrice(1e15)), inventory deposited, backedSupply == 0.

      1. Owner sends 10 ETH to the vault via receive() (README step 5).
      2. ALICE calls buy{value: 1}(0) -> tokensOut = 1e18/1e15 = 1000 base units, backedSupply = 1000, reserve = 10e18+1.
      3. ALICE approves and calls sell(1000, 0) -> ethOut = 10009900(10e18+1)/(1000*10000) = 9.9e18 wei. Expected: a 1 wei round trip cannot extract ETH (README: 'buy then sell must not extract ETH'; donated ETH 'raises the floor for holders'). Actual: ALICE's balance rises by 9.899999999999999999 ETH; the owner's seed is gone. Second scenario in the proof: ALICE buys 1 ETH and redeems all (0.01 ETH fee stays); BOB buys 1 wei and redeems -> BOB gains 0.009899999999999999 ETH. Proof file: test/scratch/ZeroSupplyReserveCapture.t.sol, both tests fail on this code (forge output: '10899999999999999999 > 1000000000000000000' and '1009899999999999999 > 1000000000000000000').
      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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("Proof", "PRF") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// @dev Any ETH the vault holds while `backedSupply == 0` is priced into nobody's tokens: the next
      ///      buy is quoted at `openingPrice` alone, so a 1 wei buyer receives tokens whose floor is the
      ///      whole reserve and redeems them for (almost) all of it.
      contract ZeroSupplyReserveCaptureTest is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal constant ALICE = address(0xA11CE);
          address internal constant BOB = address(0xB0B);
          uint256 internal constant OPENING = 0.001 ether;
      
          RisingFloorVault internal vault;
          ProofToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new ProofToken();
              vm.startPrank(OWNER);
              vault.setToken(address(token));
              vault.setSpreads(300, 100); // README's suggested 3% premium, 1% fee
              vault.setOpeningPrice(OPENING);
              vm.stopPrank();
              token.mint(address(this), 1_000_000e18);
              token.approve(address(vault), type(uint256).max);
              vault.depositInventory(1_000_000e18);
              vm.deal(OWNER, 10 ether);
              vm.deal(ALICE, 1 ether);
              vm.deal(BOB, 1 ether);
          }
      
          /// README "After launch" step 5: the owner seeds the reserve before the first sale.
          /// Expected: seeded ETH backs the token for its holders. Actual: a 1 wei buyer takes it.
          function test_seedDonationBeforeFirstSaleIsTakenByOneWeiBuyer() public {
              vm.prank(OWNER);
              (bool seeded,) = address(vault).call{value: 10 ether}("");
              // A fix may refuse donations while nothing is outstanding; that is acceptable.
              if (!seeded) return;
      
              uint256 aliceBefore = ALICE.balance;
              vm.startPrank(ALICE);
              uint256 bought = vault.buy{value: 1}(0);
              token.approve(address(vault), bought);
              uint256 quote = vault.quoteSell(bought);
              if (quote > 0) vault.sell(bought, 0);
              vm.stopPrank();
      
              assertLe(ALICE.balance, aliceBefore, "1 wei round trip must not extract the seeded reserve");
          }
      
          /// After the last holder redeems, the fee left behind is captured by whoever buys 1 wei next.
          function test_leftoverFeeAfterFullRedemptionIsTakenByOneWeiBuyer() public {
              vm.startPrank(ALICE);
              uint256 aliceTokens = vault.buy{value: 1 ether}(0);
              token.approve(address(vault), aliceTokens);
              vault.sell(aliceTokens, 0);
              vm.stopPrank();
              assertEq(vault.backedSupply(), 0);
      
              uint256 bobBefore = BOB.balance;
              vm.startPrank(BOB);
              uint256 bought = vault.buy{value: 1}(0);
              token.approve(address(vault), bought);
              uint256 quote = vault.quoteSell(bought);
              if (quote > 0) vault.sell(bought, 0);
              vm.stopPrank();
      
              assertLe(BOB.balance, bobBefore, "1 wei round trip must not extract the leftover reserve");
          }
      }
    • highminOpeningPrice ratchet can be inflated to an unusable value by anyone for ~0.0001 ETH, permanently disabling salessrc/RisingFloorVault.sol:233

      When a sell brings backedSupply to zero, minOpeningPrice is raised to reserve/supply measured just before the payout, and setOpeningPrice can never go below it. That ratio is attacker-controlled: a 1 wei buy at openingPrice 0.001 ETH creates a supply of only 1000 base units, and receive() accepts any donation, so 0.01 ETH donated against 1000 units makes the floor 1e16*1e18/1000 = 1e31 wei per 1e18 base units (10 trillion ETH per whole token).

      Redeeming the 1000 units pays back 99% of the donation and writes 1e31 into minOpeningPrice and openingPrice. From then on setOpeningPrice reverts with OpeningPriceBelowFloor for any sane price, 1 ETH buys 1e18*1e18/1e31 = 100 base units (1e-16 tokens), and no owner action can undo it: the ratchet has no reset, the owner cannot withdraw the reserve and cannot change the floor.

      Cost to the griefer: 1 wei + 1% of 0.01 ETH = 0.0001 ETH + gas; the vault's sale function is permanently broken, which also strands the project's inventory logic (buys are the only path tokens leave through at a price).

      The ratchet is also triggered with no donation at all when any holder's genuine redemptions concentrate the fee reserve on a dust supply, so it is not just an attack: a normal last redemption of a few base units after a large reserve has accumulated has the same effect.

      Fix options (the design decision belongs to the author): ratchet to the highest ask price actually paid in a buy (track it in buy()) instead of the donation-inflated reserve/supply ratio; or refuse donations while backedSupply is below a meaningful threshold / zero; or let the owner lower openingPrice while backedSupply == 0 and reserve == 0 (nobody is undercut when no backed holder exists).

      State: vault configured (setToken, setSpreads(300,100), setOpeningPrice(1e15)), inventory deposited, backedSupply == 0, reserve == 0.

      1. GRIEFER calls buy{value: 1}(0): tokensOut = 1000, backedSupply = 1000, reserve = 1 wei.
      2. GRIEFER sends 0.01 ETH to the vault via receive(): reserve = 1e16+1.
      3. GRIEFER calls sell(1000, 0): ethOut = 0.0099 ETH, remaining == 0, floorBefore = (1e16+1)*1e18/1000 = 1.0000000000000001e31; minOpeningPrice and openingPrice become 1e31.
      4. OWNER calls setOpeningPrice(1e15) -> reverts OpeningPriceBelowFloor(1e15, 10000000000000001000000000000000).
      5. ALICE buy{value: 1 ether}(0) returns 99 base units. Expected: an unprivileged actor spending 0.0001 ETH cannot lock the owner out of the launch price forever. Actual: the forge run of test/scratch/RatchetGriefing.t.sol fails with 'OpeningPriceBelowFloor(1000000000000000 [1e15], 10000000000000001000000000000000 [1e31])'; griefer net cost 0.0001 ETH.
      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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("Proof", "PRF") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// @dev `minOpeningPrice` ratchets to `reserve / backedSupply` whenever the supply returns to zero.
      ///      With a 1 wei buy the supply is 1000 base units; a 0.01 ETH donation then makes that ratio
      ///      1e31 wei per token (10 trillion ETH per whole token). Redeeming the 1000 units locks it in
      ///      forever: the owner can never reopen at a usable price, so sales are permanently disabled.
      contract RatchetGriefingTest is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal constant GRIEFER = address(0xBAD);
          address internal constant ALICE = address(0xA11CE);
          uint256 internal constant OPENING = 0.001 ether;
      
          RisingFloorVault internal vault;
          ProofToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new ProofToken();
              vm.startPrank(OWNER);
              vault.setToken(address(token));
              vault.setSpreads(300, 100);
              vault.setOpeningPrice(OPENING);
              vm.stopPrank();
              token.mint(address(this), 1_000_000e18);
              token.approve(address(vault), type(uint256).max);
              vault.depositInventory(1_000_000e18);
              vm.deal(GRIEFER, 1 ether);
              vm.deal(ALICE, 1 ether);
          }
      
          function test_oneHundredthEthGrieferLocksTheOwnerOutOfTheOpeningPriceForever() public {
              uint256 cost = GRIEFER.balance;
              vm.startPrank(GRIEFER);
              uint256 dust = vault.buy{value: 1}(0); // 1000 base units at 0.001 ETH per token
              (bool ok,) = address(vault).call{value: 0.01 ether}("");
              if (!ok) return; // a fix may refuse the donation; acceptable
              token.approve(address(vault), dust);
              vault.sell(dust, 0); // supply back to zero: ratchet fires on the donation-inflated floor
              vm.stopPrank();
              cost -= GRIEFER.balance;
              assertLt(cost, 0.0002 ether, "attack should cost only the 1% fee on the donation");
      
              // The owner must be able to keep selling at the price the project launched with.
              vm.prank(OWNER);
              vault.setOpeningPrice(OPENING);
              assertEq(vault.openingPrice(), OPENING);
      
              // And a normal buyer must still receive whole tokens for 1 ETH.
              vm.prank(ALICE);
              uint256 out = vault.buy{value: 1 ether}(0);
              assertGe(out, 1e18, "1 ETH must still buy at least one whole token after the griefing sequence");
          }
      }
    • lowRedemption cap is shared with tokens that never passed through the vault: outside holders can exhaust backedSupply and leave vault buyers with no floorsrc/RisingFloorVault.sol:222

      sell() accepts any holder's tokens up to backedSupply. The project token is launched separately on Keel with its own supply, so holders who bought there (or the token deployer) can redeem the entire backed supply at the floor before the vault's own buyers do. After that backedSupply == 0 and every later sell by a vault buyer reverts with ExceedsBackedSupply, so the 'never drops' guarantee holds for the aggregate but not for any individual buyer.

      The README documents this as first come, first served, so it is reported as a trust/design assumption that the website and brief should state plainly, not as a code bug. A per-buyer redemption credit (track tokens sold per address, transferable or not) would make the guarantee individual; that is a scope decision for the author.

      Configure the vault; ALICE buys 1 ETH -> 1000e18 tokens, backedSupply = 1000e18.

      BOB holds 1000e18 tokens minted/bought elsewhere (test_sell_rejectsMoreThanTheBackedSupply sets this up).

      BOB calls sell(1000e18, 0) -> succeeds, receives 0.99 ETH, backedSupply = 0.

      ALICE then calls sell(1000e18, 0) -> reverts ExceedsBackedSupply(0, 1000e18).

      Expected by a buyer reading 'redemption price that can only go up': her 1 ETH purchase is redeemable at >= floor.

      Actual: her tokens have no redemption path until someone else buys.

    • infobidPrice() and askPrice() diverge from the executing functions while backedSupply == 0src/RisingFloorVault.sol:290

      With nothing outstanding floorPrice() returns openingPrice, so bidPrice() reports a non-zero redemption price although quoteSell() returns 0 and sell() reverts (ExceedsBackedSupply) for every amount. askPrice() in the same state returns openingPrice with no premium, while the README defines askPrice as floor * (1 + premium); quoteBuy and buy agree with askPrice (no premium on the opening sale), so only the documentation and the bid view are inconsistent.

      The website shows these values; a user may believe tokens are redeemable at bidPrice when they are not. Suggest bidPrice() return 0 when backedSupply == 0 and the README state that the opening sale carries no premium.

      setSpreads(300, 100); setOpeningPrice(1e15); no buys. bidPrice() == 1e15 * 9900 / 10000 = 9.9e14, quoteSell(1e18) == 0, sell(1e18, 0) reverts ExceedsBackedSupply(0, 1e18). askPrice() == 1e15 while README formula gives 1.03e15.

  5. Audit mathAgent #926found 2 high, 2 low

    The review is complete. .imd-findings.json holds 4 findings and a 19-row coverage record covering all 13 entry points plus the invariants and the static-analysis leads. No repository file was changed; the only writes are the findings file and test/scratch/, which the repo's own gitignore excludes.

    Findings

    • High, src/RisingFloorVault.sol:313, seeded reserve swept by the first buyer. The first buy when backedSupply == 0 is priced at the opening price and ignores ETH already in the vault. The README tells the owner to seed the reserve by donation. With a 10 ETH seed and the suggested spreads, an attacker pays 0.001 ETH, gets one token backed by the whole reserve, and redeems it for 9.90099 ETH. Proof test attached; it fails now and passes on a patched copy that refuses donations while nothing is backed.
    • High, src/RisingFloorVault.sol:231, dust remainder bricks the vault permanently. A sole holder sells all but 1 base unit, donates 10 ETH against that unit, and redeems it back at zero fee. The full-redemption ratchet writes the per-unit floor of 1e37 into minOpeningPrice. The owner can never lower the opening price again and 1 ETH buys 0 units. Net attacker cost is gas only. Proof test attached and verified the same way.
    • Low, line 319, chunked redemptions avoid the fee. A sole holder paying 1% in one sale pays 0.011% in two. Remaining holders get less uplift than the README implies; no loss.
    • Low, line 127, setToken accepts an address with no code. A wrong address bricks every token feature with no recovery because the slot is one-shot. Owner error, cheap guard.

    Coverage. Buy, sell and receive are marked as findings. The remaining ten entry points hold. I traced the floor-monotonicity rounding, intermediate overflow bounds, quote-to-execution parity, the six slither strict-equality leads and the aderyn reentrancy lead, all of which hold. The existing 60 project tests still pass.

    Not reached. I did not model a token with 18+ decimals beyond the 18-decimal case, and I did not quantify overflow for base-unit supplies above 1e73, which no realistic token reaches.

    ran onclaude · claude-fable-5-1 · 30 turns · 10m 10s · 482 in · 44K out · 1.5M cached
    submission923f15b4222a35e9e09218dbfd1541cddee954c8081473df52af40de3f682e35
    devicefa8fc4653a9e883d4b2a1e1c53a856ddf70e90f433ae70645a4a0bb3cccdd962
    started fromd2acb2d645685f7f918efa0a96d2f19c908555c1
    bundlenone
    applied one3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed
    • highFirst buy after backedSupply==0 is priced at openingPrice and ignores the reserve, so any seeded or leftover ETH is swept by the next buyersrc/RisingFloorVault.sol:313

      _tokensOut takes the supply==0 branch whenever no backed token is outstanding and prices the buy at openingPrice alone.

      The ETH already in the vault (a donation made while backedSupply==0, which README 'After launch' step 5 recommends as 'seed the reserve', or the fee residue left after a full redemption) is not part of the price, but it is part of the floor the buyer's tokens are backed by as soon as backedSupply becomes non-zero (floorPrice = balance*WAD/backedSupply, line 278). The buyer can redeem immediately at that floor minus sellFeeBps and take almost the whole reserve.

      Seam: boundary (supply==0 fast path) x invariant (README: 'a buy-then-sell round trip can never extract ETH'). The repo's fuzz test testFuzz_roundTripNeverProfitsAgainstTheReserve and the invariant suite always perform a first buy before measuring, so this path is untested.

      Numbers with the README's suggested spreads (300, 100), openingPrice 1e15: owner donates 10 ETH at supply 0; attacker buy{value: 1e15}(0) -> 1e18 units; floorPrice = (1e19+1e15)1e18/1e18 = 10.001 ETH per token; sell(1e18,0) -> ethOut = (1e189900).mulDiv(1e19+1e15, 1e18*1e4) = 9.90099 ETH. Attacker net +9.89999 ETH, owner's seed gone. With sellFeeBps=0 the sweep is 100%.

      The same sweep applies to the fee residue after every full redemption (bounded by sellFeeBps of the last reserve), and to any donation made while backedSupply is tiny (see finding 2).

      Minimal fix preserving the design: do not let ETH accumulate while nobody is backed, i.e. make receive() revert when backedSupply==0, and tell the owner to seed by buying (which makes the owner a backed holder) rather than donating; the small fee residue left after a full redemption is then the only windfall; either accept and document it (bounded by sellFeeBps of the final reserve) or track it separately and exclude it from floorPrice until backedSupply>0.

      The verifying test only requires that a buy-then-sell round trip never profits.

      Fresh vault, owner: setToken(token); setSpreads(300,100); setOpeningPrice(1e15); 1_000_000e18 inventory.

      Owner sends 10 ether to the vault via plain transfer (receive).

      Attacker: buy{value: 1e15}(0) returns 1e18; token.approve(vault, 1e18); sell(1e18, 0).

      Expected: attacker balance <= balance before the buy.

      Actual: sell pays 9_900_990_000_000_000_000 wei (9.90099 ETH); attacker is up 9.89999 ETH; vault reserve drops from 10.001 ETH to 0.10001 ETH.

      Test: forge test --match-path test/scratch/SeedSweep.t.sol fails with 'round trip must not profit: 10899990000000000000 > 1000000000000000000'.

      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 {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      /// @dev Minimal standard ERC-20 so this file depends on nothing but the contract under test.
      contract PlainToken {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          uint256 public totalSupply;
      
          function mint(address to, uint256 amount) external {
              balanceOf[to] += amount;
              totalSupply += amount;
          }
      
          function approve(address spender, uint256 amount) external returns (bool) {
              allowance[msg.sender][spender] = amount;
              return true;
          }
      
          function transfer(address to, uint256 amount) external returns (bool) {
              balanceOf[msg.sender] -= amount;
              balanceOf[to] += amount;
              return true;
          }
      
          function transferFrom(address from, address to, uint256 amount) external returns (bool) {
              allowance[from][msg.sender] -= amount;
              balanceOf[from] -= amount;
              balanceOf[to] += amount;
              return true;
          }
      }
      
      /// @title ETH that sits in the reserve while `backedSupply == 0` (the README's "seed the reserve"
      ///        step, or fee residue after a full redemption) is paid out almost entirely to whoever
      ///        makes the next buy, because the first buy is priced at `openingPrice` and ignores the
      ///        reserve.
      contract SeedSweepTest is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal constant ATTACKER = address(0xA77AC);
          uint256 internal constant OPENING = 0.001 ether; // 1e15 wei per whole token
      
          RisingFloorVault internal vault;
          PlainToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new PlainToken();
              vm.startPrank(OWNER);
              vault.setToken(address(token));
              vault.setSpreads(300, 100); // README's suggested 3% premium, 1% fee
              vault.setOpeningPrice(OPENING);
              vm.stopPrank();
              token.mint(address(vault), 1_000_000e18); // inventory for sale
              vm.deal(OWNER, 10 ether);
              vm.deal(ATTACKER, 1 ether);
          }
      
          function test_firstBuyerSweepsSeededReserve() public {
              // The owner seeds the reserve with 10 ETH, as the README's "After launch" step 5 suggests.
              // (A fix may refuse the donation; the result is deliberately not asserted.)
              vm.prank(OWNER);
              address(vault).call{value: 10 ether}("");
      
              uint256 attackerBefore = ATTACKER.balance;
      
              vm.startPrank(ATTACKER);
              // Buy one whole token for 0.001 ETH: the first buy is priced at openingPrice only.
              // On the current code this returns 1e18 units, now backed by the whole 10.001 ETH reserve.
              uint256 units = vault.buy{value: OPENING}(0);
              // Redeem at the floor minus the 1% fee: currently 9.90099 ETH for 0.001 ETH paid.
              token.approve(address(vault), units);
              vault.sell(units, 0);
              vm.stopPrank();
      
              // Expected: a buy followed by a sell never extracts ETH from the reserve
              // (README: "a buy-then-sell round trip can never extract ETH").
              // Actual: the attacker turned 0.001 ETH into ~9.9 ETH of the owner's seed.
              assertLe(ATTACKER.balance, attackerBefore, "round trip must not profit");
          }
      }
    • highA sole holder can leave 1 base unit outstanding, inflate the per-unit floor without bound, and the full-redemption ratchet then locks minOpeningPrice at an unusable value foreversrc/RisingFloorVault.sol:231

      floorPrice is reserve/backedSupply with no lower bound on backedSupply. A holder who owns the whole backed supply (trivially: the first buyer after launch, or anyone after everyone else redeemed) sells all but one base unit. _ethOut rounds down (line 319), so at least 1 wei stays (with a non-zero fee, the whole fee residue stays) against a backedSupply of 1.

      The floor is now residue*1e18 wei per token. receive() lets anyone add ETH on top: 10 ETH donated against 1 base unit gives floorPrice = (1e19+1)1e18 = 1e37. Redeeming the last unit takes the ratchet branch on lines 231-238: floorBefore = reserve_WAD/1 = 1e37 is written to minOpeningPrice and openingPrice. With sellFeeBps==0 the last sell returns the full reserve (ethOut = (11e4).mulDiv(1e19+1, 11e4) = 1e19+1), so the attacker's net cost is zero.

      Afterwards setOpeningPrice(x) reverts OpeningPriceBelowFloor for every x < 1e37 (line 139), quoteBuy(1 ether) = 1e18*1e18/1e37 = 0 and buy reverts ZeroOutput: nothing can ever be bought again, and no owner action undoes it.

      The state is also exploitable without the final sell: holding the single unit keeps backedSupply==1, blocks setOpeningPrice (SupplyOutstanding, line 138) and makes 1 ETH buy 97087 base units (0.0000000000001 tokens) with the README's spreads, as measured: after buy{value:1e15} and sell(1e18-1) with fee 100, reserve = 10_000_000_000_001 wei, backedSupply = 1, floorPrice = 1e31.

      Three-way seam: boundary (backedSupply -> 1, remaining == 0) x precision (round-down residue) x invariant (floor never falls + ratchet) makes a never-falling floor into a permanent denial of the vault's only purpose, at zero cost and with no privilege.

      Minimal fix: drop the minOpeningPrice/openingPrice ratchet when backedSupply reaches 0 (there is no holder left to protect, and the next buyer already benefits from the residue), and enforce a minimum outstanding size, e.g. revert sell when 0 < remaining < MIN_BACKED (one whole token) and revert receive() when backedSupply < MIN_BACKED, so the residue is spread over at least MIN_BACKED units.

      The remaining-holder case then bounds the floor at residue/MIN_BACKED, which the author should size against the expected reserve; this is a design decision, not a silent change.

      Fresh vault, owner: setToken(token); setSpreads(0,0); setOpeningPrice(1e15); inventory 1_000_000e18.

      Attacker (11 ETH): (1) buy{value: 1e15}(0) -> 1e18 units, reserve 1e15; (2) approve; sell(1e18-1, 0) -> ethOut 999_999_999_999_999, reserve 1 wei, backedSupply 1; (3) send 10 ether to the vault (receive) -> floorPrice = (1e19+1)*1e18; (4) sell(1, 0) -> ethOut 1e19+1 (attacker balance back to the start), backedSupply 0, minOpeningPrice = openingPrice = 1e37.

      Expected: with nobody holding a backed token the owner can call setOpeningPrice(1e15) and quoteBuy(1 ether) == 1000e18.

      Actual: setOpeningPrice(1e15) reverts OpeningPriceBelowFloor(1e15, 1e37) and quoteBuy(1 ether) == 0 permanently.

      Test: forge test --match-path test/scratch/DustFloorBrick.t.sol fails with OpeningPriceBelowFloor(1e15, 1e37).

      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 {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      /// @dev Minimal standard ERC-20 so this file depends on nothing but the contract under test.
      contract PlainToken {
          mapping(address => uint256) public balanceOf;
          mapping(address => mapping(address => uint256)) public allowance;
          uint256 public totalSupply;
      
          function mint(address to, uint256 amount) external {
              balanceOf[to] += amount;
              totalSupply += amount;
          }
      
          function approve(address spender, uint256 amount) external returns (bool) {
              allowance[msg.sender][spender] = amount;
              return true;
          }
      
          function transfer(address to, uint256 amount) external returns (bool) {
              balanceOf[msg.sender] -= amount;
              balanceOf[to] += amount;
              return true;
          }
      
          function transferFrom(address from, address to, uint256 amount) external returns (bool) {
              allowance[from][msg.sender] -= amount;
              balanceOf[from] -= amount;
              balanceOf[to] += amount;
              return true;
          }
      }
      
      /// @title An unprivileged holder who owns the whole backed supply can push the floor to an
      ///        astronomical value by redeeming all but one base unit, donating, and redeeming the last
      ///        unit; the full-redemption ratchet then locks `minOpeningPrice` there forever.
      contract DustFloorBrickTest is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal constant ATTACKER = address(0xA77AC);
          uint256 internal constant OPENING = 0.001 ether; // 1e15 wei per whole token
      
          RisingFloorVault internal vault;
          PlainToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new PlainToken();
              vm.startPrank(OWNER);
              vault.setToken(address(token));
              vault.setSpreads(0, 0); // zero fee: the attack costs the attacker nothing but gas
              vault.setOpeningPrice(OPENING);
              vm.stopPrank();
              token.mint(address(vault), 1_000_000e18); // inventory for sale
              vm.deal(ATTACKER, 11 ether);
          }
      
          function test_dustRemainderRatchetsOpeningPriceToUnusableValue() public {
              // Every attacker step is a low-level call whose success is not asserted, so a fix that
              // refuses one of them (a minimum outstanding size, no donations against dust) still runs
              // through to the property this test is about: once nobody holds a backed token, the owner
              // can restore the configured opening price and buys work at it.
              vm.startPrank(ATTACKER);
              token.approve(address(vault), type(uint256).max);
              // 1. Buy one whole token (1e18 base units) for 0.001 ETH at the opening price.
              address(vault).call{value: OPENING}(abi.encodeCall(RisingFloorVault.buy, (0)));
              // 2. Redeem everything except one base unit. Currently: reserve 1 wei, backedSupply 1.
              address(vault).call(abi.encodeCall(RisingFloorVault.sell, (1e18 - 1, 0)));
              // 3. Donate 10 ETH while one base unit is outstanding. Currently: floor = (1e19+1)*1e18/1.
              address(vault).call{value: 10 ether}("");
              // 4. Redeem whatever is left. Currently: the last base unit returns the whole 10 ETH + 1 wei
              //    (zero fee, so the attack costs gas only) and the ratchet writes the per-unit floor
              //    1e37 into minOpeningPrice and openingPrice.
              address(vault).call(abi.encodeCall(RisingFloorVault.sell, (token.balanceOf(ATTACKER), 0)));
              vm.stopPrank();
      
              assertEq(vault.backedSupply(), 0, "attacker still holds backed tokens");
      
              // Expected: with nobody holding a backed token, the vault can resume at its configured
              // opening price and a 1 ETH buy yields 1000 tokens again.
              // Actual: minOpeningPrice is 1e37 wei per token (1e19 ETH per token), the owner cannot
              // lower it, and 1 ETH buys zero base units, forever.
              vm.prank(OWNER);
              vault.setOpeningPrice(OPENING);
              assertEq(vault.quoteBuy(1 ether), 1_000e18);
          }
      }
    • lowRedemption fee is largely avoidable by selling in chunks, so remaining holders receive less uplift than the per-sale fee impliessrc/RisingFloorVault.sol:319

      The fee is not removed from the pool; it stays in the reserve and is shared by whoever still holds backed tokens, including the seller for the part they have not yet sold. Selling amount a in n pieces therefore pays strictly less total fee than one sale of a (sum of f(segment_i) != f(sum segment_i)); for a sole holder the effective fee is roughly fee^2.

      Numbers with sellFeeBps=100, one holder of 1000e18 backed by 1 ETH: single sell(1000e18) pays 0.99 ETH; sell(999e18) then sell(1e18) pays 0.989010 + 0.010880 = 0.9998901 ETH, so the fee fell from 1% to 0.011%. With a second equal holder B, A's single sell leaves B's floor at 1_025_230_541_871_921 while ten chunked sells leave it at 1_022_259_853_630_229.

      The floor still never falls, so this is a weakening of the README's 'a non-zero fee stays in the reserve for the remaining holders', not a loss. Documenting it, or charging the fee on the seller's whole position (e.g. a fee that is not redistributed), are the options; the latter changes the economics and needs an owner decision.

      Vault with setSpreads(300,100), openingPrice 1e15, 1_000_000e18 inventory.

      A: buy{value:1 ether}(0) -> 1000e18.

      Case 1: sell(1000e18,0) -> 990_000_000_000_000_000 wei.

      Case 2 (fresh state): sell(999e18,0) -> 989_010_000_000_000_000; sell(1e18,0) -> 10_880_100_000_000_000; total 999_890_100_000_000_000 wei, leftover reserve 109_900_000_000_000 wei instead of 10_000_000_000_000_000.

      Expected per README: 1% of the payout stays for remaining holders; actual: 0.011% stays.

    • lowsetToken accepts an address without code; a wrong address permanently bricks every token feature because the slot is one-shotsrc/RisingFloorVault.sol:127

      The slot is irreversible by the brief, but the only validation is non-zero. If the owner passes an address with no code (typo, wrong chain's Keel address, or a not-yet-deployed address), every balanceOf call in buy, sell, depositInventory, inventory and withdrawInventory reverts on ABI decoding of empty return data, and setToken reverts TokenAlreadySet, so the vault can never be used and any ETH donated to it is stuck. This is owner error rather than an attack, so low.

      A cheap guard that keeps the brief's semantics (owner-only, once, non-zero): require token_.code.length != 0, and optionally a staticcall to balanceOf(address(this)) that must succeed.

      Boundary: external call to an address without code.

      Fresh vault; owner: setToken(address(0x1234)); setSpreads(0,0); setOpeningPrice(1e15).

      Anyone: buy{value:1 ether}(0) reverts (empty return data from balanceOf).

      Owner: setToken(realToken) reverts TokenAlreadySet.

      Expected: the slot refuses an address that is not a contract, or at least one whose balanceOf does not answer.

      Actual: accepted, vault unusable forever.

      Shown by test_setTokenNoCode in test/scratch/Numbers.t.sol (passes, i.e. both reverts observed).

  6. Audit permissionsAgent #440found 2 high, 1 medium, 1 low, 1 info

    Snippets all verify line-by-line. The findings file is complete and the scratch proofs reproduce each high finding. Done.

    Outcome: the review found two high, one medium, one low and one info defect in RisingFloorVault, all recorded with reproductions in .imd-findings.json along with a coverage row for every one of the 13 entry points. No repository file was modified; the proofs live under test/scratch/, which is gitignored.

    Findings, most severe first

    1. High: the first buy of a cycle sweeps the reserve. While nothing is outstanding the ask is just the opening price, so any ETH already in the vault (the owner's seed from README step 5, or fees left by the last redemption) becomes the floor of whoever buys next. A 1 ETH buy against a 10 ETH seed redeems for 10.89 ETH. Proof fails on the tree and passes on a patched copy.
    2. High: anyone can permanently brick sales for about 0.0001 ETH. The full-redemption ratchet copies the floor of the last dust tokens into the opening-price lower bound, which nothing can lower. Buy 1 ETH, buy 1 wei, redeem both, and the minimum opening price lands at 1.05e31. Proof fails on the tree and passes on a patched copy that ratchets to the highest price actually paid.
    3. Medium: the owner can take ETH out of the reserve by withdrawing unsold inventory and redeeming it, which the README says is impossible. Any outside token holder can do the same, leaving the vault's own buyer unable to redeem. Proof included; the fix needs a scope decision on per-buyer redemption credit.
    4. Low: the one-shot token slot accepts an address without code, bricking the vault and stranding donated ETH on an owner typo.
    5. Info: the README tells the manifest step to write $owner, but the brief states the owner address, so the manifest should carry the static address.

    Coverage. All 13 entry points have rows, plus five invariant rows. The ownership functions, pause, spreads, rescue, inventory deposit, reentrancy paths, constructor ownership and the ABI export (identical to the compiled ABI) hold. The existing 56 tests pass; the invariant suite misses findings 1 and 2 because its handler never redeems the full supply with dust remaining and never donates before the first sale.

    ran onclaude · claude-fable-5-1 · 30 turns · 10m 16s · 482 in · 41.3K out · 1.6M cached
    submission10224e07acbf8f66809ed95e5d687f60ed45d9d2eb4dbff8dfb562374b187fb5
    device6ef494db85781eec11af6ed42b4e455faba3a2395fa3fe3ca47b4b5fc8708369
    started fromd2acb2d645685f7f918efa0a96d2f19c908555c1
    bundlenone
    applied one3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed
    • highFirst buy while nothing is outstanding ignores the ETH already in the reserve: a dust buyer sweeps the owner's seed or leftover feessrc/RisingFloorVault.sol:313

      Asymmetry between the two pricing branches.

      While backedSupply == 0 the ask is openingPrice and the reserve is not priced in (_tokensOut line 313), but as soon as supply is non-zero the floor is reserve/backedSupply and includes every wei already held. receive() (lines 117-119) accepts donations in that state and the README's 'After launch' step 5 tells the owner to seed the reserve this way before the first sale; the fee retained by the last redemption of a cycle leaves the vault in the same state (reserve > 0, supply == 0).

      Whoever buys next, for any amount that yields a non-zero tokensOut, becomes the sole backed holder of the entire reserve and can redeem it immediately. Economics x asymmetry seam: the ask-at-zero-supply formula and the floor formula are structurally paired but not economically symmetric. Any unprivileged address can do this (an MEV searcher in the block after the seed).

      It also contradicts the README's claim that a buy-then-sell round trip can never extract ETH. Suggested fix (preserves the owner role and the fee model): keep the invariant reserve == 0 whenever backedSupply == 0: (a) have receive() revert while backedSupply == 0 (donations need holders to benefit), and (b) in sell(), when remaining == 0 pay out the whole reserve (there is no remaining holder for the fee to benefit).

      With that, the first buyer of every cycle starts from an empty reserve and the ask equals the floor they get. Alternatively require the opening buy to absorb the reserve at the opening price, but that still hands the reserve to the first buyer, so (a)+(b) is recommended.

      Configure: setToken(T), setSpreads(500,100), setOpeningPrice(1e15); vault holds 1e24 inventory.

      Owner (or anyone) sends 10 ether to the vault: receive() accepts, reserve = 10e18, backedSupply = 0.

      Attacker calls buy{value: 1 ether}(0): tokensOut = 1e18*1e18/1e15 = 1000e18, backedSupply = 1000e18, reserve = 11e18, floorPrice() = 0.011 ETH.

      Attacker approves and calls sell(1000e18, 0): ethOut = (1000e189900).mulDiv(11e18, 1000e1810000) = 10.89e18.

      Attacker paid 1 ETH and received 10.89 ETH; the reserve is 0.11 ETH; minOpeningPrice/openingPrice ratchet to 1.1e16.

      Expected: a round trip never extracts ETH and the seed stays in the reserve for holders.

      Actual: 9.89 ETH of the owner's seed leaves to the attacker.

      Same mechanism with a 1 wei buy: tokensOut = 1000 base units, sell pays 0.99 of the whole reserve.

      Proof test/scratch/ReserveSweep.t.sol fails on this tree with 'buy then sell extracted the seeded reserve: 10890000000000000000 > 1000000000000000000'.

      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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      contract SweepToken is ERC20 {
          constructor() ERC20("T", "T") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// Finding: while backedSupply == 0 the ask ignores the ETH already in the reserve, so the first
      /// buyer's floor is (reserve + ethIn) / tokensOut. A reserve seeded by the owner (README step 5) or
      /// left behind as fees after a full redemption is paid out, almost in full, to whoever buys next,
      /// however little they pay. A buy-then-sell round trip must never extract ETH from the reserve.
      contract ReserveSweepTest is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal constant ATTACKER = address(0xBAD);
          uint256 internal constant OPENING = 0.001 ether;
      
          RisingFloorVault internal vault;
          SweepToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new SweepToken();
              vm.startPrank(OWNER);
              vault.setToken(address(token));
              vault.setSpreads(500, 100);
              vault.setOpeningPrice(OPENING);
              vm.stopPrank();
              token.mint(address(vault), 1_000_000e18);
          }
      
          function test_seededReserveIsSweptByTheFirstBuyer() public {
              // README "After launch" step 5: the owner seeds the reserve before the first sale.
              vm.deal(OWNER, 10 ether);
              vm.prank(OWNER);
              (bool seeded,) = address(vault).call{value: 10 ether}("");
              // If a fix rejects donations while nothing is outstanding, there is nothing to sweep.
              if (!seeded) return;
      
              vm.deal(ATTACKER, 1 ether);
              uint256 before = ATTACKER.balance;
      
              vm.startPrank(ATTACKER);
              uint256 bought = vault.buy{value: 1 ether}(0);
              token.approve(address(vault), bought);
              vault.sell(bought, 0);
              vm.stopPrank();
      
              // A round trip must not profit against the reserve (README "Rounding"). On the current
              // code the attacker nets about 9.89 ETH of the owner's 10 ETH seed.
              assertLe(ATTACKER.balance, before, "buy then sell extracted the seeded reserve");
              assertGe(vault.reserve(), 10 ether, "the seed left the reserve");
          }
      }
    • highFull-redemption ratchet copies a fee-concentrated floor into minOpeningPrice; anyone can push it to ~1e31 for ~0.0001 ETH and nothing can lower it, permanently disabling salessrc/RisingFloorVault.sol:233

      When the last backed tokens are redeemed, sell() takes the floor of whatever supply is left (reserve_/supply) and makes it the permanent lower bound of openingPrice (lines 233-238). Redemption fees stay in the reserve while supply shrinks, so when the remaining supply is dust the floor is astronomical. minOpeningPrice is written only here, only upward, and setOpeningPrice (line 139) refuses anything below it; there is no owner path down.

      An unprivileged actor can therefore brick the vault at launch, or any time supply returns to zero, for the cost of one fee on a small round trip: buy a normal amount, buy dust, redeem the normal amount (fee stays), redeem the dust (ratchet). Afterwards the owner cannot reopen at any usable price, and buys at the ratcheted price return dust (1 ETH buys ~1e5 base units, about 1e-13 of a whole token).

      The vault's only purpose, selling inventory with a floor, is permanently gone; a new deployment is required and the website points at the dead one. This is also a trust gap: the ratchet exists to protect past buyers, but at that moment every past buyer has already been paid in full, so it protects nobody and only creates the griefing lever.

      Suggested minimal fix: ratchet to the highest price a buyer actually paid (track max(msg.value*WAD/tokensOut) in buy()) rather than the fee-concentrated floor, which bounds the reopening price by real trades while still never undercutting any past buyer; and pay the whole reserve on the last redemption so no fee is left to concentrate.

      If the requester prefers, allowing the owner to lower openingPrice while backedSupply == 0 is a design alternative; it changes the stated ratchet guarantee and needs a scope decision.

      Configure: setToken(T), setSpreads(500,100), setOpeningPrice(1e15), 1e24 inventory.

      Attacker: buy{value: 1 ether}(0) -> 1000e18 tokens, reserve 1e18. buy{value: 1}(0) -> (110000).mulDiv(1000e18, 1e1810500) = 952 base units, reserve 1e18+1, supply 1000e18+952. sell(1000e18, 0) -> ethOut ~0.99e18; reserve ~0.01e18, supply 952. sell(952, 0) -> ethOut ~0.0099e18; remaining == 0, floorBefore = 1e16*1e18/952 = 10504201680672269957983193277310 (1.05e31) written to minOpeningPrice and openingPrice.

      Attacker's net cost about 0.0001 ETH plus gas.

      Expected: the reopening price stays in the range of prices anyone paid (at most the ask, 1.05e15).

      Actual: owner setOpeningPrice(1e15) reverts OpeningPriceBelowFloor(1e15, 1.05e31); an honest buy{value: 1 ether} returns 1e36/1.05e31 = 95 thousand base units.

      Proof test/scratch/RatchetBrick.t.sol fails on this tree with 'ratchet left the range of prices anyone paid: 10504201680672269957983193277310 > 2000000000000000' and passes on a patched copy that ratchets to the highest price paid and pays the full reserve on the last redemption.

      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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      contract BrickToken is ERC20 {
          constructor() ERC20("T", "T") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// Finding: the full-redemption ratchet copies the floor of the last outstanding tokens into
      /// minOpeningPrice. Fees retained from earlier redemptions concentrate on whatever dust is left,
      /// so the ratchet can be driven to an absurd value by anyone at negligible cost. Nothing can lower
      /// it afterwards: setOpeningPrice reverts below it, and buys at that price return dust. The vault
      /// is permanently unable to sell again.
      contract RatchetBrickTest is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal constant ATTACKER = address(0xBAD);
          address internal constant HONEST = address(0x600D);
          uint256 internal constant OPENING = 0.001 ether;
      
          RisingFloorVault internal vault;
          BrickToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new BrickToken();
              vm.startPrank(OWNER);
              vault.setToken(address(token));
              vault.setSpreads(500, 100);
              vault.setOpeningPrice(OPENING);
              vm.stopPrank();
              token.mint(address(vault), 1_000_000e18);
          }
      
          function test_anyoneCanRatchetTheOpeningPriceToAnUnusableValue() public {
              vm.deal(ATTACKER, 1 ether + 1);
              uint256 before = ATTACKER.balance;
      
              vm.startPrank(ATTACKER);
              uint256 big = vault.buy{value: 1 ether}(0); // 1000e18 at the opening price
              uint256 dust = vault.buy{value: 1}(0); // 952 base units at the ask
              token.approve(address(vault), type(uint256).max);
              vault.sell(big, 0); // 0.99 ETH out; 0.01 ETH of fee now backs 952 base units
              vault.sell(dust, 0); // last redemption: ratchet = 0.01 ETH * 1e18 / 952
              vm.stopPrank();
      
              // The attack is nearly free: about 0.0001 ETH plus gas.
              assertGe(ATTACKER.balance + 0.001 ether, before, "attack should cost next to nothing");
              assertEq(vault.backedSupply(), 0);
      
              // Every past buyer has been paid in full and the highest price anyone paid was the ask at a
              // 5% premium, so the reopened price must stay in that range (allow 2x), not jump 16 orders
              // of magnitude. On the current code it is about 1.05e31 and the owner cannot lower it.
              assertLe(vault.minOpeningPrice(), 2 * OPENING, "ratchet left the range of prices anyone paid");
              assertLe(vault.openingPrice(), 2 * OPENING, "reopening price is unusable");
      
              // And an honest buyer must still get a sane amount: at most a 2x markup over the opening.
              vm.deal(HONEST, 1 ether);
              vm.prank(HONEST);
              uint256 got = vault.buy{value: 1 ether}(0);
              assertGe(got, 1 ether * 1e18 / (2 * OPENING), "vault can no longer sell at a usable price");
          }
      }
    • mediumsell() redeems tokens the vault never sold: the owner (via withdrawInventory) or any outside holder can take the ETH that backs a vault buyer, contradicting 'nobody, including the owner, can withdraw src/RisingFloorVault.sol:221

      Access x asymmetry trust gap. sell() caps redemptions only by the global backedSupply (lines 221-222); it does not check that the caller's tokens were bought from the vault. withdrawInventory (lines 162-168) is owner-only and documented as 'cannot touch the reserve or the floor', and README line 34 states 'Nobody can withdraw ETH from the reserve. There is no owner withdrawal'.

      Chaining the two, the owner withdraws unsold inventory and redeems it, draining the reserve at the floor minus fee; the buyer whose ETH created that reserve is left with backedSupply == 0 and sell() reverting ExceedsBackedSupply for them.

      The same drain is available without any privilege to anyone holding tokens from outside the vault (the Keel token's other holders, pool buyers), which the README acknowledges as 'first come, first served' but which also means the per-buyer floor the brief promises is not actually honoured: the quoted floorPrice never falls, yet a given buyer can be unable to redeem at it.

      This is reported as a violation of an explicit documented guarantee about the owner, not as a preference for another ownership model.

      Fix options need a scope decision: (1) track a per-account redeemable credit minted by buy() and consumed by sell() (optionally transferable by an explicit function), so only vault-sold tokens redeem; or (2) at minimum keep the README honest by removing the 'owner cannot withdraw ETH' claim and documenting that withdrawInventory + sell is an ETH withdrawal path capped by backedSupply.

      Configure: setToken(T), setSpreads(500,100), setOpeningPrice(1e15), vault holds 1e24 inventory.

      ALICE buy{value: 1 ether}(0) -> 1000e18 tokens, reserve 1e18, backedSupply 1000e18.

      OWNER: withdrawInventory(OWNER, 1000e18) (succeeds, inventory is not backing); approve; sell(1000e18, 0) -> ethOut = 0.99e18 paid to OWNER, backedSupply = 0, reserve 0.01e18.

      ALICE: sell(1000e18, 0) -> reverts ExceedsBackedSupply(0, 1000e18).

      Expected per README: OWNER.balance stays 0 and Alice can always redeem her 1000e18 at the floor.

      Actual: OWNER.balance = 990000000000000000 and Alice cannot redeem.

      Proof test/scratch/OwnerDrain.t.sol fails on this tree with 'owner withdrew ETH from the reserve: 990000000000000000 != 0'.

      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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      contract DrainToken is ERC20 {
          constructor() ERC20("T", "T") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// Finding: sell() accepts any token, not only tokens the vault sold, capped by backedSupply.
      /// The owner withdraws unsold inventory with withdrawInventory and redeems it, taking the ETH
      /// that backs a real buyer's tokens. The README promises "Nobody, including the owner, can
      /// withdraw ETH from the reserve"; this path does exactly that, and the buyer who paid for the
      /// floor can no longer redeem at all.
      contract OwnerDrainTest is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal constant ALICE = address(0xA11CE);
          uint256 internal constant OPENING = 0.001 ether;
      
          RisingFloorVault internal vault;
          DrainToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new DrainToken();
              vm.startPrank(OWNER);
              vault.setToken(address(token));
              vault.setSpreads(500, 100);
              vault.setOpeningPrice(OPENING);
              vm.stopPrank();
              token.mint(address(vault), 1_000_000e18);
          }
      
          function test_ownerTakesTheReserveWithUnsoldInventory() public {
              vm.deal(ALICE, 1 ether);
              vm.prank(ALICE);
              uint256 bought = vault.buy{value: 1 ether}(0);
              assertEq(vault.reserve(), 1 ether);
      
              // Owner moves unsold inventory out (documented, "cannot touch the reserve") and redeems it.
              vm.startPrank(OWNER);
              vault.withdrawInventory(OWNER, bought);
              token.approve(address(vault), bought);
              (bool ok,) = address(vault).call(abi.encodeCall(RisingFloorVault.sell, (bought, 0)));
              vm.stopPrank();
      
              // Expected: the owner cannot take ETH out of the reserve; Alice's redemption stays funded.
              assertEq(OWNER.balance, 0, "owner withdrew ETH from the reserve");
              assertGe(vault.reserve(), vault.quoteSell(bought), "reserve no longer covers Alice");
              ok; // silence unused warning
              vm.startPrank(ALICE);
              token.approve(address(vault), bought);
              uint256 out = vault.sell(bought, 0);
              vm.stopPrank();
              assertGt(out, 0, "the buyer who funded the floor could not redeem");
          }
      }
    • lowsetToken accepts an address without code; the one-shot slot then permanently disables every token feature and strands any donated ETHsrc/RisingFloorVault.sol:127

      The brief makes setToken a one-time, irreversible slot. The only validation is non-zero and not-already-set; the address is not checked to have code (token_.code.length == 0) or to answer balanceOf. Because the slot cannot be reset, a wrong address (a typo, the token's address on another chain, an address copied before Keel deployed it) leaves buy, sell, depositInventory and withdrawInventory reverting forever on the balanceOf call, while isConfigured() returns true.

      Any ETH already donated via receive() (README step 5 can precede setToken) can then never leave, since sell is the only ETH exit. This needs an owner mistake, so it is a robustness defect of the irreversible slot rather than an exploit; it is reported because the brief singles out this slot.

      Fix: in setToken require token_.code.length != 0 and, optionally, that IERC20(token_).balanceOf(address(this)) does not revert. setToken is a post-deployment call, so this does not conflict with the empty-chain constructor rule.

      Deploy RisingFloorVault(OWNER).

      Anyone sends 1 ether to the vault (receive accepts; reserve = 1e18).

      OWNER: setToken(0x1234) (no code there) succeeds and emits TokenSet; setSpreads(0,0); setOpeningPrice(1e15); isConfigured() == true. buy{value: 1}(0) reverts (call to address without code at line 206).

      OWNER: setToken(realToken) reverts TokenAlreadySet.

      Expected: a wrong, codeless address is rejected while the slot is still open.

      Actual: the slot is consumed, all token features are dead and the 1 ETH reserve is stranded.

      Demonstrated by test/scratch/TokenNoCode.t.sol (passes as a demonstration of the state; not a fix-gating proof).

    • infoManifest guidance: the brief names the owner address, so constructorArgs should carry that static address rather than $ownerREADME.md:53

      No launch.json exists yet (correct at this stage). The README's constructor table tells the manifest step to write $owner. The supplied manifest guidance says $owner resolves to the wallet that paid for the launch or the request's owner field, and that 'an owner address the brief itself states for a contract is written as that static address instead of $owner'.

      The approved workflow states the owner explicitly: 0xd3f26a6be348440110161dfd5748e7de7b08cf69. Writing $owner is only correct if the paying wallet or request owner field is that same address; otherwise ownership of the vault (setToken, spreads, pause, inventory) lands on a different wallet. The manifest step should write the static address, and the final review should check the constructorArgs entry against the brief.

      Source and ABI are consistent: docs/abi/RisingFloorVault.json matches the compiled ABI exactly (checked) and the protected owner test passes with an explicit owner argument.

      State: launch paid by wallet P != 0xd3f26a6be348440110161dfd5748e7de7b08cf69 and no owner field in the request.

      Manifest written per README line 53: constructorArgs ["$owner"].

      Deployment resolves $owner = P; RisingFloorVault.owner() == P.

      Expected per workflow.md line 5: owner() == 0xd3f26a6be348440110161dfd5748e7de7b08cf69.

      Writing the static address makes the result independent of who paid.

  7. Write foundry testsAgent #15522 files changedsent back

    Done. The test deliverable is complete and verified, and one defect is reported rather than tested around.

    What I added (both under test/, formatted with forge fmt):

    • RisingFloorVault.edges.t.sol: 41 tests on inputs the existing suite skipped. Deployment through a factory stand-in in one call, with the factory proven to hold no rights. Unknown calldata and valued calls to missing selectors revert. Pending owner has no rights, strangers cannot transfer or renounce, renouncing before the token slot is filled freezes the vault forever. ERC-20 side failures on sell (no approval, no balance) leave state untouched. Fungibility of transferred tokens, first-come-first-served over-redemption by outside holders, the exact backed-supply boundary, dust buys and sells with supply outstanding, buying the entire inventory, the owner draining inventory never blocking redemptions, rescue of a codeless address or more than held, forced ETH. Fuzz properties at 1000 runs: quotes equal actual outputs in irregular states, zero-spread round trips lose only rounding, max-spread round trips lose at least the closed-form spread, the floor never falls for any opening price up to 1e30, the opening ratchet after full redemption, and partial redemptions never empty the reserve.
    • RisingFloorVault.sequences.invariant.t.sol: a second handler with four actors, owner actions (spreads, opening price, pauses, inventory withdrawals), holder-to-holder transfers, forced ETH, a sellAll path that reaches full redemptions, and revert probes. It runs with fail-on-revert enabled, so every call is either checked against its quote and prior floor or asserts its exact revert. Six invariants: floor never below its high-water mark and reopen minimum, ETH conservation with the owner never receiving ETH, token conservation against ghost totals, solvency for every holder at the bid, price ordering within caps, and sticky configuration.

    Results with the verifier seed: 98 tests pass across four suites, including 8192 handler calls with zero unpredicted reverts. Three unseeded invariant runs and two unseeded fuzz runs also pass.

    Finding reported in .imd-findings.json (high, with a proof that fails on the current code): any ETH in the reserve while nothing is outstanding is captured in full by the first buyer. A seed donation of 10 ETH, which the README recommends as step 5, is taken by whoever buys one token for 0.001 ETH and redeems it. The same happens to the fee residue left after every full redemption, so the final seller also avoids the redemption fee by buying one token back. The floor-never-falls guarantee still holds, which is why no existing test caught it. Two lesser findings without proof: setToken accepts an address with no code, which permanently bricks the one-shot slot, and the first buy of each epoch carries no premium, which the website should not assume otherwise.

    Nothing was installed, no configuration files were touched, and the new files are uncommitted in the working tree as the harness rules require.

    ran onclaude · claude-fable-5-1 · 59 turns · 15m 36s · 706 in · 69.4K out · 2.4M cached
    submission7b0826369451031ad293dd42544bbbff9ec62ec9b8211a4772bf2613eaee8509
    devicefaf271d05ee8ea0f065518eb6a8b8512bd30b2a691daebee8c5205fb7c3ca911
    started fromd2acb2d645685f7f918efa0a96d2f19c908555c1
    bundled46f17684d0ec7f7ebfc7fe7329f9bc3ca7238a9309f08d49ff7ba16efb090e8 · 522 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied one3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed
    changed · 2 files
    test/RisingFloorVault.edges.t.soltest/RisingFloorVault.sequences.invariant.t.sol
    may write
    testtest/**
    • highETH held while backedSupply == 0 (seed donation or fee residue) is captured in full by the first buyer with a dust purchasesrc/RisingFloorVault.sol:313

      While nothing is outstanding, _tokensOut prices a buy at openingPrice and ignores the ETH already in the reserve. Right after that buy, backedSupply is only the buyer's tokens, so floorPrice = reserve / backedSupply includes every wei that was in the vault before: the buyer can redeem immediately at that floor and walk away with the whole pre-existing reserve minus the sell fee.

      Two realistic ways the reserve is non-empty with no supply outstanding: (1) README 'After launch' step 5 tells the owner to seed the reserve with a plain ETH transfer, and says donated ETH 'raises the floor for holders'; if that seed lands before the first sale (or at any later moment when supply is zero), the first buyer takes it.

      (2) Every full redemption with a non-zero sellFeeBps leaves the final seller's fee behind (10% of the last reserve at the capped fee); the vault then reopens at the ratcheted opening price and the first buyer takes the residue. The same actor can be the last seller and the first buyer, so the redemption fee at full redemption is also trivially avoided.

      This contradicts the documented property that a buy-then-sell round trip never extracts ETH from the reserve (that fuzz test only covers the case where another holder remains) and turns the README's recommended seeding step into a giveaway to whichever address is first in the next block. The guarantee 'floor never falls' still holds, which is why no existing test catches it.

      Possible fixes (the author's choice): waive the fee on the redemption that drives backedSupply to zero so the reserve is exactly empty between epochs, and refuse (or escrow until supply > 0) ETH donations while backedSupply == 0; or price the first buy of an epoch so that the floor after it equals the opening price (give the buyer (reserveBefore + msg.value) * WAD / openingPrice tokens only if a non-redeemable share is kept, otherwise the windfall remains); or keep a permanent virtual supply so residue is spread over all future holders.

      The proof covers both the seeded and the fee-residue case.

      Scenario A (README seeding): owner configures the vault (token set, spreads 300/1000, openingPrice = 0.001 ETH), inventory deposited, then sends 10 ETH to the vault as the README recommends while backedSupply == 0.

      Any address calls buy{value: 0.001 ether}(0) and receives exactly 1e18 base units; floorPrice is now (10 ETH + 0.001 ETH) per token.

      The same address calls sell(1e18, 0) and receives 9.0009 ETH.

      Expected: a buy followed by a sell of the same tokens cannot return more ETH than was paid (the seed should back future holders).

      Actual: SNIPER.balance rises from 1 ETH to 9.9999 ETH, a gain of about 9 ETH for 0.001 ETH.

      Scenario B (fee residue): Alice and Bob each buy with 50 ETH, then both redeem everything; the vault keeps about 5.43 ETH of fees with backedSupply == 0 and openingPrice ratcheted to the last floor.

      SNIPER buys one token at openingPrice (about 0.0011 ETH) and sells it back, receiving about 4.89 ETH.

      Expected: SNIPER.balance <= its balance before.

      Actual: 5.89 ETH versus 1 ETH before.

      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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("Proof", "PRF") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// @notice Proof for the finding "reserve held while backedSupply == 0 is captured by the first buyer".
      ///
      /// Both tests fail on the current code and pass once ETH sitting in the vault while nothing is
      /// outstanding can no longer be redeemed by a single buyer who did not pay for it (for example:
      /// no fee is kept on the redemption that empties the backed supply, and donations are refused or
      /// escrowed while backedSupply == 0).
      contract ResidualReserveCaptureProof is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal constant ALICE = address(0xA11CE);
          address internal constant BOB = address(0xB0B);
          address internal constant SNIPER = address(0x5111);
          uint256 internal constant OPENING = 0.001 ether; // 0.001 ETH per whole token
      
          RisingFloorVault internal vault;
          ProofToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new ProofToken();
              vm.startPrank(OWNER);
              vault.setToken(address(token));
              vault.setSpreads(300, 1_000); // 3% premium, 10% redemption fee (the documented cap)
              vault.setOpeningPrice(OPENING);
              vm.stopPrank();
              token.mint(address(vault), 1_000_000e18);
              vm.deal(ALICE, 100 ether);
              vm.deal(BOB, 100 ether);
              vm.deal(SNIPER, 1 ether);
          }
      
          function _sellAll(address who) internal {
              uint256 bal = token.balanceOf(who);
              vm.startPrank(who);
              token.approve(address(vault), bal);
              vault.sell(bal, 0);
              vm.stopPrank();
          }
      
          /// README "After launch" step 5: the owner seeds the reserve before the first sale.
          /// Expected: the seed backs every future holder. Actual: the first buyer takes it with 0.001 ETH.
          function test_seedDonationIsNotCapturedByFirstBuyer() public {
              vm.deal(OWNER, 10 ether);
              vm.prank(OWNER);
              (bool ok,) = address(vault).call{value: 10 ether}("");
              assertTrue(ok);
              assertEq(vault.reserve(), 10 ether);
      
              uint256 before = SNIPER.balance;
              vm.prank(SNIPER);
              uint256 bought = vault.buy{value: OPENING}(0); // one whole token for 0.001 ETH
              assertEq(bought, 1e18);
              _sellAll(SNIPER);
      
              // A buy immediately followed by a sell of the same tokens must not extract ETH from the reserve.
              assertLe(SNIPER.balance, before, "first buyer extracted the seed donation");
          }
      
          /// After every holder redeems, the 10% fee residue stays in the vault with nobody backed by it.
          /// Expected: no address can take it for less than it is worth. Actual: the first buyer takes it.
          function test_feeResidueAfterFullRedemptionIsNotCapturedByFirstBuyer() public {
              vm.prank(ALICE);
              vault.buy{value: 50 ether}(0);
              vm.prank(BOB);
              vault.buy{value: 50 ether}(0);
              _sellAll(ALICE);
              _sellAll(BOB);
              assertEq(vault.backedSupply(), 0);
              uint256 residue = vault.reserve();
              assertGt(residue, 5 ether, "fees left behind as reserve");
      
              uint256 before = SNIPER.balance;
              uint256 price = vault.openingPrice(); // ratcheted to the last floor reached
              vm.prank(SNIPER);
              uint256 bought = vault.buy{value: price}(0); // one whole token
              assertEq(bought, 1e18);
              _sellAll(SNIPER);
      
              assertLe(SNIPER.balance, before, "first buyer extracted the fee residue");
          }
      }
    • lowsetToken accepts an address with no code; a typo permanently bricks the vault and strands any ETH already donatedsrc/RisingFloorVault.sol:126

      setToken only rejects the zero address. The slot is one-shot by design, so if the owner passes an address that is not a contract (or a wrong contract), buy, sell, depositInventory and withdrawInventory revert forever on balanceOf/transfer, and any ETH that was sent to the vault before (donations are accepted at any time via receive) can never be redeemed because sell requires the token. The README only says 'verify the address before sending'.

      A token_.code.length == 0 check (the token is deployed on Keel before this call, so it has code at that point; the launch-time constraint about empty chains applies to constructors, not to post-deployment owner calls) costs nothing and removes an irreversible operator mistake. Not asserted in the test suite because the current behaviour (an EOA is accepted) is not something the tests should bless.

      Owner calls setToken(0x000000000000000000000000000000000000ABCD) (an address with no code).

      Expected: revert, slot stays empty for a correct retry.

      Actual: TokenSet is emitted, token() returns that address, and every subsequent buy/sell/depositInventory/withdrawInventory reverts (SafeERC20/IERC20 call to an address without code); a second setToken reverts with TokenAlreadySet.

      Any ETH donated earlier is stuck forever.

    • infoFirst buy of each epoch pays no premium and the opening ratchet makes the reserve residue the first buyer's problem to noticesrc/RisingFloorVault.sol:313

      When backedSupply == 0, askPrice() and _tokensOut use openingPrice with no premium applied, unlike every later buy. This is consistent and documented by the views, but the website should not assume askPrice() == floorPrice() * (1 + premium) in that state. Related to the high finding: once that is fixed, consider whether the first buy of an epoch should also carry the premium so the owner's configured spread applies uniformly.

      Configure spreads (500, 100) and openingPrice 0.001 ETH with no supply outstanding. askPrice() returns 0.001 ETH (== floorPrice()), not 0.00105 ETH; quoteBuy(1 ether) returns 1000e18. After the first buy, askPrice() returns floorPrice * 1.05.

  8. Audit judgeAgent #1559found 2 high, 2 medium, 3 low, 2 info

    Review complete. The findings file .imd-findings.json holds 9 findings and 17 coverage rows (all 13 entry points answered, plus 4 invariant/manifest rows). The project's own 98 tests pass on this tree; every finding below was reproduced with a Foundry test under test/scratch/, and the three proofs attached fail on the current code for the stated reason.

    Kept and merged findings

    1. High. Idle reserve at zero supply is swept by the next buyer. Five specialists reported this; merged into one. With no backed tokens, a buy is priced at the opening price alone, so a 1 wei buyer after a 10 ETH seed donation redeems 9.9 ETH. The same path lets the last redeemer reclaim the fee that is supposed to stay for holders. Proof attached, fails with a 10.9 ETH versus 1 ETH balance.
    2. High. Full-redemption ratchet is griefable to an unusable price. Three reports merged. A 1 wei buy, a 0.01 ETH donation and a redemption write about 1e31 into minOpeningPrice, which has no path down. The owner can never reopen sales at a sane price. Proof attached, fails with OpeningPriceBelowFloor.
    3. Medium. Redemption is not limited to vault-sold tokens. Three reports merged (specialists rated it high, medium and low). The owner withdraws inventory and redeems it for 9.9 ETH of a buyer's payment, contradicting the "no owner withdrawal" guarantee in natspec and README. Any outside holder can do the same. Proof attached.
    4. Medium. The manifest passes $owner where the brief names a static owner. The specialists flagged this against the README. launch.json now exists and carries the same $owner, so I raised it as a manifest finding. Schema, argument count, name and ABI export are otherwise correct.
    5. Low. setToken accepts a codeless address into the one-shot slot. Three reports merged.
    6. Low. Donations before setToken plus renounceOwnership strand ETH forever.
    7. Low. Chunked selling reduces the effective redemption fee from 1% to about 0.011%.
    8. Info. bidPrice is non-zero and askPrice omits the premium while supply is zero. Two reports merged.
    9. Info. Owner spread changes between quote and execution, recorded as the documented trust assumption.

    Dropped or downgraded. Nothing was dropped outright. The static-analysis leads did not add findings: the strict-equality warnings are intentional zero-supply branches, and the aderyn reentrancy line is covered by nonReentrant with state finalised before the ETH call.

    My own pass found no further defects. Runtime bytecode is about 5.9 KB, the constructor makes no external calls, and arithmetic uses mulDiv with no overflow path. The owner-drain proof is my own; the other two attached proofs are the specialists' files, verified self-contained and failing here.

    ran onclaude · claude-fable-5-1 · 16 turns · 5m 35s · 322 in · 22.5K out · 823.3K cached
    submission6bbff7836d8fb16a84bda35129600e85e5af7fbecbb13247bd3d467dfe201de5
    device2b170a76e3d9adca5bc821cd1416f3dbdd76151670e9a7bc21f5407d70659eb6
    started from6ca66ec7bcd9fc72f8ba073e67274de5087ea2ae
    bundlenone
    applied one3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed, 675701250ac528deb94d52baad28f0b09c8d8ff160a0f3e0bbb9dda51580fd73, 01d3b5c42ce5d09635567c54f1a41fb58fdf6add8e03893880f9707b88ffb909
    • highETH held while backedSupply == 0 (owner seed donation or fee residue after a full redemption) is captured by the next buyer of any sizesrc/RisingFloorVault.sol:313

      Reported independently by all five contributors (economics, tests, flow, math, permissions); merged here. While no backed token is outstanding, _tokensOut prices a buy at openingPrice alone and ignores address(this).balance, but as soon as backedSupply becomes non-zero floorPrice() (line 278) is balance*WAD/backedSupply and includes every wei already held.

      The buyer of the first tokens of an epoch therefore owns the whole pre-existing reserve and can redeem it through sell() at that floor minus the fee.

      The reserve is non-empty with zero supply in two ordinary flows: README 'After launch' step 5 tells the owner to seed the reserve with a plain ETH transfer, and receive() (line 117) accepts ETH in every state; and every redemption that drives backedSupply to zero leaves the sell fee behind (sell computes ethOut with BPS - sellFeeBps even when remaining == 0), so the last redeemer or anyone else can buy 1 wei of tokens and take the fee that README line 28-29 says stays 'for the remaining holders'.

      This contradicts the README and natspec claim that a buy-then-sell round trip can never extract ETH and that the owner cannot take ETH out (the owner can pause buys, then unpause+buy+sell in one bundle). The existing fuzz test testFuzz_roundTripNeverProfitsAgainstTheReserve always performs a 1 ETH buy before the round trip and never seeds while supply is zero, so the path is untested.

      The 'floor never falls' invariant still holds, which is why the invariant suite does not catch it. Fix is the author's design call; the minimal one that keeps the design is to maintain reserve == 0 whenever backedSupply == 0: make receive() revert while backedSupply == 0 (seed by buying instead, which makes the owner a backed holder) and pay the whole reserve, without fee, on the redemption that brings supply to zero. Any fix must also change README step 5.

      State: RisingFloorVault(OWNER); OWNER calls setToken(T), setSpreads(300,100), setOpeningPrice(1e15); 1_000_000e18 inventory deposited; backedSupply == 0.

      (1) OWNER sends 10 ether to the vault via receive(): accepted, reserve = 10e18.

      (2) ALICE calls buy{value: 1}(0): tokensOut = 1*1e18/1e15 = 1000 base units, backedSupply = 1000, reserve = 10e18+1, floorPrice = 1.0000000000000000001e34.

      (3) ALICE approves and calls sell(1000, 0): ethOut = (10009900).mulDiv(10e18+1, 100010000) = 9.9e18 wei.

      Expected: a 1 wei buyer can take at most 1 wei of backing (README: 'a buy-then-sell round trip can never extract ETH').

      Actual: ALICE's balance rises by 9.899999999999999999 ETH, the owner's seed is gone and the vault keeps 0.1 ETH.

      Variant without seeding: ALICE buys with 1 ether (1000e18 tokens) and sells all (0.99 ETH out, 0.01 ETH fee stays, backedSupply 0, openingPrice ratchets to 1e15); BOB buys 1 wei -> 1000 base units; BOB sells 1000 -> 0.0099 ETH: BOB gains 0.009899999999999999 ETH for 1 wei.

      Verified: forge test --match-path test/scratch/Proof_fc1655becc2a.t.sol fails on this tree with '1 wei round trip must not extract the seeded reserve: 10899999999999999999 > 1000000000000000000' and '1 wei round trip must not extract the leftover reserve: 1009899999999999999 > 1000000000000000000'.

      Specialist proofs Proof_09602b45e982, Proof_1894340dea49 also fail on this tree for the same reason (9.0009 ETH / 4.89 ETH fee residue / 9.9 ETH respectively).

      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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("Proof", "PRF") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// @dev Any ETH the vault holds while `backedSupply == 0` is priced into nobody's tokens: the next
      ///      buy is quoted at `openingPrice` alone, so a 1 wei buyer receives tokens whose floor is the
      ///      whole reserve and redeems them for (almost) all of it.
      contract ZeroSupplyReserveCaptureTest is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal constant ALICE = address(0xA11CE);
          address internal constant BOB = address(0xB0B);
          uint256 internal constant OPENING = 0.001 ether;
      
          RisingFloorVault internal vault;
          ProofToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new ProofToken();
              vm.startPrank(OWNER);
              vault.setToken(address(token));
              vault.setSpreads(300, 100); // README's suggested 3% premium, 1% fee
              vault.setOpeningPrice(OPENING);
              vm.stopPrank();
              token.mint(address(this), 1_000_000e18);
              token.approve(address(vault), type(uint256).max);
              vault.depositInventory(1_000_000e18);
              vm.deal(OWNER, 10 ether);
              vm.deal(ALICE, 1 ether);
              vm.deal(BOB, 1 ether);
          }
      
          /// README "After launch" step 5: the owner seeds the reserve before the first sale.
          /// Expected: seeded ETH backs the token for its holders. Actual: a 1 wei buyer takes it.
          function test_seedDonationBeforeFirstSaleIsTakenByOneWeiBuyer() public {
              vm.prank(OWNER);
              (bool seeded,) = address(vault).call{value: 10 ether}("");
              // A fix may refuse donations while nothing is outstanding; that is acceptable.
              if (!seeded) return;
      
              uint256 aliceBefore = ALICE.balance;
              vm.startPrank(ALICE);
              uint256 bought = vault.buy{value: 1}(0);
              token.approve(address(vault), bought);
              uint256 quote = vault.quoteSell(bought);
              if (quote > 0) vault.sell(bought, 0);
              vm.stopPrank();
      
              assertLe(ALICE.balance, aliceBefore, "1 wei round trip must not extract the seeded reserve");
          }
      
          /// After the last holder redeems, the fee left behind is captured by whoever buys 1 wei next.
          function test_leftoverFeeAfterFullRedemptionIsTakenByOneWeiBuyer() public {
              vm.startPrank(ALICE);
              uint256 aliceTokens = vault.buy{value: 1 ether}(0);
              token.approve(address(vault), aliceTokens);
              vault.sell(aliceTokens, 0);
              vm.stopPrank();
              assertEq(vault.backedSupply(), 0);
      
              uint256 bobBefore = BOB.balance;
              vm.startPrank(BOB);
              uint256 bought = vault.buy{value: 1}(0);
              token.approve(address(vault), bought);
              uint256 quote = vault.quoteSell(bought);
              if (quote > 0) vault.sell(bought, 0);
              vm.stopPrank();
      
              assertLe(BOB.balance, bobBefore, "1 wei round trip must not extract the leftover reserve");
          }
      }
    • highFull-redemption ratchet copies a donation- or fee-concentrated floor into minOpeningPrice; anyone can push it to ~1e31 for ~0.0001 ETH and permanently disable salessrc/RisingFloorVault.sol:233

      Reported by economics, math and permissions (three variants of the same root cause); merged. When a sell brings backedSupply to zero, lines 233-238 write reserve_/supply (measured before the payout) into minOpeningPrice and openingPrice. minOpeningPrice is written only here and only upward, and setOpeningPrice (line 139) refuses anything below it; there is no owner path down.

      That ratio is attacker-controlled because floorPrice has no lower bound on backedSupply and receive() accepts ETH in every state: a 1 wei buy at openingPrice 1e15 creates a supply of 1000 base units, and 0.01 ETH donated against those 1000 units makes the floor 1e16*1e18/1000 = 1e31 wei per 1e18 base units (ten trillion ETH per whole token). Redeeming the 1000 units returns 99% of the donation and locks 1e31 in.

      Equivalent variants need no donation: buy a normal amount plus dust, redeem the normal amount (fee stays), redeem the dust (ratchet on fee/dust), or a sole holder sells all but 1 base unit and then donates (with sellFeeBps 0 the net cost is zero and the ratchet lands at 1e37).

      After that setOpeningPrice reverts OpeningPriceBelowFloor for every sane price, quoteBuy(1 ether) returns ~100 base units (1e-16 tokens) or 0 (ZeroOutput), and no owner action undoes it: the vault's only purpose, selling inventory with a floor, is permanently gone and the website points at a dead contract. The ratchet exists to protect past buyers, but at the moment it fires every past buyer has been paid in full, so it protects nobody.

      The same state is reached by honest activity when the last few base units are redeemed after a large fee residue has accumulated.

      Minimal fix direction (author's design call): ratchet to the highest price a buyer actually paid (track max(msg.value*WAD/tokensOut) in buy()) rather than reserve/supply, pay the whole reserve on the last redemption, and/or refuse donations while backedSupply is below a meaningful minimum; alternatively allow the owner to lower openingPrice while backedSupply == 0, which changes the stated guarantee and needs a scope decision.

      State: vault configured (setToken(T), setSpreads(300,100), setOpeningPrice(1e15)), inventory deposited, backedSupply == 0, reserve == 0.

      (1) GRIEFER calls buy{value: 1}(0): tokensOut = 1000, backedSupply = 1000, reserve = 1 wei.

      (2) GRIEFER sends 0.01 ether to the vault via receive(): reserve = 1e16+1.

      (3) GRIEFER approves and calls sell(1000, 0): ethOut = 0.0099 ETH, remaining == 0, floorBefore = (1e16+1)*1e18/1000 = 10000000000000001000000000000000; minOpeningPrice = openingPrice = 1e31.

      Griefer net cost: 1 wei + 1e14 wei (the 1% fee) + gas.

      (4) OWNER calls setOpeningPrice(1e15): reverts OpeningPriceBelowFloor(1000000000000000, 10000000000000001000000000000000).

      (5) ALICE buy{value: 1 ether}(0) returns 99 base units.

      Expected: an unprivileged actor spending 0.0001 ETH cannot lock the owner out of the launch price forever; 1 ETH still buys at least one whole token.

      Actual: as above.

      Verified: forge test --match-path test/scratch/Proof_f2d2d0fd68e1.t.sol fails on this tree with 'OpeningPriceBelowFloor(1000000000000000 [1e15], 10000000000000001000000000000000 [1e31])'.

      Math variant (setSpreads(0,0)): buy{value:1e15} -> 1e18 units; sell(1e18-1) -> reserve 1 wei, supply 1; donate 10 ether; sell(1,0) pays 1e19+1 back (zero net cost) and writes 1e37; quoteBuy(1 ether) == 0 forever.

      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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("Proof", "PRF") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// @dev `minOpeningPrice` ratchets to `reserve / backedSupply` whenever the supply returns to zero.
      ///      With a 1 wei buy the supply is 1000 base units; a 0.01 ETH donation then makes that ratio
      ///      1e31 wei per token (10 trillion ETH per whole token). Redeeming the 1000 units locks it in
      ///      forever: the owner can never reopen at a usable price, so sales are permanently disabled.
      contract RatchetGriefingTest is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal constant GRIEFER = address(0xBAD);
          address internal constant ALICE = address(0xA11CE);
          uint256 internal constant OPENING = 0.001 ether;
      
          RisingFloorVault internal vault;
          ProofToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new ProofToken();
              vm.startPrank(OWNER);
              vault.setToken(address(token));
              vault.setSpreads(300, 100);
              vault.setOpeningPrice(OPENING);
              vm.stopPrank();
              token.mint(address(this), 1_000_000e18);
              token.approve(address(vault), type(uint256).max);
              vault.depositInventory(1_000_000e18);
              vm.deal(GRIEFER, 1 ether);
              vm.deal(ALICE, 1 ether);
          }
      
          function test_oneHundredthEthGrieferLocksTheOwnerOutOfTheOpeningPriceForever() public {
              uint256 cost = GRIEFER.balance;
              vm.startPrank(GRIEFER);
              uint256 dust = vault.buy{value: 1}(0); // 1000 base units at 0.001 ETH per token
              (bool ok,) = address(vault).call{value: 0.01 ether}("");
              if (!ok) return; // a fix may refuse the donation; acceptable
              token.approve(address(vault), dust);
              vault.sell(dust, 0); // supply back to zero: ratchet fires on the donation-inflated floor
              vm.stopPrank();
              cost -= GRIEFER.balance;
              assertLt(cost, 0.0002 ether, "attack should cost only the 1% fee on the donation");
      
              // The owner must be able to keep selling at the price the project launched with.
              vm.prank(OWNER);
              vault.setOpeningPrice(OPENING);
              assertEq(vault.openingPrice(), OPENING);
      
              // And a normal buyer must still receive whole tokens for 1 ETH.
              vm.prank(ALICE);
              uint256 out = vault.buy{value: 1 ether}(0);
              assertGe(out, 1e18, "1 ETH must still buy at least one whole token after the griefing sequence");
          }
      }
    • mediumsell() redeems tokens the vault never sold: the owner (via withdrawInventory) or any outside holder can take the reserve ETH that backs a vault buyer, contradicting the documented "no owner withdrawalsrc/RisingFloorVault.sol:221

      Reported by flow (high), permissions (medium) and economics (low); merged at medium: it breaks an explicit documented guarantee and leaves individual buyers unable to redeem, but the ETH stays within the token's holder set and the README does acknowledge first-come-first-served for outside holders. sell() caps redemptions only by the global backedSupply; it does not check that the caller's tokens were bought from the vault.

      Unsold inventory is the same ERC-20, so withdrawInventory (owner-only, line 162) followed by sell() from the recipient is a two-call ETH withdrawal for the owner, capped at the reserve minus fee.

      The natspec (line 24: 'Nobody, including the owner, can withdraw ETH from the reserve'), README line 34-35 ('There is no owner withdrawal') and README line 75/82 (withdrawInventory 'cannot touch the reserve or the floor', 'a censorship and availability power rather than a custody one') are all false.

      The same drain is available without privilege to anyone holding project tokens from outside the vault (the token is launched separately on Keel, so most supply is outside); once backedSupply hits zero the vault's own buyers get ExceedsBackedSupply and have no redemption path until someone buys again, so the 'never drops' guarantee holds for the aggregate floor number but not for any individual buyer.

      Fix needs a scope decision: track a per-account redeemable credit minted by buy() and consumed by sell(), or at minimum correct the natspec/README/website so the owner power is documented as a reserve-custody power and the per-buyer guarantee is not claimed.

      State: configured vault (spreads 300/100, openingPrice 1e15), 1_000_000e18 inventory.

      ALICE calls buy{value: 10 ether}(0) -> 10_000e18 tokens, backedSupply = 10_000e18, reserve = 10 ETH, quoteSell(10_000e18) = 9.9 ETH.

      OWNER calls withdrawInventory(OWNER, 10_000e18) (allowed: inventory is not backing), approves, and calls sell(10_000e18, 0): receives 9.9 ETH, backedSupply = 0, reserve = 0.1 ETH.

      ALICE calls sell(10_000e18, 0): reverts ExceedsBackedSupply(0, 10000000000000000000000); quoteSell returns 0.

      Expected per natspec line 24 and README line 34: OWNER.balance unchanged and ALICE redeemable at the floor.

      Actual: OWNER.balance rises by 9900000000000000000 wei and ALICE holds unbacked tokens.

      Verified: forge test --match-path test/scratch/OwnerDrain.t.sol fails on this tree with 'owner withdrew ETH from the reserve via inventory redemption: 9900000000000000000 != 0'.

      The outside-holder variant is the project's own test_sell_rejectsMoreThanTheBackedSupply setup: BOB with 1000e18 tokens minted elsewhere calls sell(1000e18,0) after ALICE's 1 ETH buy, succeeds, and ALICE's later sell reverts ExceedsBackedSupply(0, 1000e18).

      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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("Proof", "PRF") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// @dev README: "Nobody can withdraw ETH from the reserve. There is no owner withdrawal" and
      ///      withdrawInventory "cannot touch the reserve or the floor". Unsold inventory is the same
      ///      ERC-20 as sold tokens and sell() caps redemptions only by the global backedSupply, so the
      ///      owner withdraws inventory, redeems it, and takes the ETH a vault buyer paid; the buyer is
      ///      left unable to redeem.
      contract OwnerDrainTest is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal constant ALICE = address(0xA11CE);
      
          RisingFloorVault internal vault;
          ProofToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new ProofToken();
              vm.startPrank(OWNER);
              vault.setToken(address(token));
              vault.setSpreads(300, 100);
              vault.setOpeningPrice(0.001 ether);
              vm.stopPrank();
              token.mint(address(this), 1_000_000e18);
              token.approve(address(vault), type(uint256).max);
              vault.depositInventory(1_000_000e18);
              vm.deal(ALICE, 10 ether);
          }
      
          function test_ownerCannotTakeReserveEthThroughInventoryRedemption() public {
              vm.startPrank(ALICE);
              uint256 aliceTokens = vault.buy{value: 10 ether}(0);
              vm.stopPrank();
              assertEq(aliceTokens, 10_000e18);
              assertEq(vault.reserve(), 10 ether);
      
              uint256 ownerEthBefore = OWNER.balance;
              vm.startPrank(OWNER);
              vault.withdrawInventory(OWNER, aliceTokens);
              token.approve(address(vault), aliceTokens);
              uint256 quote = vault.quoteSell(aliceTokens);
              if (quote > 0) vault.sell(aliceTokens, 0);
              vm.stopPrank();
      
              assertEq(OWNER.balance, ownerEthBefore, "owner withdrew ETH from the reserve via inventory redemption");
      
              // The vault buyer must still be able to redeem what she bought at the floor.
              vm.startPrank(ALICE);
              token.approve(address(vault), aliceTokens);
              uint256 out = vault.sell(aliceTokens, 0);
              vm.stopPrank();
              assertGt(out, 0);
          }
      }
    • mediumlaunch.json passes $owner although the brief states the owner address; the manifest guidance requires the static address, otherwise ownership can land on the paying walletlaunch.json:6

      Flow and permissions raised this against the README before the manifest existed; launch.json is now in the tree and carries the same $owner. The approved workflow states the owner explicitly: 'The owner of every deployed contract is 0xd3f26a6be348440110161dfd5748e7de7b08cf69 (the creator's wallet), set in the constructor'.

      The canonical manifest guidance says $owner resolves to the wallet that paid for the launch or the request's owner field, and that 'an owner address the brief itself states for a contract is written as that static address instead of $owner'. The notes field asserts that $owner is the creator wallet, but notes are explanatory text, not deployment authority, and nothing in the manifest binds $owner to that address.

      If the paying wallet or policy owner differs from the brief's wallet, RisingFloorVault.owner() is a different address and the brief's owner cannot call setToken, setSpreads, setOpeningPrice, setBuysPaused or withdrawInventory; since the owner role is unrecoverable (Ownable2Step transfer requires the current owner), the deployed vault would be permanently controlled by the wrong party.

      The rest of the manifest is correct: only kind/contracts/notes keys, one string argument matching the single constructor parameter, a 16-character unique name, notes under 4000 characters, no token/pool/ETH funding, and docs/abi/RisingFloorVault.json matches the compiled ABI exactly (70 entries, checked).

      State: launch paid by wallet P != 0xd3f26a6be348440110161dfd5748e7de7b08cf69 and the request's owner field absent or different.

      Manifest as in the tree: constructorArgs ["$owner"].

      The factory resolves $owner = P and calls RisingFloorVault(P); owner() == P.

      Expected per workflow.md: owner() == 0xd3f26a6be348440110161dfd5748e7de7b08cf69.

      Required change: constructorArgs ["0xd3f26a6be348440110161dfd5748e7de7b08cf69"], which makes the result independent of who paid; the protected Owners test already exercises the constructor with an explicit owner argument so the source needs no change.

      Needed evidence if $owner is kept: the pinned policy's contractsContext owner must be shown to equal the brief's wallet.

    • lowsetToken accepts an address with no code; because the slot is one-shot a wrong address permanently disables every token feature and strands any donated ETHsrc/RisingFloorVault.sol:127

      Reported by tests, math and permissions; merged. The brief makes the slot irreversible, but the only validation is non-zero and not-already-set.

      An address without code (typo, the token's address on another chain, an address copied before Keel deployed it) is accepted and TokenSet is emitted; afterwards every balanceOf call in buy, sell, depositInventory, inventory and withdrawInventory reverts (empty return data decoding), isConfigured() returns true, and setToken reverts TokenAlreadySet. ETH donated earlier through receive() can then never leave, since sell is the only ETH exit.

      Owner error rather than an exploit, hence low.

      Fix: require token_.code.length != 0 in setToken (and optionally that IERC20(token_).balanceOf(address(this)) succeeds). setToken is a post-deployment owner call, so this does not conflict with the empty-chain constructor rule.

      Deploy RisingFloorVault(OWNER).

      Anyone sends 1 ether to the vault (receive accepts, reserve = 1e18).

      OWNER calls setToken(0x0000000000000000000000000000000000001234) (no code): succeeds, emits TokenSet.

      OWNER calls setSpreads(0,0), setOpeningPrice(1e15); isConfigured() == true.

      ALICE calls buy{value: 1}(0): reverts (call to address without code at line 206).

      OWNER calls setToken(realToken): reverts TokenAlreadySet.

      Expected: a codeless address is rejected while the slot is still open.

      Actual: slot consumed, vault unusable, 1 ETH stranded.

      Observed in test/scratch/Checks.t.sol test_setTokenNoCode (setToken on 0x1234 accepted, buy reverted; the test's final assertion used a zero token address by my mistake and is not evidence).

    • lowreceive() accepts donations before setToken; renounceOwnership (or key loss) before setToken locks that ETH foreversrc/RisingFloorVault.sol:117

      Reported by flow. receive() accepts ETH in every state, including before the token slot is filled. ETH leaves only through sell(), which requires a token and backedSupply > 0. If the owner calls the inherited renounceOwnership() (README line 77 lists it as available) before setToken(), or loses the key, no token can ever be set and every donated wei is permanently locked.

      Owner-triggered, so low.

      Mitigation: refuse ETH until isConfigured(), or make renounceOwnership() revert while token() is zero. Overlaps with finding 1 (donations at zero supply) in the sense that both are fixed by refusing ETH while backedSupply == 0.

      Deploy RisingFloorVault(OWNER).

      Anyone sends 1 ether: accepted, ReserveDonated emitted, reserve() == 1e18.

      OWNER calls renounceOwnership(): owner() == address(0). setToken(x) now reverts OwnableUnauthorizedAccount for every caller; buy/sell revert TokenNotSet forever.

      Expected: an admin action cannot strand user ETH.

      Actual: 1 ether unrecoverable by anyone.

      Verified in test/scratch/Checks.t.sol test_donationThenRenounce (passes: reserve 1 ether, owner zero, setToken reverts).

    • lowRedemption fee is largely avoidable by selling in chunks, so remaining holders receive far less uplift than the per-sale fee impliessrc/RisingFloorVault.sol:319

      Reported by math. The fee is not removed from the pool; it stays in the reserve and is shared by whoever still holds backed tokens, including the seller for the part not yet sold. Selling in n pieces therefore pays strictly less total fee than one sale; for a sole holder the effective fee is roughly fee squared.

      The floor still never falls, so this weakens README line 28-29 ('a non-zero fee stays in the reserve for the remaining holders') rather than causing a loss.

      Options: document it, or charge the fee on a basis that is not redistributed to the seller; the latter changes the economics and needs an owner decision.

      Configured vault (setSpreads(300,100), openingPrice 1e15, 1_000_000e18 inventory).

      ALICE buy{value: 1 ether}(0) -> 1000e18.

      Single sell(1000e18,0) pays 990000000000000000 wei and leaves 10000000000000000 wei.

      Instead: sell(999e18,0) pays 989010000000000000; sell(1e18,0) pays 10880100000000000; total 999890100000000000 wei, leftover reserve 109900000000000 wei.

      Expected per README: 1% of the payout (0.01 ETH) stays for remaining holders.

      Actual: 0.011% (0.0001099 ETH) stays.

      Verified in test/scratch/Checks.t.sol test_chunkedFee (logs chunk1 989010000000000000, chunk2 10880100000000000, leftover 109900000000000).

    • infobidPrice() reports a non-zero redemption price while backedSupply == 0 although sell() reverts for every amount; askPrice() omits the premium in that state, unlike the README formulasrc/RisingFloorVault.sol:290

      Reported by economics and tests; merged. With nothing outstanding floorPrice() returns openingPrice, so bidPrice() returns openingPrice*(BPS-fee)/BPS although quoteSell() returns 0 and sell() reverts ExceedsBackedSupply for every amount. askPrice() returns openingPrice with no premium while README line 22 defines askPrice as floor*(1+premium); quoteBuy and buy agree with askPrice, so only the documentation and the bid view are inconsistent.

      The website shows these values; a user may believe tokens are redeemable at bidPrice when they are not. Suggest bidPrice() return 0 when backedSupply == 0 and the README state that the opening sale carries no premium.

      setSpreads(300,100); setOpeningPrice(1e15); no buys. bidPrice() == 990000000000000, askPrice() == 1000000000000000 (README formula gives 1030000000000000), quoteSell(1e18) == 0, sell(1e18,0) reverts ExceedsBackedSupply(0, 1e18). Verified in test/scratch/Checks.t.sol test_viewsAtZeroSupply.

    • infoOwner can change spreads within the caps between a quote and its execution; users who pass zero slippage minimums can pay up to 10% more fee or 20% more premium (documented trust assumption)src/RisingFloorVault.sol:145

      Reported by flow. README line 73 documents that spreads may change at any time within the caps; recorded here as a trust assumption with actor and preconditions, not a defect. setSpreads has no timelock, so a pending sell(amount, 0) or buy(0) can be front-run by the owner to the cap. Funds stay in the reserve for remaining holders, so this is not custody.

      The slippage parameters defend against it when the website passes quoteBuy/quoteSell as minimums as README line 143-144 instructs.

      Configured vault with sellFeeBps = 0.

      ALICE holds 1000e18 backed tokens with floor 1e15; quoteSell(1000e18) == 1 ether.

      ALICE submits sell(1000e18, 0); OWNER's setSpreads(0, 1000) is mined first.

      ALICE receives 900000000000000000 wei instead of 1 ether; with minEthOut = 1 ether she would have reverted SlippageExceeded.

      Verified in test/scratch/Checks.t.sol test_spreadFrontRun (logs 'out after frontrun 900000000000000000').

  9. Build contract projectAgent #18715 files changedsent back

    The responses file is excluded from git by the harness's own exclude list, which is its collection mechanism, so it sits at the repository root as required. All checks are green and nothing else is outstanding.

    Recap

    All three reopened findings reproduced on the starting tree and are fixed. The three held proofs now pass, along with the full suite.

    CheckResult
    forge buildpasses
    forge test with the verifier's fuzz seed74 passed, 0 failed
    forge test with a second seed74 passed, 0 failed
    forge fmt --checkclean
    Reviewer proofs (copied to scratch)3 of 3 pass

    What changed in src/RisingFloorVault.sol

    • Reserve capture at zero supply. Plain ETH is refused while nothing is outstanding, and the redemption that empties the backed supply pays the whole reserve fee-free. The reserve is therefore empty whenever the supply is, so no buyer ever inherits ETH priced into nobody's tokens.
    • Ratchet griefing. I evaluated the suggested "highest price paid" ratchet and found it equally movable: with any non-zero fee, a sole holder can concentrate residue on one base unit, buy one unit at that ask, and redeem everything for the cost of the fee. So the activity-derived ratchet is gone. The minimum opening price now records only the owner-set opening price at which an epoch's first sale happened, which nobody but the owner can influence. A full redemption no longer rewrites the opening price.
    • Redeeming unsold inventory. Each buy credits the buyer with a per-account redemption right that sell spends. Withdrawn inventory, outside-minted and market-bought tokens have no credit, so the owner's two-step drain is closed and every vault buyer keeps their own redemption. A credit transfer function lets holders pass the right on with their tokens. The caller-side quote now returns zero without credit, and an explicit per-account quote was added.
    • Advisory items. The token setter rejects codeless addresses, renouncing ownership is refused until the token slot is filled, the bid price reads zero while nothing is redeemable, and the chunked-fee behaviour and the spread front-run scenario are documented rather than changed.

    Tests and docs. The unit file gained tests for every item above, including both griefing sequences and a fuzz test that an empty supply always means an empty reserve. The invariant handler now bounds sells by credit, moves credits, expects donation refusals at zero supply, and has the owner attempt to redeem every withdrawal. Because fail_on_revert is off, handler checks are recorded as violations and asserted from an invariant, which I verified actually fails when a violation occurs. The README's guarantees, epoch semantics, owner table, after-launch steps and website guidance were rewritten to match, and the ABI export was regenerated.

    Design trade-off to be aware of. An epoch's final floor is no longer carried into the next epoch. Every holder it protected has already been paid at it, and the owner can raise the opening price before the next sale. The README states this under "Epochs".

    All eight findings are answered in the responses file with verdict "fixed" and the reproduction and tests behind each. No launch.json or blocked file was written, and nothing was committed.

    ran onclaude · claude-fable-5-1 · 69 turns · 18m 30s · 546 in · 82.7K out · 2.3M cached
    submissionddc6ea0c84f71ea9c0de7c752807b1aea029ce3fb4d0523222dfd1f44375dcb0
    device2a9662a76cb5f51d178c6d5ff9e9a5da33ad63feb5a9ef85547ee127dbf9fd6f
    started fromd2acb2d645685f7f918efa0a96d2f19c908555c1
    bundleece60206d50216d5343abaf615fb410d08017de4b7d80003da2f3dc7f448f1f4 · 522 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 5 files
    README.mddocs/abi/RisingFloorVault.jsonsrc/RisingFloorVault.soltest/RisingFloorVault.invariant.t.soltest/RisingFloorVault.t.sol
  10. ManifestAgent #11731 file changedsent back

    The finding reproduced and is fixed.

    What I found. The approved brief in workflow.md states the owner explicitly as the creator wallet. The manifest guidance says a brief-stated owner is written as that static address rather than $owner. The previous launch.json used $owner, so ownership would follow the paying wallet if it differed from the brief's wallet.

    What I changed. Only launch.json:

    • The single constructor argument for RisingFloorVault is now the static address from the brief. This matches the one-parameter constructor in src/RisingFloorVault.sol.
    • The notes now explain that the static address is used so ownership does not depend on who paid.

    Checks. The manifest has only the kind, contracts and notes keys, with notes under the length limit. Both forge build and forge test with the verifier's fuzz seed pass, with 70 tests passing and none failing. Git status shows launch.json as the only modified tracked file.

    Response file. The answer to the finding is recorded in .imd-responses.json at the repository root with verdict fixed. The file exists on disk but is gitignored in this workspace, so it will not appear in the commit diff. That is the location the task specified, so it is left as is.

    ran onclaude · claude-fable-5-1 · 9 turns · 53s · 162 in · 3.1K out · 184.7K cached
    submissionf9acebd06afc09f6fb3fe05e53d495b9839a732fe997bc9ed6dd258643f02c93
    device5c1e4764d4e20ef59396a25901f46bc91bd6bc928f7285902a1b85ca759b1272
    started fromc61b145bf4e364f4fb1f62ac5fb03a4877cbea9b
    bundle832477c4a6dfe1f7c6bbbeed11d6e9ee43dff022b854698a452a65b948ce9a1a · 523 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied one3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed
    changed · 1 file
    launch.json
    may write
    launch.json
  11. Write foundry testsAgent #2052 files changedsent back

    The findings file is excluded from git by the harness's own .git/info/exclude, so that is expected: the harness collects it from the root. Everything is in place.

    Outcome: the suite passes against the revised source, and one defect is reported rather than tested around.

    The source was changed after my previous round (redemption credits, refused donations between epochs, the last redemption paying the whole reserve, renamed errors), which left both of my files failing to compile. I revised them to the new semantics without rewriting them, and added coverage for the new surface.

    Changes in test/RisingFloorVault.edges.t.sol

    • Updated the renamed errors and the quote calls that now depend on the caller's credit.
    • Split the old "transferred tokens redeem" test into three: tokens without credit fail at the credit gate, credit without tokens fails at the pull and leaves the credit intact, and both together redeem the whole reserve.
    • Added transferRedeemable failure paths: zero address, zero amount, one over the credit, a stranger and the owner with no credit, a self-transfer that must not burn or double credit, a three-way split that sums to the reserve, and a reentrancy probe through the sell payout into the unguarded transfer.
    • Reworked the epoch-end tests to the new rule: the last holder takes the residue, the vault reopens at the opening price, and the ratchet is the opening sold at, not the floor reached.
    • Added refused donations between epochs, renounce refused before the token is set, and forced ETH while empty going to the next epoch's holders.

    Changes in test/RisingFloorVault.sequences.invariant.t.sol

    • The handler now predicts every revert in the credit model, including credit-without-tokens pulls, and drives credit transfers and the owner's attempt to redeem withdrawn inventory.
    • Donations are asserted refused exactly while nothing is outstanding. Forced ETH between epochs is tracked as a ghost so "empty vault, empty reserve" still holds.
    • Invariants were made per-epoch for the floor and now also check that credits sum to the backed supply, that the ratchet equals the best opening ever sold at, and solvency per credit holder.

    Results with the verifier's fuzz seed and a second seed:

    SuiteTestsResult
    All four suites123pass
    Sequence invariant128 runs, depth 64, fail-on-revert0 reverts

    Reported in .imd-findings.json

    • Medium: a holder who redeems all but one base unit of credit concentrates the fee and rounding residue on that unit, pinning the ask at about 1e16 ETH per token. Sales effectively stop and the owner has no remedy, since the opening price cannot be changed while anything is outstanding. The proof in the findings file fails on the current code with the exact numbers.
    • Info: between epochs the quoted floor returns to the opening price, below the floor the closed epoch reached. The source documents this as intended, so it is noted for the reviewer only.

    Only the two test files changed in the tree. No configuration or source was touched.

    ran onclaude · claude-fable-5-1 · 48 turns · 11m 48s · 322 in · 56.3K out · 1.1M cached
    submissiond322a0b046ed2550b077c50212620340e0a556c2b89c179a54ed8172c54e19ec
    device357c46e3781993d449f398d7eae2be8718b1cfa8deff2cc3661e944506942b5e
    started from5e26ec272028c9decd999a7383e737e20bbab2a4
    bundlefdc5f6b4c48205146e069afdcc6ad1b9120307a370eaa0796dca8f32d8e6fc26 · 544 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied one3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed
    changed · 2 files
    test/RisingFloorVault.edges.t.soltest/RisingFloorVault.sequences.invariant.t.sol
    may write
    testtest/**
    • mediumA holder keeping one base unit of credit pins the floor orders of magnitude higher and stalls sales; the owner has no remedysrc/RisingFloorVault.sol:371

      floorPrice = reserve / backedSupply with no lower bound on backedSupply. A holder who redeems all but one base unit of their credit leaves every wei of fee and rounding residue backing that single unit (_ethOut pays amount*(BPS-fee)reserve/(supplyBPS) for a partial redemption, so the residue stays).

      With the default 1% fee a 1 ETH epoch leaves ~0.01 ETH behind one base unit, so the ask becomes ~1.05e34 wei per whole token (about 1e16 ETH): the same 1 ETH that bought 1e21 base units a moment earlier now buys 95. Even with zero fee the rounding residue alone (1 wei on 1 unit) pins the floor at 1 ETH per token, 1000x the opening.

      The state is permanent until the dust holder chooses to redeem: setOpeningPrice reverts with SupplyOutstanding while the unit is outstanding, nobody can be forced to redeem, and withdrawing inventory or pausing does not help. Any buyer can therefore grief the vault for the cost of one buy, and an ordinary user does it by accident by redeeming 'everything' with a rounded amount.

      No funds are lost (the floor-only-rises guarantee still holds), so this is a liveness/griefing issue, not a theft. Possible mitigations for the implementer to weigh: a minimum redemption that leaves either zero or at least some floor amount of credit outstanding (e.g. amount == credit or remaining >= 1e18 base units), or an owner-triggered epoch close that pays the remaining credit holders at the floor and reopens.

      Configure setSpreads(500, 100), setOpeningPrice(0.001 ether), inventory 1e24.

      Alice: buy{value: 1 ether}(0) -> 1000e18 units.

      Alice: sell(1000e18 - 1, 0).

      State: backedSupply == 1, reserve ~ 0.0099 ETH.

      Expected: askPrice within a bounded multiple of the opening price (the proof uses 1000x = 1 ETH per token) and quoteBuy(1 ether) >= 1e18.

      Actual: askPrice() == 10500000000000001050000000000000000 wei per whole token, quoteBuy(1 ether) == 95 base units, and setOpeningPrice(0.001 ether) by the owner reverts SupplyOutstanding(1).

      Run: forge test --match-path test/scratch/DustPinsFloor.t.sol -vv

      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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("Proof", "PRF") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// @notice A holder who keeps one base unit of credit after redeeming the rest concentrates every
      ///         wei of fee and rounding residue on that unit. The floor (reserve / supply) then jumps by
      ///         orders of magnitude, every buy of ordinary size rounds to zero tokens, and the owner has
      ///         no remedy: the opening price cannot be changed while anything is outstanding and nobody
      ///         can be forced to redeem. Sales stop until the dust holder chooses to leave.
      contract DustPinsFloorProof is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal constant ALICE = address(0xA11CE);
          address internal constant BOB = address(0xB0B);
          uint256 internal constant OPENING = 0.001 ether; // 0.001 ETH per whole token
      
          RisingFloorVault internal vault;
          ProofToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new ProofToken();
              vm.startPrank(OWNER);
              vault.setToken(address(token));
              vault.setSpreads(500, 100); // 5% premium, 1% fee
              vault.setOpeningPrice(OPENING);
              vm.stopPrank();
              token.mint(address(vault), 1_000_000e18);
              vm.deal(ALICE, 10 ether);
              vm.deal(BOB, 10 ether);
          }
      
          function test_oneBaseUnitOfCreditFreezesSalesAndTheOwnerCannotReopen() public {
              // Alice buys 1 ETH of tokens at the opening price: 1000 whole tokens.
              vm.prank(ALICE);
              uint256 bought = vault.buy{value: 1 ether}(0);
              assertEq(bought, 1000e18);
      
              // She redeems everything but one base unit. The 1% fee (about 0.01 ETH) stays behind,
              // backing a single base unit.
              vm.startPrank(ALICE);
              token.approve(address(vault), bought - 1);
              vault.sell(bought - 1, 0);
              vm.stopPrank();
              assertEq(vault.backedSupply(), 1);
              assertGt(vault.reserve(), 0.009 ether);
      
              // The owner cannot reset the epoch while the unit is outstanding, and cannot force it out.
              vm.prank(OWNER);
              vm.expectRevert(abi.encodeWithSelector(RisingFloorVault.SupplyOutstanding.selector, 1));
              vault.setOpeningPrice(OPENING);
      
              // Expected: the vault keeps selling at a price in the region of what it sold at before
              // (the ask within, say, 1000x the opening price). Actual: the ask is ~1e34 wei per whole
              // token, about 1e16 ETH, so the same 1 ETH that bought 1e21 base units a moment ago now
              // buys 95, and the state persists until the dust holder chooses to redeem.
              emit log_named_uint("ask price (wei per whole token)", vault.askPrice());
              emit log_named_uint("base units for 1 ETH now", vault.quoteBuy(1 ether));
              assertLe(vault.askPrice(), 1000 * OPENING, "ask price pinned out of reach by one base unit");
              assertGe(vault.quoteBuy(1 ether), 1e18, "1 ETH no longer buys even one whole token");
          }
      }
    • infoBetween epochs the quoted floor falls back to the opening price, below the floor the closed epoch reachedsrc/RisingFloorVault.sol:316

      minOpeningPrice records only the opening price an epoch sold at, not the floor it reached, and the last redemption pays the whole reserve. So after a full redemption floorPrice() drops from the epoch's final floor to openingPrice, and the owner may legitimately reopen anywhere at or above the old opening.

      Nobody holds a backed token at that moment, so no holder is underpaid, and the source documents this as intended (it replaced a ratchet that could be locked by donations against dust).

      It is reported only because the brief's wording ('never drops in price') and the website will show a price that visibly goes down between epochs; the tests assert the documented behaviour (test_sell_lastRedeemerTakesTheResidueAndTheEpochReopensAtTheOpeningPrice, testFuzz_openingRatchetAfterFullRedemption).

      setSpreads(500,100), setOpeningPrice(0.001 ether).

      Alice buy 1 ETH, Bob buy 1 ETH, Alice sell all -> floorPrice ~ 0.00104 ETH.

      Bob sell all -> floorPrice() == 0.001 ether, minOpeningPrice() == 0.001 ether.

      Expected per the brief's wording: the displayed price never goes down; actual: it returns to the opening price.

  12. Audit judgeAgent #785found 1 high, 2 info

    Review complete. The findings file is at .imd-findings.json with 3 findings and 16 coverage rows; no repository file was changed.

    Settled from last round

    • Reserve capture at zero supply (high): fixed. receive() now reverts while nothing is outstanding and the final redemption pays the whole reserve fee-free. My proof and both specialist proofs on this root cause pass on this tree.
    • Ratchet brick (high): fixed. minOpeningPrice is now written only from the owner-set opening price inside buy(). Both ratchet proofs pass.
    • Owner or outside holder redeems the reserve (medium): fixed by the per-account redeemable credit. My proof passes. The flow specialist's proof now fails only because the owner's sell reverts, which is the fix working.
    • launch.json $owner (medium): fixed. The manifest carries the brief's static owner address, matches the single constructor parameter, has only the schema keys, and the ABI export is identical to the compiled one.
    • Low and info items (codeless setToken, pre-setToken donation stranding, chunked fee, bid price at zero supply, spread front-run): all confirmed fixed in code or documented as the author described.

    The project's own suite passes with 123 tests.

    New finding, carried from the tester (rated high, 1 of 3)

    A sole credit holder who redeems all but one base unit leaves the whole fee residue behind that unit. The ask becomes astronomical, setOpeningPrice reverts with SupplyOutstanding, and the owner has no remedy. The position costs the griefer nothing since the last unit redeems the residue fee-free. Reproduced at launch with a 1000 wei buy: 1 ETH then buys 0.0866 of a token instead of 1000. The proof at test/scratch/Proof_DustPin.t.sol fails on this tree and is attached. I rated it high rather than the tester's medium because it is unprivileged, costs gas only, and leaves the deployment unusable at the attacker's discretion.

    Two info notes: the floor visibly drops back to the opening price between epochs (documented design), and the README still says the manifest passes $owner and that no launch.json exists.

    Coverage answers all 14 entry points: 13 hold, sell carries finding 1. Static-analysis leads (strict equalities, reentrancy in buy) were checked and are intentional or guarded, so none were kept.

    ran onclaude · claude-fable-5-1 · 16 turns · 5m 3s · 322 in · 19.8K out · 980.5K cached
    submissionb73ee04ee3f9208894d2669790f6f17200825827a5b804e75e099c12c75a21b0
    device48cf2e19b6c5438694ce408bdf284342cd2d56a53719317bf19c016febaa01cd
    started from07e1e6258884267f477f66cd8ec473fe8aa4b74b
    bundlenone
    applied one3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed, 675701250ac528deb94d52baad28f0b09c8d8ff160a0f3e0bbb9dda51580fd73, 01d3b5c42ce5d09635567c54f1a41fb58fdf6add8e03893880f9707b88ffb909
    • highA sole credit holder who redeems all but one base unit pins the floor on the fee residue; the owner has no remedy and anyone can stall sales indefinitely for about 1000 weisrc/RisingFloorVault.sol:265

      Carried over from the independent tester's finding (reported there as medium) and from the dust variant of my earlier ratchet finding; the ratchet itself is fixed, but the state that fed it remains. floorPrice() is reserve/backedSupply with no lower bound on backedSupply, and sell() lets a partial redemption leave any remainder, including 1 base unit.

      Because a partial redemption keeps the fee (and the round-down residue) in the reserve, a holder who owns the whole backed supply and redeems all but one unit leaves every wei of residue behind that single unit.

      The ask then becomes astronomical (11 ETH per whole token for a 1000 wei position; about 1e16 ETH per token after a 1 ETH position), quoteBuy(1 ether) returns a fraction of a token, and setOpeningPrice reverts SupplyOutstanding(1) for as long as the unit is outstanding. Nothing the owner can do clears it: pausing, withdrawing inventory and donating do not change the ratio (a donation raises it), and no function force-redeems.

      Only the dust holder can end it, and holding costs them nothing: the final unit is redeemed fee-free for the whole residue, so their ETH is fully recoverable at any time. Any unprivileged actor can therefore stall the vault's only function at launch or between epochs for roughly 1000 wei plus gas, repeatably, and an ordinary user reaches the same state by accident by redeeming a rounded amount.

      No funds are lost and the floor-only-rises guarantee holds, which is why the invariant suite does not catch it; it is rated high because it is unprivileged, costs gas only and leaves this deployment unusable at the attacker's discretion.

      Fix is the author's design call: refuse a redemption that leaves 0 < remaining < some minimum (one whole token, or an owner-set minimum) unless it empties the supply, and/or give the owner an epoch-close that pays every remaining credit at the floor and reopens; refusing donations below the same minimum closes the donation variant.

      Configured vault (setSpreads(500,100), setOpeningPrice(1e15), setToken, 1_000_000e18 inventory), backedSupply == 0.

      GRIEFER: buy{value: 1000}(0) -> 1_000_000 base units, reserve 1000 wei. approve; sell(999_999, 0) -> ethOut = (999_9999900).mulDiv(1000, 1_000_00010_000) = 989 wei; reserve = 11 wei, backedSupply = 1, floorPrice() = 11e18 (11 ETH per whole token, 11_000x the opening).

      OWNER: setOpeningPrice(1e15) reverts SupplyOutstanding(1). quoteBuy(1 ether) = (1e1810_000).mulDiv(1, 1110_500) = 86_580_086_580_086_580 base units (0.0866 token; 1 ETH bought 1000e18 a moment earlier).

      GRIEFER may later call sell(1, 0) and receive 11 wei, restoring their balance exactly.

      Honest variant: ALICE buy{value: 1 ether} -> 1000e18; sell(1000e18 - 1, 0) -> reserve ~0.0099 ETH, backedSupply 1, askPrice() = 10500000000000001050000000000000000.

      Expected: an unprivileged 1000 wei position cannot stop 1 ETH from buying at least one whole token near the opening price, or the owner has a remedy.

      Actual: as above.

      Verified: forge test --match-path test/scratch/Proof_DustPin.t.sol fails on this tree with '1 ETH no longer buys one whole token: sales are stalled: 86580086580086580 < 1000000000000000000' and 'ask left any sane range: 10500000000000001050000000000000000 > 1000000000000000000'.

      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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {RisingFloorVault} from "src/RisingFloorVault.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("Proof", "PRF") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      /// @dev floorPrice = reserve / backedSupply has no lower bound on backedSupply. A holder who owns
      ///      the whole backed supply redeems all but one base unit; the fee and rounding residue then
      ///      backs that single unit, the ask becomes astronomical, setOpeningPrice reverts with
      ///      SupplyOutstanding, and nobody but the dust holder can ever clear the state. The griefer
      ///      keeps the right to redeem the residue fee-free, so the attack costs gas only.
      ///
      ///      The tests pass if the vault refuses to leave such a dust position outstanding (the sell
      ///      reverts) or if the quoted buy stays within a sane range afterwards.
      contract DustPinTest is Test {
          address internal constant OWNER = 0xD3f26A6Be348440110161dFD5748E7DE7B08cf69;
          address internal constant GRIEFER = address(0xBAD);
          address internal constant ALICE = address(0xA11CE);
          uint256 internal constant OPENING = 0.001 ether;
      
          RisingFloorVault internal vault;
          ProofToken internal token;
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new ProofToken();
              vm.startPrank(OWNER);
              vault.setSpreads(500, 100);
              vault.setOpeningPrice(OPENING);
              vault.setToken(address(token));
              vm.stopPrank();
              token.mint(address(this), 1_000_000e18);
              token.approve(address(vault), type(uint256).max);
              vault.depositInventory(1_000_000e18);
              vm.deal(GRIEFER, 1 ether);
              vm.deal(ALICE, 10 ether);
          }
      
          /// 1000 wei pins the vault while nothing is outstanding: the owner cannot reset the price,
          /// 1 ETH buys a fraction of a token, and the griefer loses nothing by holding the position.
          function test_thousandWeiGrieferStallsSalesAndOwnerHasNoRemedy() public {
              assertEq(vault.backedSupply(), 0);
              uint256 before = GRIEFER.balance;
      
              vm.startPrank(GRIEFER);
              uint256 units = vault.buy{value: 1000}(0); // 1e6 base units at 0.001 ETH per token
              assertEq(units, 1_000_000);
              token.approve(address(vault), units);
              (bool pinned,) = address(vault).call(abi.encodeCall(RisingFloorVault.sell, (units - 1, 0)));
              vm.stopPrank();
              if (!pinned) return; // the vault refuses to leave a dust position outstanding: fixed
      
              assertEq(vault.backedSupply(), 1);
              uint256 residue = vault.reserve(); // 11 wei of fee and rounding residue behind one unit
              assertEq(residue, 11);
              assertEq(vault.floorPrice(), 11e18); // 11 ETH per whole token, 11000x the opening price
      
              // The owner cannot reopen: the price is locked behind the one outstanding unit.
              vm.prank(OWNER);
              vm.expectRevert(abi.encodeWithSelector(RisingFloorVault.SupplyOutstanding.selector, 1));
              vault.setOpeningPrice(OPENING);
      
              // The griefer can exit fee-free at any time and recover the whole residue: net cost is gas.
              uint256 snap = vm.snapshotState();
              vm.prank(GRIEFER);
              vault.sell(1, 0);
              assertEq(GRIEFER.balance, before);
              vm.revertToState(snap);
      
              // Expected: an unprivileged actor spending 1000 wei cannot stop 1 ETH from buying at least
              // one whole token near the opening price. Actual: 1 ETH buys 0.0866 tokens (ask 11.55 ETH).
              uint256 quote = vault.quoteBuy(1 ether);
              assertGe(quote, 1e18, "1 ETH no longer buys one whole token: sales are stalled");
          }
      
          /// The same state is reached by an honest sole holder who redeems "everything" minus one unit.
          function test_honestHolderLeavingOneUnitPinsTheFloorForEveryoneElse() public {
              vm.startPrank(ALICE);
              uint256 units = vault.buy{value: 1 ether}(0);
              assertEq(units, 1000e18);
              token.approve(address(vault), units);
              (bool pinned,) = address(vault).call(abi.encodeCall(RisingFloorVault.sell, (units - 1, 0)));
              vm.stopPrank();
              if (!pinned) return; // fixed: dust position refused
      
              assertEq(vault.backedSupply(), 1);
              // ~0.0099 ETH of fee residue backs one base unit: ask is ~1.05e34 wei per whole token.
              uint256 ask = vault.askPrice();
              assertLe(ask, OPENING * 1000, "ask left any sane range");
              assertGe(vault.quoteBuy(1 ether), 1e18, "1 ETH buys less than one whole token");
          }
      }
    • infoBetween epochs floorPrice() falls back to openingPrice, below the floor the closed epoch reached; the website will show a price going downsrc/RisingFloorVault.sol:318

      Reported by the independent tester; confirmed. The fix for the ratchet finding records only the owner-set opening price in minOpeningPrice and pays the whole reserve on the last redemption, so after a full redemption floorPrice() drops from the epoch's final floor to openingPrice and the owner may reopen anywhere at or above the old opening.

      No holder is underpaid (nobody holds a backed token at that moment) and the README documents it as intended, so this is a note for the brief's 'never drops in price' wording and the website, not a defect: the displayed floor visibly decreases between epochs unless the owner raises openingPrice before the next sale (README 'Operational responsibilities').

      setSpreads(500,100), setOpeningPrice(1e15).

      ALICE buy{value: 1 ether} -> 1000e18; BOB buy{value: 1 ether} -> (1e1810000).mulDiv(1000e18, 1e1810500) = 952.38e18; floorPrice ~1.0244e15.

      ALICE sell(1000e18, 0) -> fee stays; floorPrice rises to ~1.0344e15.

      BOB sell(all, 0) -> pays the whole reserve; backedSupply 0; floorPrice() == 1e15 == minOpeningPrice().

      Expected by the brief's wording: the displayed price never goes down.

      Actual: it returns from ~1.0344e15 to 1e15.

      The project's own test_sell_lastRedeemerTakesTheResidueAndTheEpochReopensAtTheOpeningPrice asserts this behaviour.

    • infoREADME still says the manifest passes `$owner` and that no launch.json exists, contradicting the accepted launch.json which carries the static owner addressREADME.md:238

      The manifest finding from the last round is fixed in launch.json (constructorArgs is now the static address 0xd3f26a6be348440110161dfd5748e7de7b08cf69, matching workflow.md line 5 and the single constructor parameter). The README was not updated: line 71 lists the launch value as $owner and lines 237-239 say the manifest passes $owner and that the repository contains no launch.json.

      Documentation only; it does not affect deployment, but an operator reading the README would expect the paying wallet to become owner.

      Read launch.json: constructorArgs == ["0xd3f26a6be348440110161dfd5748e7de7b08cf69"] and the file exists at the repository root.

      Read README.md line 71 and 237-239: '$owner = ...' and 'no launch.json'.

      Expected: README matches the manifest.

      Actual: it describes the superseded $owner argument and denies the file exists.

  13. Build contract projectAgent #1626 files changed2 attempts
    #125Claudetests failed

    The revision is complete, but with one important caveat: forge build and forge test could not be run on this machine. Every shell command this session failed before starting with the sandbox error below, so the verifier's run will be the first compile of these changes. I reviewed the edits by hand against the compiler rules and the proof's expectations.

    apply-seccomp: write /proc/self/setgroups (nested userns is capability-restricted; caller must provide CAP_SYS_ADMIN): Permission denied
    

    What I changed for the high finding (dust pin, id 7245…), in src/RisingFloorVault.sol:

    • Minimum position. New owner-set minPosition with default DEFAULT_MIN_POSITION of one whole 18-decimal token. A sell may not leave a nonzero credit below it in the seller's account, and a credit transfer applies the same rule to both the credit left behind and the credit received. A full exit is always allowed, so no setting locks anyone in. Quotes return zero for a refused redemption. Both proof scenarios now revert at the dust-leaving sell, which is the proof's pass branch.
    • Donation variant. Plain ETH is refused while the whole backed supply is below the minimum.
    • Owner remedy. The minimum cannot stop a sub-minimum buy made before a large holder's full exit from inheriting that exit's fee. The new forceRedeem pays such a holder their whole credit at the floor, fee-free, clears the credit and leaves the others' floor unchanged or higher. The owner receives nothing. Refused ETH is escrowed per holder and collected with withdrawPending, and the reserve excludes escrow.

    Tests and docs. Twelve new unit tests cover the 1000 wei scenario, exact-minimum remainders, full exits under a raised minimum, transfer rules, dust donations, and all forceRedeem paths including escrow and reentrancy. The tests that asserted the old dust behaviour were updated, the sell fuzz test asserts the refusal, and the invariant handler now drives minimum changes and forced redemptions with ETH conservation extended to escrow. README documents the minimum, the remedy as a trust assumption, the website duties and a new after-launch step. The ABI export was extended by hand.

    Advisory findings. The between-epoch floor fallback is answered as disputed: it is the intentional alternative to the ratchet removed last round, and the README already tells the owner how to carry a floor over. The README manifest wording is fixed: it now names the static owner address and explains that launch.json is written by the manifest assignment, not by this tree.

    All three answers are in .imd-responses.json. The previous attempt's console ambiguity error did not come from anything in this tree, whose forge-std is the standard v1.9.7 alias, so nothing in lib/ was touched.

    ran onclaude · claude-fable-5-1 · 101 turns · 22m 53s · 642 in · 105.4K out · 2.7M cached
    submissionea19945ffcc60d859efbc61de9ce953d2153532b8a775ad5fe9d645e094ab6b9
    device27168b1ff68f84d222c33c9caedd445b1ce14d8429cb7647d95146490b6d392c
    started fromcd2745c4230e631789d69587aea50efaf2ffd120
    bundle5e2d1f634d1c47c30fa7c8daccd89c004dd1efafb4849a5274142ac09a8efe0a · 531 KB
    changed · 6 files
    README.mddocs/abi/RisingFloorVault.jsonsrc/RisingFloorVault.soltest/RisingFloorVault.invariant.t.soltest/RisingFloorVault.t.soltest/mocks/Actors.sol
    #162Claude6 files changed

    Everything passes and the revision is complete.

    Finding 1 (high, dust-pinned floor): fixed. The proof reproduced on the starting tree with the exact values reported. The fix in src/RisingFloorVault.sol applies both remedies the reviewer suggested:

    • No dust left behind. A partial redemption or a credit transfer can no longer leave any account with a nonzero credit worth less than the new constant MIN_POSITION_VALUE (0.01 ETH) at the floor before the operation. It reverts with PositionTooSmall. Full redemptions of any size and buys of any size still work, so the proof's 1000 wei buy succeeds and the pare-down to one unit is refused. A new view minRemainingCredit() tells the website the threshold, which shrinks as the floor rises. Donations against a reserve below the same minimum are refused.
    • Owner remedy. forceRedeem(address[] holders) clears each listed credit and pays its exact pro-rata share of the reserve, the floor with no fee, into an escrow only that holder can withdraw via withdrawPayout(to). Holders keep their tokens, nobody else's floor moves, and clearing the last credit empties the reserve so the opening price can be set again. reserve() now excludes the escrow.

    The invariant suite caught a self-transfer double-count in my first draft of the new credit-transfer check. That is fixed and has a regression test.

    Finding 2 (info, floor display between epochs): disputed. The behaviour reproduces but is the documented design. Carrying the final floor into the next opening was the ratchet removed last round, and no holder exists when the displayed value changes.

    Finding 3 (info, README manifest wording): fixed. The constructor table and Deployment section now state the static owner address and that the manifest assignment writes launch.json.

    Verification, with Foundry 1.5.1 and solc 0.8.26 downloaded into the scratch directory:

    CheckResult
    Reviewer's proof (copied under test/scratch)2 passed
    forge test with the verifier's fuzz seed80 unit/fuzz + 6 invariants passed
    forge test with a random seedall passed
    forge build --force, forge fmt --checkclean, contract 8,095 bytes

    Files changed: the contract, both test files, the reentrancy mock, the regenerated ABI export at docs/abi/RisingFloorVault.json, the README, and .imd-responses.json answering all three findings.

    One limit remains and is documented in the README: a holder with capital can still raise the floor by donating or by buying dust and letting others' fees accrue to it. That now costs locked capital rather than nothing, and forceRedeem clears it. If the owner renounces ownership, that remedy goes away with the rest of the configuration.

    ran onclaude · claude-fable-5-1 · 95 turns · 20m 32s · 1.1K in · 86.6K out · 4.3M cached
    submissiond5ad347255ae3390bad67c414bea5d800cf7cc9f62e810405fb5a15d29fdd31a
    device803eeae3d8b63de342abf5c1621b903ebc38796b2e6904b6b88e609c2a9d8dc5
    started fromcd2745c4230e631789d69587aea50efaf2ffd120
    bundlee3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed · 531 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 6 files
    README.mddocs/abi/RisingFloorVault.jsonsrc/RisingFloorVault.soltest/RisingFloorVault.invariant.t.soltest/RisingFloorVault.t.soltest/mocks/Actors.sol
  14. ManifestAgent #18761 file changed

    The manifest is verified and updated. Only launch.json changed.

    What I checked

    • The accepted source in src/RisingFloorVault.sol still has a single constructor parameter, the owner address. The ABI export in docs/abi agrees.
    • The brief states the owner wallet explicitly, so the manifest keeps it as the static address rather than $owner. The factory receives no privileges.
    • The manifest has exactly kind, contracts and notes, one contract entry with one string argument, and notes under the 4000-character limit.
    • forge build succeeds and forge test with the verifier's fuzz seed passes all 81 tests.

    What I changed

    The other contributor's revision added an owner privilege, forced redemption at full floor value into per-holder escrow. The notes now list the owner's privileges, including that one, and state what the owner cannot do: withdraw reserve ETH or pause redemptions. The contract entry and constructor argument are unchanged.

    Next

    Services take this manifest through publication, attestation, admission and deployment on Ethereum mainnet.

    ran onclaude · claude-fable-5-1 · 12 turns · 1m 37s · 226 in · 4.1K out · 250K cached
    submission96ab4b9258ae1c1af5fa2cd2efd82316fca684c30872ccf3551abf2a007e7927
    device03845cacb54c3a737bb490638adf9db97b70c1ddeedd2fd50a31e67223e19cea
    started from6ae1f3224dea6e7442172a78e53d4d0959400233
    bundle01d3b5c42ce5d09635567c54f1a41fb58fdf6add8e03893880f9707b88ffb909 · 533 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied one3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed
    changed · 1 file
    launch.json
    may write
    launch.json
  15. Write foundry testsAgent #1942 files changed

    The revision is complete and verified. Summary of what I found and did:

    Why the suite was failing. The implementation was revised after my previous round. It added a dust rule (MIN_POSITION_VALUE, PositionTooSmall), a donation minimum (ReserveBelowMinimum), owner-driven forceRedeem into an escrow, and withdrawPayout. Five of my fuzz tests and all seven sequence invariants failed on those new refusals.

    What changed in test/RisingFloorVault.edges.t.sol (67 tests, up from 51):

    • The five failing fuzz tests now predict the exact PositionTooSmall revert and check that a refused call moved nothing, instead of skipping those inputs.
    • New boundary tests for the dust rule: exactly the minimum passes and one unit less fails, for partial sales, top-ups of a dust recipient, whole dust positions, and self-transfers.
    • New escrow tests: a duplicate holder in the list reverts the whole call atomically, all holders in one call split exactly the reserve, escrow is excluded from floor, ask, bid, quotes and the next epoch's opening, a forced redemption that drains the reserve under the minimum blocks donations and blocks a payout routed back into the vault, a payout routed into the vault while supply is outstanding becomes a donation, re-entry from the payout hook into withdrawPayout or buy is blocked, and forceRedeem is refused to the pending owner, holders, and the old owner after renouncing.
    • A fuzz property that every wei paid in comes back out across a forced redemption, the remaining holders' exits, and the escrow withdrawal.

    What changed in test/RisingFloorVault.sequences.invariant.t.sol: the handler now predicts the dust and donation-minimum refusals, and drives forceRedeem over random subsets of holders (asserting pro-rata payouts in list order and whole-call refusal on any holder without credit) and withdrawPayout to random recipients. ETH conservation now covers the escrow, and token conservation accounts for tokens whose credit was ended.

    Results. All 149 tests pass with the verifier's seed and with a random seed. The sequence run made 8192 calls with zero unpredicted reverts, including 403 forced redemptions and 415 escrow withdrawals. forge build and forge fmt --check are clean.

    Findings. I found no defect in the revised implementation that had to be tested around, so no findings file was written. One quirk is documented in a test rather than reported: a self-transfer of credit is refused when the remainder between debit and credit would be dust, even though it would change nothing. It is harmless and consistent with the stated rule.

    The changes are in the working tree and uncommitted, as the brief did not ask for a commit.

    ran onclaude · claude-fable-5-1 · 50 turns · 12m 16s · 898 in · 51.7K out · 3.3M cached
    submissionc35ab17d924ed5a11063af3087ee4411d8d5630b3ba4223683a88a71aca2e15b
    devicef5666f1d1aa756784a8b11cb24c6b11ba1d751dca015f7dc74292aa3a4c4cb5a
    started fromb8eba09ae955bf86f6a186d61aed23d379c6c729
    bundle675701250ac528deb94d52baad28f0b09c8d8ff160a0f3e0bbb9dda51580fd73 · 562 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied one3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed
    changed · 2 files
    test/RisingFloorVault.edges.t.soltest/RisingFloorVault.sequences.invariant.t.sol
    may write
    testtest/**
  16. Audit judgeAgent #999found 1 medium

    judge findings unresolved after 2 revisions: no revision budget left for build_contract_project (2 revisions, 2 from the judge) — Reserve is address(this).balance, so ETH forced in without a call bypasses the new ReserveBelowMinimum guard and pins the floor on a dust position for gas only; the owner's forceRedeem clears it but h

    The review is written to .imd-findings.json and no tracked file changed.

    Settlement of last round

    • High, dust pin (id 7245387d…): fixed. My proof now passes on the revised tree. A partial redemption or credit transfer can no longer leave a remainder below minRemainingCredit(), and the owner has a working remedy in forceRedeem, which pays each listed holder its exact pro-rata share into an escrow only that holder can withdraw. I traced the escrow accounting, the pro-rata rounding (others' floor never falls), and the self-transfer fix.
    • Info, floor falls back to openingPrice between epochs: dropped. The author's dispute holds. Only owner-set prices land in minOpeningPrice, nobody holds a backed token at the moment the displayed value changes, and the README and a test document the behaviour as intended.
    • Info, README still said $owner: fixed. The constructor table and Deployment section now state the static address the brief names, and correctly say the manifest step writes launch.json. The merged launch.json has only the three schema keys, one contract, one static constructor argument matching the brief, and notes consistent with the code.

    One new finding, medium. The donation guard added in the fix is bypassable. receive() refuses ETH against a reserve below the minimum, but the reserve is address(this).balance, so ETH forced in through a selfdestruct is counted with no guard. A griefer buys 1000 wei of tokens at the opening price, forces in 1e6 wei, and the ask jumps to about 1000 times the opening price. Their ETH is fully recoverable, so the cost is gas. The owner can clear it with forceRedeem, which escrows the ETH back to the griefer, who re-pins in the next block. No funds are lost and the floor-only-rises invariant holds, which is why the suite does not see it. The attached proof fails on this tree and passes on a copy that accounts the reserve internally instead of reading the balance. The README's claim that forced ETH harms nobody is true only when a real position is outstanding.

    Verification done

    • Project suite passes: 149 tests including 6 invariants.
    • Protected CREATE2 rehearsal passes with the manifest's constructor argument. Init code is 8,405 bytes, well under the limits, with no forbidden opcodes.
    • The exported ABI is identical to the compiled ABI.
    • All 16 entry points answered in the coverage record, 15 hold and receive() carries the finding. The static-analysis leads were all benign: the strict equalities are supply-is-zero branches, the uninitialized local is zero by default, and the reentrancy lead is a view call on the trusted token under nonReentrant.
    ran onclaude · claude-fable-5-1 · 27 turns · 7m 34s · 450 in · 31.3K out · 1.4M cached
    submissionbb94945396c14934f4fd1c477662437af64c5cb42af81b672003d1a131333f97
    device99c6d0bcc495ad613a6a5093465f2cc2d3ac6a53d90273d31b81cfc62f92c524
    started from19019ab7aa3e7f5487fdac367ee5e87547cd7a4a
    bundlenone
    applied one3e1f89ce744378e884dc3e4a457f91cb9132092fbbbf8558a6b6b35d760b8ed, 675701250ac528deb94d52baad28f0b09c8d8ff160a0f3e0bbb9dda51580fd73, 01d3b5c42ce5d09635567c54f1a41fb58fdf6add8e03893880f9707b88ffb909
    • mediumReserve is address(this).balance, so ETH forced in without a call bypasses the new ReserveBelowMinimum guard and pins the floor on a dust position for gas only; the owner's forceRedeem clears it but hsrc/RisingFloorVault.sol:471

      Residual of the previous round's high finding (dust pin, id 7245387d...). That finding is fixed in substance: sell/transferRedeemable refuse a nonzero remainder below minRemainingCredit() (the reproduction in Proof_7245387db65d now passes), and the owner has a remedy in forceRedeem.

      The donation half of the fix is incomplete: receive() (lines 156-161) refuses a donation while the reserve is below MIN_POSITION_VALUE, but the reserve is measured as address(this).balance - totalPendingPayouts, and ETH that arrives without a call (a selfdestruct beneficiary under Cancun, a block reward) is counted in it with no guard.

      README lines 161-164 state that such ETH 'benefits the credit holders' and 'nobody loses anything, so the contract does not guard against it'; that holds when a real position is outstanding, but not when the only outstanding position is dust the sender bought themselves. A griefer buys 1000 wei of tokens at the opening price (1_000_000 base units; buy has no minimum), then forces 1e6 wei in through a throwaway contract that selfdestructs in its constructor.

      The floor becomes 1.001 ETH per whole token (1001x the opening price), askPrice 1.051 ETH, and quoteBuy(1 ether) returns 0.95 of a token, so sales are stalled exactly as in the original finding while the griefer's capital is fully recoverable: as sole holder, sell(all) pays the whole reserve, and forceRedeem escrows the same amount for them.

      Per round the griefer spends one transaction of gas; the owner spends one forceRedeem transaction; and the griefer re-buys dust and re-forces in the next block (test_ownerRemedyWorksButGrieferRecoversEverythingAndCanRepeat in my scratch probe). Anyone who does buy at the pinned ask gets a sliver of a token backed at the pinned floor (no loss of backing, but a 1000x nominal price for the rest of the epoch, since the floor never falls).

      No funds are lost and the floor-only-rises invariant holds, so the invariant suite passes; it is rated medium because the vault's only function is stalled at an unprivileged actor's discretion for gas, with a remedy that costs the owner one transaction per round.

      Minimal fix that preserves the design: account the reserve internally (a storage total increased by buy and receive, decreased by sell payouts and forceRedeem escrow) and use it instead of the balance, so ETH that did not arrive through buy or receive is simply not part of the reserve (optionally sweepable by the owner, since it backs nobody).

      Verified: the attached proof fails on this tree with 'forced ETH against dust pinned the ask: 951429522858094286 < 1000000000000000000' and passes on a copy of the vault patched that way (full suite not rerun against the patch; the patch is only evidence the proof is fixable). Not a duplicate of any specialist finding: the specialists' reports predate forceRedeem and the receive() guard.

      Vault configured by OWNER: setSpreads(500,100), setOpeningPrice(1e15), setToken(T), 1_000_000e18 inventory, backedSupply == 0; quoteBuy(1 ether) == 1000e18.

      GRIEFER: buy{value: 1000}(0) -> 1_000_000 base units, reserve 1000 wei.

      GRIEFER: address(vault).call{value: 1_000_000}('') -> reverts ReserveBelowMinimum(1000, 1e16) (the guard works).

      GRIEFER: new Forcer{value: 1_000_000}(vault) where Forcer's constructor is selfdestruct(target) -> reserve() == 1_001_000, backedSupply == 1_000_000, floorPrice() == 1_001_000_000_000_000_000 (1.001 ETH per token), askPrice() == 1_051_050_000_000_000_000, quoteBuy(1 ether) == 951_429_522_858_094_286 (< 1e18).

      ALICE buy{value: 1 ether}(0) -> 951_429_522_858_094_286 base units.

      Expected: ETH that bypasses the donation guard cannot move the ask; 1 ETH still buys at least one whole token near the opening price.

      Actual: as above.

      Remedy and repeat: OWNER forceRedeem([GRIEFER]) -> backedSupply 0, reserve 0, pendingPayout(GRIEFER) == 1_001_000, quoteBuy(1 ether) == 1000e18 again; GRIEFER withdrawPayout(GRIEFER) -> balance back to exactly 1 ether; GRIEFER repeats buy{value:1000} + Forcer{value:1e6} -> quoteBuy(1 ether) < 1e18 again.

      Run: copy the proof under test/scratch/ and forge test --match-path test/scratch/Proof_ForcedEthDustPin.t.sol; it fails on this tree for the stated 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 {RisingFloorVault} from "src/RisingFloorVault.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      
      contract ProofToken is ERC20 {
          constructor() ERC20("Proof", "PRF") {
              _mint(msg.sender, 1_000_000e18);
          }
      }
      
      /// Forces ETH into a target without calling it. Under Cancun a selfdestruct in the creating
      /// transaction still destroys the account and always transfers its balance to the beneficiary.
      contract Forcer {
          constructor(address payable target) payable {
              selfdestruct(target);
          }
      }
      
      /// The ReserveBelowMinimum guard in receive() is the fix that stops a donation from pinning the
      /// floor on a dust position. ETH that arrives without a call is not guarded and is counted in the
      /// reserve (address(this).balance), so the same pin costs gas only: the griefer's ETH is fully
      /// recoverable (sole holder, full redemption pays the whole reserve, or the owner's forceRedeem
      /// escrows it for them), and the owner's remedy can be undone in the next block.
      contract ProofForcedEthDustPin is Test {
          RisingFloorVault vault;
          ProofToken token;
          address OWNER = address(0xD3f26A6Be348440110161dFD5748E7DE7B08cf69);
          address GRIEFER = address(0xBAD);
      
          function setUp() public {
              vault = new RisingFloorVault(OWNER);
              token = new ProofToken();
              vm.startPrank(OWNER);
              vault.setSpreads(500, 100);
              vault.setOpeningPrice(1e15);
              vault.setToken(address(token));
              vm.stopPrank();
              token.approve(address(vault), type(uint256).max);
              vault.depositInventory(1_000_000e18);
              vm.deal(GRIEFER, 1 ether);
          }
      
          function test_forcedEthOnADustPositionPinsTheFloorForGasOnly() public {
              assertEq(vault.quoteBuy(1 ether), 1000e18, "1 ETH buys 1000 whole tokens at the opening price");
      
              vm.startPrank(GRIEFER);
              uint256 units = vault.buy{value: 1000}(0); // 1_000_000 base units backed by 1000 wei
              assertEq(units, 1_000_000);
      
              // The guarded path refuses a donation against dust, as the fix intends.
              (bool ok,) = address(vault).call{value: 1_000_000}("");
              assertFalse(ok, "plain donation against dust is refused");
      
              // The unguarded path: the same 1e6 wei forced in without a call.
              new Forcer{value: 1_000_000}(payable(address(vault)));
              vm.stopPrank();
      
              // Expected: ETH that bypassed the guard cannot move the ask; 1 ETH still buys at least one
              // whole token. Actual on this code: floor 1.001 ETH per token (1001x the opening price),
              // quoteBuy(1 ether) = 951429522858094286 base units (0.95 of one token).
              assertGe(vault.quoteBuy(1 ether), 1e18, "forced ETH against dust pinned the ask");
          }
      }
  17. DeployedNeeds attentionfindings: 1 blocking finding(s) never resolved — audit_judge: Reserve is address(this).balance, so ETH forced in without a call bypasses the new ReserveBelowMinimum guard and pins the floor on a dust position for gas only; the owner's forceRedeem clears it but h
    rebuilt
    RisingFloorVault · verifier 0.1.0 · solc 0.8.26
    gates
    6 of 7 passed
    • provenance
    • findings
    • independent review
    • bytecode
    • manifest
    • protected invariants
    • economics
    parked
    findings: 1 blocking finding(s) never resolved — audit_judge: Reserve is address(this).balance, so ETH forced in without a call bypasses the new ReserveBelowMinimum guard and pins the floor on a dust position for gas only; the owner's forceRedeem clears it but h
    proof
    commit, attestation, manifest, tree, per-contract hashes
    repository
    identity-md-launches/launch-1977-workflow-contract-stage-context
    commit
    18dc5fad75066f209f658c74241c3afe3a22dbf7
    attestation
    1645e263a7ded121c1db611e4d1318027678f815264cbbdc81c51c4e5cbab1dc
    manifest
    8098825acf7577e78c3451db664fb336c957d5785437f11aeac71ea23eb6f2be
    constructor
    RisingFloorVault: 0xd3f26a6be348440110161dfd5748e7de7b08cf69
    tree
    74dc6c0dec8fb69111346db1aa1769552806ffdc
    compiler
    solc 0.8.26, optimizer 200 runs, reproducible
    contract
    RisingFloorVault
    src/RisingFloorVault.sol · 8373 bytes
    creation 1893d0eec703676ae6a1cd8f7b7f71cef1fa8425c8707d38be66e18d251cbc6b
    abi bdccb124b12408139601cd61bdf62ebd7de72409587e62cb0e16a8cbb250ec60
    metadata 60ae5f398a28456d10122dd474ce1e7ba085a013472855aee627aba5fe126b42
  18. Website built
  19. Website published
  20. Hosted
  21. Checked