SqueekMatch: a non-custodial, fee-free Squeek merge-partner finder and P2P trading app for Squeekchain on Sepolia.

Problem: merging needs two Squeeks of the same level; a merged Squeek earns more power than the two combined. Holders with a lone Squeek at a level ("single") cannot merge and lose that power.

CONTRACT (SqueekMatchEscrow, Sepolia, no fee, no owner, no proxy, verified on Etherscan, events for every action):

  • Ask: maker lists a wallet-held Squeek for SQK (Squeek stays in wallet with approval).
  • Bid: maker escrows SQK for any Squeek of a chosen level, optional "merge-ready" filter checked on-chain at fill.
  • Swap + difference: Squeek for Squeek, either side may add SQK.
  • create/fill/cancel for each, expiry (default 7d, max 30d), bid refunds on cancel/expiry. Level and eligibility read from the Squeek contract at fill. Reentrancy guard. Tests for all paths.

WEBSITE (static, no backend):

  1. My Singles: connect wallet or paste address; per level paired vs single Squeeks, cooldowns, deposit state, "lost power" counter in SQK/day.
  2. Chain Planner: fewest purchases that unlock the most merges (e.g. 3xL1+1xL2: buy one L1 -> L2 -> L3), with cost, power gained, cooldown time at hamster pace; one click turns a path into bids.
  3. Match Board: all holders' singles by level from on-chain events (browser eth_getLogs, IndexedDB cache), filters, fair-value tag per listing.
  4. Trade: create/fill/cancel asks, bids, swaps; my orders.
  5. Buy then Merge: after a fill, pick survivor, preview new power, sign official Squeek merge / Staking mergeDeposited.
  6. Market summary: floor ask, best bid per level, last fills.

DATA: api.squeekchain.com (/v1/accounts/{address}, /v1/squeeks/{id}, /v1/economy/state, /v1/measurement/stats, /v1/status, /v1/events) for display only; every transaction reads the contracts. Verify contract addresses against Squeekchain Deployments docs and API identities; refuse trading on mismatch.

SAFETY: Sepolia-only, eth_call dry-run before every tx, warn seller to claim unclaimed SQK first (it moves with the Squeek), explain deposited Squeeks must be withdrawn to trade, show cooldown/scheduled power change before buying, confirm merge survivor (burned Squeek is permanent), plain-language errors, testnet banner.

Docs: docs.squeekchain.com (API, Merging Squeeks, SqueekNFT, Build owner actions). Fan-made, unofficial.

Also approved

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

Published · Site

site
squeekmatch-find-your-merge-part.imd.site
ipfs
bafybeigzzqliyac73vkcsr4ozx2nzcmcceqf4vfhnp7r3faqqi5aqif6ba
website
identity-md-launches/launch-2900-workflow-frontend-stage-context

Published · Contracts

app
SqueekMatchEscrow 0xd6c8fef5721398750c2bc679d1cfe9c3ab0b43f9source verified
github
identity-md-launches/launch-2897-workflow-contract-stage-context

Work

  1. Build contract projectAgent #343415 files changedsent back

    The SqueekMatchEscrow contract stage is complete and committed. All checks pass: forge build, 92 tests across 8 suites under the verifier's fuzz seed and a second seed, and forge fmt --check.

    What was built

    • src/SqueekMatchEscrow.sol: one ownerless, fee-free contract with asks, bids and swaps, each with create, fill and cancel. Asks keep the Squeek in the maker's wallet. Bids escrow SQK for any Squeek at an exact level, with an optional merge-ready filter. Swaps trade Squeek for Squeek, pinned to a token or any Squeek at a level, with an SQK difference payable by either side. Expiry defaults to 7 days with a 30-day cap. The maker may cancel at any time and anyone may cancel after expiry, with refunds always going to the maker. Every action emits an event, every function is reentrancy guarded, and effects precede token moves.
    • Level and eligibility are read at fill from SqueekMining through the NFT's mining() pointer. Merge-ready mirrors SqueekMining's own merge precondition: live position, below the maximum level, no pending power change, cooldown reached at SqueekCore's completed block height.
    • test/: mocks of the four Squeekchain contracts, three reentrancy attackers, success and failure tests for every path, SQK conservation checks, and five fuzz tests.
    • docs/abi/SqueekMatchEscrow.json, README.md, a reviewable deploy script with a tested deploy function, and vendored forge-std 1.17.0 and OpenZeppelin 5.4.0 as plain files.

    Deployment parameters for the manifest step

    ArgumentSepolia value
    squeek_ (SqueekNFT)0x0eAF3E0d40A0a8E5a6Ac030b2459E9FB0B7905c6
    sqk_ (SqueekToken)0x6872BfA5CcC3E9e440C6c16e5fFdAbb8DBA4Cc76

    Both come from the Squeekchain Deployments page named in the brief and were verified live on Sepolia through a public RPC. The constructor makes no external call. No $owner argument exists because the brief requires no owner, and there is nothing to set after launch.

    Points for the independent review

    • Squeekchain documents its contracts as in active development. SqueekMining and SqueekCore are proxies, so a breaking change to position, collectionPolicy or state would make bid and swap fills revert while asks keep working. The README records this.
    • The brief's "deposit state" and mergeDeposited refer to a staking feature whose ABI Squeekchain has not published. A deposited Squeek is simply not owned by its holder, so fills involving it revert with a named error.
    • A scratch test replicating the protected floor (CREATE2 deploy from a factory on an empty chain plus an opcode scan) passed with a 10,459-byte runtime. Slither was not available on this machine and did not run.
    ran onclaude · claude-fable-5-1 · 43 turns · 16m 37s · 642 in · 72.7K out · 1.9M cached
    submissionc6cacc423caa72f13ca0d53f9f995bbfe1f99b593083ad9808c415632f68a290
    device7236faf97e42979a0bf46f2de9036a518eeef7f97434bb583acc0e8716523d8b
    started from0243d7da4a4337ae8b16bcdf15bb4ead736fd68f
    bundle9cf6d3e645a8bbfa5a6a07df15790e1e07e513320887bad3236f8c25db33fda3 · 548 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 415 files
    .gitignoreREADME.mddocs/abi/SqueekMatchEscrow.jsonfoundry.tomllib/forge-std/CONTRIBUTING.mdlib/forge-std/LICENSE-APACHElib/forge-std/LICENSE-MITlib/forge-std/README.mdlib/forge-std/RELEASE_CHECKLIST.mdlib/forge-std/foundry.tomllib/forge-std/package.jsonlib/forge-std/scripts/vm.pylib/forge-std/src/Base.sollib/forge-std/src/Config.sollib/forge-std/src/LibVariable.sollib/forge-std/src/Script.sollib/forge-std/src/StdAssertions.sollib/forge-std/src/StdChains.sollib/forge-std/src/StdCheats.sollib/forge-std/src/StdConfig.sollib/forge-std/src/StdConstants.sollib/forge-std/src/StdError.sollib/forge-std/src/StdInvariant.sollib/forge-std/src/StdJson.sollib/forge-std/src/StdMath.sollib/forge-std/src/StdSecp256k1.sollib/forge-std/src/StdStorage.sollib/forge-std/src/StdStyle.sollib/forge-std/src/StdToml.sollib/forge-std/src/StdUtils.sollib/forge-std/src/Test.sollib/forge-std/src/Vm.sollib/forge-std/src/console.sollib/forge-std/src/console2.sollib/forge-std/src/interfaces/IERC1155.sollib/forge-std/src/interfaces/IERC165.sollib/forge-std/src/interfaces/IERC20.sollib/forge-std/src/interfaces/IERC4626.sollib/forge-std/src/interfaces/IERC6909.sollib/forge-std/src/interfaces/IERC721.sollib/forge-std/src/interfaces/IERC7540.sollib/forge-std/src/interfaces/IERC7575.sollib/forge-std/src/interfaces/IMulticall3.sollib/forge-std/src/safeconsole.sollib/forge-std/test/CommonBase.t.sollib/forge-std/test/Config.t.sollib/forge-std/test/LibVariable.t.sollib/forge-std/test/StdAssertions.t.sollib/forge-std/test/StdChains.t.sollib/forge-std/test/StdCheats.t.sollib/forge-std/test/StdConstants.t.sollib/forge-std/test/StdError.t.sollib/forge-std/test/StdJson.t.sollib/forge-std/test/StdMath.t.sollib/forge-std/test/StdSecp256k1.t.sollib/forge-std/test/StdStorage.t.sollib/forge-std/test/StdStyle.t.sollib/forge-std/test/StdToml.t.sollib/forge-std/test/StdUtils.t.sollib/forge-std/test/Vm.t.sollib/forge-std/test/compilation/CompilationScript.sollib/forge-std/test/compilation/CompilationScriptBase.sollib/forge-std/test/compilation/CompilationStdSecp256k1.sollib/forge-std/test/compilation/CompilationTest.sollib/forge-std/test/compilation/CompilationTestBase.sollib/forge-std/test/fixtures/broadcast.log.jsonlib/forge-std/test/fixtures/config.tomllib/forge-std/test/fixtures/test.jsonlib/forge-std/test/fixtures/test.tomllib/openzeppelin-contracts/LICENSElib/openzeppelin-contracts/README.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.txtscript/DeploySqueekMatchEscrow.s.solsrc/SqueekMatchEscrow.solsrc/interfaces/ISqueekchain.soltest/DeploySqueekMatchEscrow.t.soltest/SqueekMatchEscrow.t.soltest/mocks/Attackers.soltest/mocks/SqueekchainMocks.sol
  2. ManifestAgent #5991 file changedsent back

    Created launch.json with the accepted constructor addresses.

    Manifest structure and ABI checks passed. forge build succeeded; all 92 tests passed with the supplied fuzz seed. No other submission files changed.

    Notes flag an address mismatch between the live API and official deployment record, requiring resolution before trading.

    ran oncodex · gpt-6-astra · 4 turns · 2m 0s · 45.3K in · 4.9K out · 419.6K cached
    submissioncd55a650a63521f5e086426a36e933ae1f29a1eb86a82cdb9d47b01732085508
    device665adaf4bb30b4d1e84a6fd61a7dab08e170f437d96914cc2de0554abe2f494f
    started from77e69dbf4a43a16dc6f82ace41f13b27582778bd
    bundle2d17b338dbac747228d2427f0c64a6a4f921c2091ba255460ab6798035f64e55 · 549 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on2de4afa0809c7a0ee0c3a4526a85760767168fa6961c13d5f2ae2b952a749c81
    changed · 1 file
    launch.json
    may write
    launch.json
  3. Audit mathAgent #1182found 1 medium, 1 info

    I found one medium-severity defect and one informational item, and wrote them to .imd-findings.json with a coverage row for each of the 9 entry points. The existing test suite passes (92 tests). No repository files were changed; the scratch test directory has been deleted.

    Findings

    1. Medium: old asks and swaps come back to life when the Squeek returns to the maker (src/SqueekMatchEscrow.sol:219, and the same check at :372). At fill time the contract only checks who owns the Squeek now. When the Squeek leaves the maker's wallet, any other order for it stays open for up to 30 days. If the Squeek later comes back, anyone can fill that old order at its old terms.

      • Example: Alice lists Squeek 11 at 1 SQK and again at 100 SQK. Bob fills the 100 SQK ask. Two days later Alice buys 11 back from Bob. Carol then fills Alice's forgotten 1 SQK ask, and Alice loses the Squeek for 1 SQK.
      • Broken promise: the README says "only one fill can succeed" when a Squeek is listed twice.
      • Proof: an attached Foundry test fails on the current code for exactly this reason.
      • Suggested fix: tie each order to the ownership period it was created in, for example with a per-maker, per-token counter that each fill increases.
    2. Info: bids and swaps can be created at levels that can never fill (:255, :327). Only level 0 is rejected. On Sepolia the maximum level is 10, so a bid for level 11 or higher can never match. A merge-ready bid or swap at level 10 always reverts at fill. Nobody loses funds, because the maker can cancel at any time, but the SQK sits in an order that looks open.

    What I checked and found sound

    • Matches the live contracts: I read the verified SqueekMining source on Sepolia. The escrow's merge-ready check matches what merge() actually requires:
      • no scheduled power change;
      • cooldown block reached (blockHeight < nextMergeBlock is the same threshold);
      • below the maximum level.
    • No surprises from the other contracts:
      • transferring a Squeek never calls into SqueekMining, so a Squeek that was ready before the fill is still ready after it;
      • token id 0 is never minted, so it is safe as the "any Squeek" value;
      • SQK is a plain capped ERC20 with no transfer fee.
    • Data reads: the escrow's interface structs decode the live Sepolia return values correctly.
    • Expiry: fills work strictly before expiresAt and cancelling by anyone is allowed from expiresAt on, with no gap or overlap. A 30-day duration is accepted, and the expiry value cannot overflow.
    • Escrowed SQK: each amount enters once at creation and leaves once, through either a fill or a cancel.

    Coverage: the five entry points named in the findings (createAsk, createBid, createSwap, fillAsk, fillSwap) are marked as findings. The other four (fillBid, cancelAsk, cancelBid, cancelSwap) hold. None were left unreached.

    Static-analysis leads: I rejected all of them.

    • Slither's warnings about the strict equality and timestamp comparisons describe intended behaviour.
    • Aderyn's reentrancy warning at line 219 points at a read-only ownerOf call inside a reentrancy-guarded function, and the order's status is set before any token moves.

    I couldn't read the Squeekchain docs site (it blocks automated requests), so the merge-rule comparison rests on the verified on-chain source instead.

    ran onclaude · claude-opus-5-5 · 29 turns · 4m 10s · 52 in · 21.1K out · 1.5M cached
    submission50a21a00cf549471638c37e6e56b347d27fa930c36b4ffed0ec452e918d367b9
    device3c9b660ee1938912fe6a358aa40bd92886ab34dc995194a468090e3c1fc25f05
    started from77e69dbf4a43a16dc6f82ace41f13b27582778bd
    bundlenone
    applied on2de4afa0809c7a0ee0c3a4526a85760767168fa6961c13d5f2ae2b952a749c81
    • mediumAsks and swaps never become void when the Squeek leaves the maker; a stale order is filled at its old terms once the Squeek returnssrc/SqueekMatchEscrow.sol:219

      An ask (and likewise a swap, line 372) is only checked against the CURRENT owner and approval at fill. When the listed Squeek leaves the maker's wallet through any other path (a second ask of the same token, fillBid, fillSwap, a direct transfer), the other orders for that token stay Status.Open for up to 30 days. When the Squeek comes back to the maker, they become fillable again.

      The maker still has setApprovalForAll from trading, and the README expects makers to use it. Anyone can then take the Squeek at the stale price, or in the stale swap, without the maker acting. This breaks the README guarantee that 'if a maker lists the same Squeek twice, only one fill can succeed'.

      Here two fills succeed on the same listing set. It is the dormant-listing pattern that cost NFT marketplace users real assets.

      Root cause: an order is not bound to the ownership period in which it was created.

      Alice owns Squeek 11, has setApprovalForAll(escrow,true) and approved SQK.

      1. alice createAsk(11, 1e18, 0) -> ask 1; alice createAsk(11, 100e18, 0) -> ask 2.

      2. bob fillAsk(2): bob pays 100 SQK and gets 11.

      Ask 1 is still Open.

      1. 2 days later bob createAsk(11, 100e18, 0) -> ask 3; alice fillAsk(3), so alice owns 11 again.

      2. carol fillAsk(1).

      Expected: revert, because the 1-SQK listing was consumed when 11 was sold.

      Actual: it succeeds; carol gets Squeek 11 for 1 SQK and alice loses 99 SQK of value.

      The same sequence works with createSwap/fillSwap on makerTokenId (line 372 performs the same owner-only check).

      Fix: bind each order to the ownership period it was created in.

      For example, keep a per-(maker, tokenId) nonce that fillAsk/fillSwap/fillBid increment for the outgoing token, store it in Ask/Swap at creation, and require it to match at fill.

      Alternatively, have the website cancel sibling orders, but the contract guarantee in the README needs the on-chain check.

      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 {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
      import {SqueekMatchEscrow} from "src/SqueekMatchEscrow.sol";
      
      contract SQK is ERC20 {
          constructor() ERC20("SQK", "SQK") {}
      
          function mint(address to, uint256 a) external {
              _mint(to, a);
          }
      }
      
      contract NFT is ERC721 {
          address public mining;
      
          constructor() ERC721("Squeek", "SQUEEK") {}
      
          function mint(address to, uint256 id) external {
              _mint(to, id);
          }
      }
      
      contract StaleAskTest is Test {
          SQK sqk;
          NFT nft;
          SqueekMatchEscrow escrow;
          address alice = makeAddr("alice");
          address bob = makeAddr("bob");
          address carol = makeAddr("carol");
          uint256 constant A1 = 11;
      
          function setUp() public {
              sqk = new SQK();
              nft = new NFT();
              escrow = new SqueekMatchEscrow(address(nft), address(sqk));
              nft.mint(alice, A1);
              address[3] memory users = [alice, bob, carol];
              for (uint256 i; i < 3; ++i) {
                  sqk.mint(users[i], 1_000 ether);
                  vm.startPrank(users[i]);
                  sqk.approve(address(escrow), type(uint256).max);
                  nft.setApprovalForAll(address(escrow), true);
                  vm.stopPrank();
              }
          }
      
          function test_staleAskRevivesWhenSqueekReturnsToMaker() public {
              vm.startPrank(alice);
              uint256 cheap = escrow.createAsk(A1, 1 ether, 0);
              uint256 dear = escrow.createAsk(A1, 100 ether, 0);
              vm.stopPrank();
      
              vm.prank(bob);
              escrow.fillAsk(dear);
      
              vm.warp(block.timestamp + 2 days);
              vm.prank(bob);
              uint256 back = escrow.createAsk(A1, 100 ether, 0);
              vm.prank(alice);
              escrow.fillAsk(back);
              assertEq(nft.ownerOf(A1), alice);
      
              vm.prank(carol);
              (bool ok,) = address(escrow).call(abi.encodeCall(SqueekMatchEscrow.fillAsk, (cheap)));
              assertFalse(ok, "stale 1-SQK ask filled after the Squeek returned to its maker");
              assertEq(nft.ownerOf(A1), alice, "alice lost A1 at the stale price");
          }
      }
    • infocreateBid/createSwap accept levels that can never fill (level > maximumLevel, or mergeReady at maximumLevel), locking SQK until the maker cancelssrc/SqueekMatchEscrow.sol:255

      The NatSpec says level is '1..maximum level', but only the lower bound is enforced (createBid line 255, createSwap line 327). On Sepolia, collectionPolicy(0).maximumLevel is 10. A bid for level 11..255 can never match a position.

      A mergeReady bid or swap at level 10 always reverts AtMaximumLevel at fill (line 486). Escrowed SQK stays in an order that looks open on the Match Board until the maker cancels or someone cancels it after expiry. The maker can always cancel, so no funds are lost.

      This is a boundary-validation gap, not a loss.

      alice createBid(11, false, 100e18, 30 days) succeeds and escrows 100 SQK.

      Every fillBid(id, t) reverts with LevelMismatch, because no Squeek exceeds level 10.

      Likewise createBid(10, true, 100e18, 0) succeeds, but every fillBid with a level-10 Squeek reverts AtMaximumLevel.

      Expected: creation reverts InvalidLevel.

      Actual: an unfillable order holds the SQK until cancelBid.

      Fix: in createBid/createSwap, read mining().collectionPolicy(0).maximumLevel and require level <= max (and level < max when mergeReady).

      Creation runs after deployment, so this does not affect the empty-chain constructor rule.

  4. Audit flowAgent #401found 2 medium

    I found two medium-severity defects in SqueekMatchEscrow. Both are written to .imd-findings.json, each with a Foundry proof. Each proof fails on the current code and passes once a minimal fix is applied. I tried that fix in a separate copy under /tmp, so no repository file was changed. Both defects come from the same gap: an ask or swap records only the maker and the token id, and a fill re-checks only that the maker still owns the token and has approved the escrow.

    Finding 1 (medium): a merged-up Squeek sells at its old price (src/SqueekMatchEscrow.sol:218, swap version at :371-373)

    • What happens: Alice lists her level-1 Squeek A1 for 1 SQK. She then buys a level-1 partner through her own merge-ready bid and merges, keeping A1, which becomes level 2. Bob can still fill the old ask and gets the level-2 Squeek for 1 SQK.
    • Swap version: the same happens with a swap. Someone who brings a level-1 Squeek receives Alice's level-2.
    • Why it's likely: this is the app's main flow. Merging and cancelling are separate transactions, and Alice's approval for all her Squeeks stays on because every trade needs it.
    • Fix: store the Squeek's level when the order is created and require it to be unchanged at fill.

    Finding 2 (medium): an old order comes back to life when the Squeek returns (src/SqueekMatchEscrow.sol:219)

    • What happens: Alice lists A1 for 1 SQK, sells it into Bob's 5 SQK bid, then buys it back from Bob's 6 SQK ask. Her forgotten 1 SQK ask is fillable again, and Carol takes A1 for 1 SQK.
    • README conflict: the README says a superseded listing "reverts with MakerNoLongerOwns until it is cancelled". That only holds while the token is away from the maker.
    • Fix: keep a per-token counter that goes up every time the escrow moves the token, and reject orders created under an older value. A transfer outside the escrow still needs the maker to cancel, and the README should say so.

    Coverage: the record has a row for all 9 entry points plus 3 invariant and static-analysis rows.

    • Findings: createAsk, fillAsk, createSwap and fillSwap.
    • Holds:
      • The three cancels: each refunds exactly once, only to the maker, and only the maker can cancel before expiry.
      • createBid and fillBid: checked against the published SqueekMining merge rules. The merge-ready check matches what a merge requires: no pending power change, below the maximum level, and the cooldown block reached. Transfers don't reset a Squeek's position.
    • Static-analysis leads, both rejected:
      • Aderyn reentrancy at line 219: it's a read-only ownerOf call, every function has the reentrancy guard, and order status is set before any token moves.
      • Slither strict equality at line 451: it's an intended order-status comparison.
    • Checks that held: escrowed SQK always equals the open bid amounts plus the makers' swap payments, and expiry boundaries are consistent between fill and cancel.

    Not covered:

    • The real behaviour of the Sepolia SqueekMining contract. I checked the docs, not the deployed code.
    • Whether a merged Squeek's pending power change shows up the way the docs describe.
    • launch.json, which doesn't exist yet at this stage. That isn't a defect.

    The scratch tests, the patched copy and the forge build output have all been removed. Only .imd-findings.json is new, and it's untracked.

    ran onclaude · claude-opus-5-5 · 18 turns · 4m 53s · 30 in · 25.1K out · 995.9K cached
    submissionb09a29e983734b0750706939437c2dc39bd23b2219d442292a0d62d72879cbdf
    devicef2d7eadae2a3cfdff82a4b65aa5db46e945ffbaa6a780401de9ed25dc899b844
    started from77e69dbf4a43a16dc6f82ace41f13b27582778bd
    bundlenone
    applied on2de4afa0809c7a0ee0c3a4526a85760767168fa6961c13d5f2ae2b952a749c81
    • mediumAsk/swap listing is bound only to the token id, so after the maker merges the listed Squeek up a level the stale order sells the upgraded Squeek at the old pricesrc/SqueekMatchEscrow.sol:218

      createAsk stores (maker, tokenId, price, expiresAt) and createSwap stores makerTokenId; neither records the listed Squeek's level. At fill, fillAsk (lines 218-221) and fillSwap (lines 371-373) only re-check that the maker still owns and has approved the token. They never re-read the listed token's own level, even though the contract and README promise that 'level and eligibility are read live at fill'.

      A merge keeps the survivor's id and raises its level (the brief's 'Buy then Merge' flow: pick a survivor, the other Squeek is burned). If the listed Squeek is the survivor, the open ask or swap now offers a level-(L+1) Squeek on level-L terms. The maker keeps setApprovalForAll on the escrow because every trade needs it, so any watcher can fill the order.

      This is a likely path in this app: a holder lists their lone single, then finds a partner through their own mergeReady bid (the main purpose of the app) and merges right away. Merge and cancel are separate transactions, so the order stays fillable at least until the cancel lands, and for up to 30 days if the maker forgets it.

      Assumption violated: the listing describes the asset at the level it had when listed.

      Fix: store the token's level (read from mining().position(0, tokenId)) in Ask and Swap at creation, and in fillAsk and fillSwap require the live level of tokenId / makerTokenId to equal it, reverting with LevelMismatch otherwise.

      Setup: alice owns A1 at level 1 and has setApprovalForAll(escrow, true).

      1. alice createAsk(A1, 1e18, 30 days) -> askId.

      2. alice createBid(1, true, 10e18, 0); carol fillBid(bidId, C1) (C1 level 1 goes to alice).

      3. alice merges with A1 as survivor and C1 burned: SqueekMining now reports A1 at level 2, with nextMergeBlock raised.

      4. bob fillAsk(askId).

      Expected: revert, because the listing was for a level-1 Squeek.

      Actual: succeeds; bob pays 1 SQK and receives the level-2 A1.

      The swap version is the same: alice createSwap(A1, 0, 1, false, 0, 0, 30 days), merges A1 to level 2, then bob fillSwap(swapId, B1) with a level-1 B1 receives the level-2 A1.

      Proof: both tests fail on the current tree ('next call did not revert as expected').

      Both pass when the level is stored at creation and checked at fill.

      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 {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
      import {SqueekMatchEscrow} from "src/SqueekMatchEscrow.sol";
      import {ISqueekCore, ISqueekMining} from "src/interfaces/ISqueekchain.sol";
      
      contract PSqk is ERC20 {
          constructor() ERC20("Squeek Token", "SQK") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      contract PCore is ISqueekCore {
          function state() external pure returns (CoreState memory s) {
              s.blockHeight = 100;
          }
      }
      
      contract PMining is ISqueekMining {
          address public immutable core;
          mapping(uint256 => MiningPosition) internal positions;
      
          constructor(address core_) {
              core = core_;
          }
      
          function setPosition(uint256 tokenId, uint8 level, uint48 nextMergeBlock) external {
              positions[tokenId].level = level;
              positions[tokenId].activeWeight = uint64(level) * 1000;
              positions[tokenId].nextMergeBlock = nextMergeBlock;
          }
      
          function clearPosition(uint256 tokenId) external {
              delete positions[tokenId];
          }
      
          function position(uint8, uint256 tokenId) external view returns (MiningPosition memory) {
              return positions[tokenId];
          }
      
          function collectionPolicy(uint8) external pure returns (MiningCollectionPolicy memory policy) {
              policy.maximumLevel = 10;
          }
      }
      
      contract PNft is ERC721 {
          address public immutable mining;
      
          constructor(address mining_) ERC721("Squeek", "SQUEEK") {
              mining = mining_;
          }
      
          function mint(address to, uint256 tokenId) external {
              _mint(to, tokenId);
          }
      
          function burn(uint256 tokenId) external {
              _burn(tokenId);
          }
      }
      
      contract MergedListingTest is Test {
          PSqk sqk;
          PMining mining;
          PNft nft;
          SqueekMatchEscrow escrow;
      
          address alice = makeAddr("alice");
          address bob = makeAddr("bob");
          address carol = makeAddr("carol");
      
          uint256 constant A1 = 11; // alice's lone level-1 Squeek, listed for sale
          uint256 constant B1 = 21; // bob's level-1 Squeek
          uint256 constant C1 = 31; // carol's level-1 Squeek
      
          function setUp() public {
              vm.warp(1_800_000_000);
              sqk = new PSqk();
              mining = new PMining(address(new PCore()));
              nft = new PNft(address(mining));
              escrow = new SqueekMatchEscrow(address(nft), address(sqk));
      
              _mint(alice, A1);
              _mint(bob, B1);
              _mint(carol, C1);
              address[3] memory users = [alice, bob, carol];
              for (uint256 i; i < 3; ++i) {
                  sqk.mint(users[i], 1_000 ether);
                  vm.startPrank(users[i]);
                  sqk.approve(address(escrow), type(uint256).max);
                  nft.setApprovalForAll(address(escrow), true);
                  vm.stopPrank();
              }
          }
      
          function _mint(address to, uint256 id) internal {
              nft.mint(to, id);
              mining.setPosition(id, 1, 0);
          }
      
          /// Alice lists her level-1 single A1 for 1 SQK, then buys Carol's L1 through the escrow and merges it
          /// into A1 (A1 survives at level 2, C1 is burned). The 1 SQK listing was for a level-1 Squeek; it must not
          /// sell the level-2 survivor.
          function test_askListedAtLevel1_isNotFillableAfterMakerMergedItToLevel2() public {
              vm.prank(alice);
              uint256 askId = escrow.createAsk(A1, 1 ether, 30 days);
      
              // Alice finds a partner: Carol sells C1 into Alice's L1 bid.
              vm.prank(alice);
              uint256 bidId = escrow.createBid(1, true, 10 ether, 0);
              vm.prank(carol);
              escrow.fillBid(bidId, C1);
              assertEq(nft.ownerOf(C1), alice);
      
              // SqueekMining.merge(A1 survivor, C1 burned): A1 becomes level 2 with a new cooldown.
              nft.burn(C1);
              mining.clearPosition(C1);
              mining.setPosition(A1, 2, 104);
      
              // Anyone watching fills the stale level-1 ask: the level-2 Squeek leaves for 1 SQK.
              vm.expectRevert();
              vm.prank(bob);
              escrow.fillAsk(askId);
              assertEq(nft.ownerOf(A1), alice, "upgraded Squeek sold at the old level-1 price");
          }
      
          /// Same root cause through a swap: Alice offers A1 (L1) for any L1; after she merges A1 to L2 the swap
          /// hands the L2 to whoever brings an L1.
          function test_swapOfferingLevel1_isNotFillableAfterMakerMergedItToLevel2() public {
              vm.prank(alice);
              uint256 swapId = escrow.createSwap(A1, 0, 1, false, 0, 0, 30 days);
      
              vm.prank(alice);
              uint256 bidId = escrow.createBid(1, true, 10 ether, 0);
              vm.prank(carol);
              escrow.fillBid(bidId, C1);
              nft.burn(C1);
              mining.clearPosition(C1);
              mining.setPosition(A1, 2, 104);
      
              vm.expectRevert();
              vm.prank(bob);
              escrow.fillSwap(swapId, B1);
              assertEq(nft.ownerOf(A1), alice, "level-2 Squeek swapped for a level-1");
          }
      }
    • mediumAn ask or swap left open while its Squeek is away becomes fillable again at the old price when the Squeek returns to the makersrc/SqueekMatchEscrow.sol:219

      The only thing that makes an ask stale is SQUEEK.ownerOf(tokenId) != maker (line 219; line 372 for swaps). It is a condition checked at fill time, not a state change: the order stays Open. If the maker sells or swaps the Squeek away through another order (or outside the escrow) without cancelling, the old order merely pauses.

      It comes back to life as soon as the maker owns the token again. The maker's escrow approval is still set, because the earlier fillBid/fillSwap that moved the Squeek out required it. Any watcher then fills the forgotten order at its old price.

      The README states the opposite guarantee: 'only one fill can succeed and the other listing reverts with MakerNoLongerOwns until it is cancelled'. That holds only while the maker does not hold the token. In a merge-partner market, buying back the same Squeek (or getting it back through a swap) is a reachable state.

      Assumption violated: a listing whose token left the maker is dead.

      Fix: keep a per-token generation counter that every escrow fill moving the token increments, store it in Ask/Swap at creation, and require it to be unchanged at fill. This covers every route the escrow itself mediates. A transfer outside the escrow still needs the maker to cancel, which the README should then say.

      Setup: alice owns A1 (level 1). alice, bob and carol have approved the escrow for SQK and Squeeks.

      1. alice createAsk(A1, 1e18, 30 days) -> oldAsk.

      2. bob createBid(1, false, 5e18, 0); alice fillBid(bobBid, A1): A1 goes to bob, alice gets 5 SQK. carol fillAsk(oldAsk) now reverts MakerNoLongerOwns, as documented.

      3. bob createAsk(A1, 6e18, 0); alice fillAsk(bobAsk): A1 is back with alice.

      4. carol fillAsk(oldAsk).

      Expected: revert, because the listing was superseded when A1 left.

      Actual: succeeds; carol takes A1 for 1 SQK that alice just paid 6 SQK for.

      Proof: the test fails on the current tree and passes with the generation-counter fix.

      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 {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
      import {SqueekMatchEscrow} from "src/SqueekMatchEscrow.sol";
      import {ISqueekCore, ISqueekMining} from "src/interfaces/ISqueekchain.sol";
      
      contract PSqk is ERC20 {
          constructor() ERC20("Squeek Token", "SQK") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      contract PCore is ISqueekCore {
          function state() external pure returns (CoreState memory s) {
              s.blockHeight = 100;
          }
      }
      
      contract PMining is ISqueekMining {
          address public immutable core;
          mapping(uint256 => MiningPosition) internal positions;
      
          constructor(address core_) {
              core = core_;
          }
      
          function setPosition(uint256 tokenId, uint8 level, uint48 nextMergeBlock) external {
              positions[tokenId].level = level;
              positions[tokenId].activeWeight = uint64(level) * 1000;
              positions[tokenId].nextMergeBlock = nextMergeBlock;
          }
      
          function clearPosition(uint256 tokenId) external {
              delete positions[tokenId];
          }
      
          function position(uint8, uint256 tokenId) external view returns (MiningPosition memory) {
              return positions[tokenId];
          }
      
          function collectionPolicy(uint8) external pure returns (MiningCollectionPolicy memory policy) {
              policy.maximumLevel = 10;
          }
      }
      
      contract PNft is ERC721 {
          address public immutable mining;
      
          constructor(address mining_) ERC721("Squeek", "SQUEEK") {
              mining = mining_;
          }
      
          function mint(address to, uint256 tokenId) external {
              _mint(to, tokenId);
          }
      
          function burn(uint256 tokenId) external {
              _burn(tokenId);
          }
      }
      
      contract RevivedAskTest is Test {
          PSqk sqk;
          PMining mining;
          PNft nft;
          SqueekMatchEscrow escrow;
      
          address alice = makeAddr("alice");
          address bob = makeAddr("bob");
          address carol = makeAddr("carol");
      
          uint256 constant A1 = 11; // alice's lone level-1 Squeek, listed for sale
          uint256 constant B1 = 21; // bob's level-1 Squeek
          uint256 constant C1 = 31; // carol's level-1 Squeek
      
          function setUp() public {
              vm.warp(1_800_000_000);
              sqk = new PSqk();
              mining = new PMining(address(new PCore()));
              nft = new PNft(address(mining));
              escrow = new SqueekMatchEscrow(address(nft), address(sqk));
      
              _mint(alice, A1);
              _mint(bob, B1);
              _mint(carol, C1);
              address[3] memory users = [alice, bob, carol];
              for (uint256 i; i < 3; ++i) {
                  sqk.mint(users[i], 1_000 ether);
                  vm.startPrank(users[i]);
                  sqk.approve(address(escrow), type(uint256).max);
                  nft.setApprovalForAll(address(escrow), true);
                  vm.stopPrank();
              }
          }
      
          function _mint(address to, uint256 id) internal {
              nft.mint(to, id);
              mining.setPosition(id, 1, 0);
          }
      
          /// Alice lists A1 for 1 SQK, sells A1 into Bob's 5 SQK bid, then buys A1 back from Bob's 6 SQK ask.
          /// Her first 1 SQK listing comes back to life and Carol takes A1 for 1 SQK.
          function test_askIsNotRevivedWhenTheSqueekReturnsToTheMaker() public {
              vm.prank(alice);
              uint256 oldAsk = escrow.createAsk(A1, 1 ether, 30 days);
      
              vm.prank(bob);
              uint256 bobBid = escrow.createBid(1, false, 5 ether, 0);
              vm.prank(alice);
              escrow.fillBid(bobBid, A1);
              assertEq(nft.ownerOf(A1), bob);
      
              // While A1 is away the old listing reverts with MakerNoLongerOwns, as the README documents.
              vm.expectRevert(abi.encodeWithSelector(SqueekMatchEscrow.MakerNoLongerOwns.selector, oldAsk, A1));
              vm.prank(carol);
              escrow.fillAsk(oldAsk);
      
              vm.prank(bob);
              uint256 bobAsk = escrow.createAsk(A1, 6 ether, 0);
              vm.prank(alice);
              escrow.fillAsk(bobAsk);
              assertEq(nft.ownerOf(A1), alice);
      
              vm.expectRevert();
              vm.prank(carol);
              escrow.fillAsk(oldAsk);
              assertEq(nft.ownerOf(A1), alice, "listing revived after the Squeek came back");
          }
      }
  5. Audit permissionsAgent #1030found 1 low

    The review is complete. The findings file is valid, the snippet matches the tree at the cited line, all nine entry points have a coverage row, and the scratch test is removed. The working tree holds no changes beyond the findings file, which is ignored by git.

    Outcome. The permission model of SqueekMatchEscrow holds up. There is no owner, initializer, proxy or privileged path, every state-changing entry point is reentrancy guarded, and every token movement is bounded to the parties of the order being filled or cancelled. I report one low-severity asymmetry and no permission bypass.

    What I checked across the three guides

    • Access Control: every writer of each order's status, the maker-or-expired cancel gate, the self-trade gate, and the confused-deputy question of whether the escrow's Squeek or SQK approvals can be spent through someone else's order. They cannot: fills only move the stored maker's listed token and the caller's own token, and SQK is only ever pulled from the caller.
    • Asymmetry: create versus fill versus cancel for all three kinds, the fill settlement order under maker and taker receive hooks, and the view versus fill expiry comparisons. A scratch test confirmed a swap maker that moves its offered Squeek away inside the hook reverts the whole fill.
    • Trust Gap: the merge-ready gate against the live Sepolia SqueekMining implementation fetched from Sourcify. Its merge requires exactly what the escrow enforces for the delivered Squeek, SqueekNFT transfers never call Mining, and SQK is a plain capped ERC-20. The live ABI word layouts of position, collectionPolicy and state match the interface.

    The finding, low severity. Fills check the taker's Squeek live for level and merge readiness, but never re-read the maker's listed Squeek in an ask or the maker's offered Squeek in a swap. A maker can merge that Squeek up as a survivor after creating the order, which raises its level and sets a pending round and cooldown, and an incoming fill still succeeds. The taker loses no SQK and receives a higher-level token, but not the merge partner the trade was for. The reproduction and a minimal fix that keeps the design are in the file.

    Also verified, not findings. The README's two Sepolia constructor addresses respond correctly over a public RPC, the committed ABI export is identical to the compiled ABI, the full suite passes 92 of 92, and all four static-analysis leads are false positives for the reasons given in the coverage rows.

    Not reached. The Squeekchain docs site is behind a bot wall, so the merge rules were taken from the verified on-chain source rather than the documentation.

    ran onclaude · claude-fable-5-1 · 47 turns · 7m 26s · 546 in · 32.1K out · 1.9M cached
    submissionf4a660215585b0231f98112b0eed29b2306991a8effa827df1b4ad04e1d5e94b
    device829d84438fb3fdc88e93850c9bddcabb55e7d84f7fb9a6e15974f2983199bb64
    started from77e69dbf4a43a16dc6f82ace41f13b27582778bd
    bundlenone
    applied on2de4afa0809c7a0ee0c3a4526a85760767168fa6961c13d5f2ae2b952a749c81
    • lowAsymmetric fill checks: the taker's Squeek is level/merge-ready checked at fill, the maker's offered Squeek (swap) or listed Squeek (ask) is never re-checked, so a maker can change it between create asrc/SqueekMatchEscrow.sol:374

      Pair compared: fillSwap taker side vs maker side (and fillAsk, src/SqueekMatchEscrow.sol:219-220). For the taker's Squeek, fillSwap/fillBid read SqueekMining live and revert on LevelMismatch / PowerChangePending / CooldownActive / AtMaximumLevel. For the maker's makerTokenId (fillSwap:372-373) and the ask's tokenId (fillAsk:219-220) only ownership and approval are checked.

      Neither Ask nor Swap stores the level or merge state of the Squeek the maker gives, and fill does not read it. A swap is 'Squeek for Squeek' between two holders who each want a merge partner at the same level, so the taker is relying on the maker's Squeek being what the Match Board showed.

      The maker alone can change that state after creation without touching the order: merging makerTokenId as the survivor raises its level, sets pendingRound and resets nextMergeBlock (live SqueekMining.merge, verified from the Sepolia implementation 0x08890b36...3b4e lines 463-472: ++survivor.level; pendingRound = round; nextMergeBlock = _nextMergeBlock(...)). The maker can do this right before an incoming fill (same block, higher gas) and the fill still succeeds.

      The escrow's own createSwap/createAsk also never records the level, so a dry-run eth_call by the website cannot protect against a change that lands between the dry run and the fill.

      Impact is bounded: the taker receives a higher-level (more valuable) Squeek that is not merge-ready and is not at the taker's level, so the merge the trade was for cannot happen; no SQK is lost. The reverse (level going down) is not possible on the live contracts; a donor merge burns the token and the fill then reverts (ERC721NonexistentToken / MakerNoLongerOwns).

      Reported as low because the taker is not out of pocket, but it is a broken guarantee of the stated purpose ('merge-partner finder') and an asymmetry between the two sides of the same trade.

      Suggested minimal fix that preserves the design: record the maker Squeek's level at createAsk/createSwap (one extra uint8 in Ask and Swap) and in fillAsk/fillSwap revert if mining().position(0, makerTokenId).level differs; optionally also require pendingRound == 0 when the swap asked for mergeReady so both sides of a merge-ready swap meet the same predicate. Alternatively, let the filler pass an expectedMakerLevel argument.

      Mock setup as in test/SqueekMatchEscrow.t.sol: alice owns #11 (level 1) and #12 (level 1), bob owns #21 (level 1), all approved.

      1. alice: createSwap(makerTokenId=11, wantedTokenId=0, wantedLevel=1, mergeReady=true, 0, 0, 0) -> swapId 1.

      2. alice merges #12 into #11 on SqueekMining (in the mock: nft.burn(12); mining.setPosition(11, 2, 7, 10_000)); escrow.levelOf(11) == 2, escrow.isMergeReady(11) == false.

      3. bob: fillSwap(1, 21).

      Expected (what the taker relied on and what the contract enforces for the taker's own Squeek): revert, because the maker's Squeek is no longer a level-1, merge-ready partner.

      Actual: the call succeeds, nft.ownerOf(11) == bob with level 2 and a pending activation, nft.ownerOf(21) == alice.

      Same with an ask: alice createAsk(11, 100 ether, 0); same merge; bob fillAsk(1) succeeds and pays 100 SQK for a Squeek in a different state than listed.

      Verified with a scratch Foundry test (test_fillSwap_makerSqueekStateNotPinned, test_fillAsk_listedSqueekLevelNotPinned) against this tree; both fills pass.

  6. Audit economicsAgent #339found 1 high, 2 medium, 2 low

    I audited the economics of SqueekMatchEscrow and found 5 defects: 1 high, 2 medium and 2 low. They're all written to .imd-findings.json, with coverage rows for all 9 entry points. No project files changed, and I deleted my scratch tests afterwards.

    #SeverityFindingReproduced with
    1HighMerged Squeek sold at old price: an open ask or swap on a Squeek stays fillable after the maker merges it up. On the fork, a sniper paid 100 SQK for an L2 Squeek and claimed 12,461 SQK.Sepolia fork, plus a proof test
    2MediumStale order revives: if the maker sells a Squeek and buys it back, the old ask or swap works again, as long as setApprovalForAll is still on.Proof test
    3MediumUnclaimed SQK isn't protected: buyers have no on-chain minimum for it, and a contract swap maker can claim it during the fill. On the fork, the maker took 6,230 SQK and the taker received the Squeek with 0 claimable.Sepolia fork
    4LowUnfillable bids and swaps: createBid and createSwap accept levels that can never fill, such as level 11, or level 10 with mergeReady. These can show as the best bid.Project mocks
    5LowPhantom floor price: isOpen stays true after the maker revokes approval, so a 1-wei ask can sit as the floor for gas alone.Project mocks

    Proofs: findings 1 and 2 carry proof tests that fail on the current code. They pass against a candidate fix I tried in a throwaway copy, not in your tree. That fix isn't the only option, and its token-level approval requirement has a usability cost that finding 2 describes. Finding 3 has no proof because it needs a fork, which the proof format doesn't allow.

    What holds:

    • All three cancel functions and fillBid behave as intended, and the 92 existing tests pass.
    • Escrowed SQK is fully accounted for by open orders, based on reading the code; no test checked this on a fork.
    • The mergeReady check matches the real merge rules, and the contract's struct layouts match the live contracts.
    • The Aderyn and Slither leads were false positives; each has a coverage row explaining why.

    What I couldn't reach:

    • Squeekchain docs: docs.squeekchain.com returned a Cloudflare 403, and I didn't try to get around it. I used the Sourcify-verified NFT, SQK and Mining source plus live chain reads instead.
    • SqueekCore source: I only exercised it through live reads and didn't read its code.
    • launch.json: it doesn't exist in this tree yet, so there was no manifest to review.

    The live figures come from a fork around Sepolia block 12051138, so they will drift over time. All of it is testnet SQK.

    ran onclaude · claude-opus-5-5 · 26 turns · 7m 40s · 29 in · 40.4K out · 1.2M cached
    submissionf3421b4fde866b6697125788d0f3410afb8c9af69ee482b3d8503ef46d3009db
    devicef96a0c89c039c22a846cc626e2739e902aec1218a771dd7ceb363bf2c60dea96
    started from77e69dbf4a43a16dc6f82ace41f13b27582778bd
    bundlenone
    applied on2de4afa0809c7a0ee0c3a4526a85760767168fa6961c13d5f2ae2b952a749c81
    • highOpen ask or swap keeps selling a Squeek at its pre-merge price after the maker merges it upsrc/SqueekMatchEscrow.sol:219

      An ask or swap stores only the maker's token id, never the level it was priced at, and fillAsk/fillSwap (lines 372-373) check only owner and approval for the maker's Squeek. SqueekMining.merge (verified implementation 0x08890b360C65FD878344Bf6e23d1c36358433B4E) keeps the survivor's id and owner, raises its level, adds the donor's accruedScaled and burns only the donor. No transfer touches the survivor, so even a per-token approval survives.

      Any order on the survivor therefore stays open and fillable at the old terms. This hits the product's main flow: a holder lists a lone L1, finds a partner, picks the listed Squeek as survivor in Buy then Merge, and the listing now sells the merged Squeek. Anyone can watch PositionsMerged and fill.

      The maker can avoid it only by remembering to cancel first; nothing on chain enforces that. A fix must keep merge-to-higher-level possible, for example by snapshotting the maker Squeek's level at createAsk/createSwap and reverting at fill if it changed (a candidate fix made this proof pass in a throwaway copy).

      Run on a Sepolia fork (block ~12051138, live SqueekNFT/SqueekMining/SQK, Squeeks moved to test wallets by prank).

      (1) alice owns L1 #1 and L1 #2 and calls SQUEEK.approve(escrow, 1).

      (2) alice calls createAsk(1, 100e18, 0).

      (3) alice calls mining.merge(0, 1, 2): #1 is now level 2, getApproved(1) is still the escrow, isOpen(ask) is true.

      (4) sniper calls fillAsk(ask) paying 100 SQK, then mining.claim(0,[1]) and receives 12,461.377786 SQK.

      Expected: the fill reverts, because the order priced a level-1 Squeek.

      Actual: sniper nets about 12,361 SQK plus an L2 Squeek (weight 1000 becoming 3000), and alice receives 100 SQK for two L1s.

      Same result through createSwap(1, 0, 1, false, 0, 0, 0) then fillSwap with one L1: the level-2 survivor leaves for one level-1 Squeek.

      Attached proof uses local mocks; both tests fail today.

      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 {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
      import {SqueekMatchEscrow} from "src/SqueekMatchEscrow.sol";
      import {ISqueekCore, ISqueekMining} from "src/interfaces/ISqueekchain.sol";
      
      contract Sqk is ERC20("Squeek", "SQK") {
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      contract Core is ISqueekCore {
          function state() external pure returns (CoreState memory s) {
              s.blockHeight = 100;
          }
      }
      
      /// @dev Reproduces the only part of SqueekMining.merge that matters here: the survivor keeps its id and owner
      /// and gains a level; the donor is burned. No ERC-721 transfer touches the survivor, so its approval survives.
      contract Mining is ISqueekMining {
          address public immutable core;
          mapping(uint256 => uint8) public levels;
      
          constructor(address core_) {
              core = core_;
          }
      
          function setLevel(uint256 id, uint8 level) external {
              levels[id] = level;
          }
      
          function position(uint8, uint256 id) external view returns (MiningPosition memory p) {
              p.level = levels[id];
          }
      
          function collectionPolicy(uint8) external pure returns (MiningCollectionPolicy memory p) {
              p.maximumLevel = 10;
          }
      }
      
      contract Squeek is ERC721("Squeek", "SQUEEK") {
          address public immutable mining;
      
          constructor(address mining_) {
              mining = mining_;
          }
      
          function mint(address to, uint256 id) external {
              _mint(to, id);
          }
      
          function burn(uint256 id) external {
              _burn(id);
          }
      }
      
      contract StaleMergedOrderTest is Test {
          Sqk sqk;
          Mining mining;
          Squeek nft;
          SqueekMatchEscrow escrow;
          address alice = makeAddr("escrow-alice");
          address sniper = makeAddr("escrow-sniper");
          uint256 constant SURVIVOR = 11;
          uint256 constant DONOR = 12;
          uint256 constant SNIPER_L1 = 21;
      
          function setUp() public {
              sqk = new Sqk();
              mining = new Mining(address(new Core()));
              nft = new Squeek(address(mining));
              escrow = new SqueekMatchEscrow(address(nft), address(sqk));
              nft.mint(alice, SURVIVOR);
              nft.mint(alice, DONOR);
              nft.mint(sniper, SNIPER_L1);
              mining.setLevel(SURVIVOR, 1);
              mining.setLevel(DONOR, 1);
              mining.setLevel(SNIPER_L1, 1);
              sqk.mint(sniper, 1_000e18);
              vm.startPrank(sniper);
              sqk.approve(address(escrow), type(uint256).max);
              nft.setApprovalForAll(address(escrow), true);
              vm.stopPrank();
          }
      
          function _merge() internal {
              vm.prank(alice);
              nft.burn(DONOR);
              mining.setLevel(DONOR, 0);
              mining.setLevel(SURVIVOR, 2);
          }
      
          /// An ask priced for a level-1 Squeek must not sell the level-2 Squeek the maker merged it into.
          function test_askListedAtLevel1CannotSellMergedLevel2() public {
              vm.startPrank(alice);
              nft.approve(address(escrow), SURVIVOR);
              uint256 askId = escrow.createAsk(SURVIVOR, 100e18, 0);
              vm.stopPrank();
              _merge();
              vm.prank(sniper);
              try escrow.fillAsk(askId) {} catch {}
              assertEq(nft.ownerOf(SURVIVOR), alice, "level-2 survivor sold at the stale level-1 ask price");
          }
      
          /// A swap that offered a level-1 Squeek must not hand over the level-2 survivor for one level-1 Squeek.
          function test_swapOfferedAtLevel1CannotSellMergedLevel2() public {
              vm.startPrank(alice);
              nft.approve(address(escrow), SURVIVOR);
              uint256 swapId = escrow.createSwap(SURVIVOR, 0, 1, false, 0, 0, 0);
              vm.stopPrank();
              _merge();
              vm.prank(sniper);
              try escrow.fillSwap(swapId, SNIPER_L1) {} catch {}
              assertEq(nft.ownerOf(SURVIVOR), alice, "level-2 survivor swapped away for one level-1 Squeek");
          }
      }
    • mediumAsk or swap revives at its old price when the maker re-acquires the Squeek under a standing setApprovalForAllsrc/SqueekMatchEscrow.sol:467

      Selling a Squeek elsewhere does not close the seller's other orders on it: nothing notifies the escrow, and the order stays Open for up to 30 days. Fill only asks whether the maker owns it now and whether the escrow is approved. A per-token approval is cleared by the transfer, but isApprovedForAll persists, and makers need it to run several orders.

      So an order that looks dead becomes fillable again the moment the token returns. README line 130-131 describes such a listing as reverting with MakerNoLongerOwns "until it is cancelled", which is false once the maker holds the token again. Candidate fix, checked in a throwaway copy: accept only getApproved(tokenId) for the maker's Squeek at fill, since the ERC-721 clears it on every transfer.

      The trade-off is one approve per listed Squeek.

      (1) alice calls setApprovalForAll(escrow,true) and approve(escrow,#11), then createAsk(11, 100e18, 30 days).

      (2) alice sells #11 outside that ask, e.g. fills someone's bid or transfers it to a buyer. fillAsk now reverts MakerNoLongerOwns, and the order is still Open.

      (3) Days later alice buys #11 back for 800e18.

      (4) sniper calls fillAsk(askId) for 100e18.

      Expected: revert, since the listing ended when the token left.

      Actual: #11 goes to sniper for 100 SQK and alice loses 700 SQK on the round trip.

      Applies equally to fillSwap at lines 372-373.

      Attached proof fails today.

      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 {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
      import {SqueekMatchEscrow} from "src/SqueekMatchEscrow.sol";
      import {ISqueekCore, ISqueekMining} from "src/interfaces/ISqueekchain.sol";
      
      contract Sqk is ERC20("Squeek", "SQK") {
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      contract Core is ISqueekCore {
          function state() external pure returns (CoreState memory s) {
              s.blockHeight = 100;
          }
      }
      
      contract Mining is ISqueekMining {
          address public immutable core;
      
          constructor(address core_) {
              core = core_;
          }
      
          function position(uint8, uint256) external pure returns (MiningPosition memory p) {
              p.level = 1;
          }
      
          function collectionPolicy(uint8) external pure returns (MiningCollectionPolicy memory p) {
              p.maximumLevel = 10;
          }
      }
      
      contract Squeek is ERC721("Squeek", "SQUEEK") {
          address public immutable mining;
      
          constructor(address mining_) {
              mining = mining_;
          }
      
          function mint(address to, uint256 id) external {
              _mint(to, id);
          }
      }
      
      contract StaleAskRevivedTest is Test {
          Sqk sqk;
          Squeek nft;
          SqueekMatchEscrow escrow;
          address alice = makeAddr("escrow-alice");
          address buyer = makeAddr("escrow-buyer");
          address sniper = makeAddr("escrow-sniper");
          uint256 constant ID = 11;
      
          function setUp() public {
              sqk = new Sqk();
              nft = new Squeek(address(new Mining(address(new Core()))));
              escrow = new SqueekMatchEscrow(address(nft), address(sqk));
              nft.mint(alice, ID);
              sqk.mint(sniper, 1_000e18);
              vm.prank(sniper);
              sqk.approve(address(escrow), type(uint256).max);
          }
      
          /// An ask must not come back to life when the Squeek leaves the maker's wallet and later returns.
          function test_askDoesNotReviveWhenMakerReacquiresToken() public {
              vm.startPrank(alice);
              nft.setApprovalForAll(address(escrow), true); // used for other orders; survives every transfer
              nft.approve(address(escrow), ID);
              uint256 askId = escrow.createAsk(ID, 100e18, 30 days);
              nft.transferFrom(alice, buyer, ID); // sold outside this ask
              vm.stopPrank();
              vm.prank(buyer);
              nft.transferFrom(buyer, alice, ID); // alice buys it back, days later, at a higher price
              vm.prank(sniper);
              try escrow.fillAsk(askId) {} catch {}
              assertEq(nft.ownerOf(ID), alice, "revived ask sold the re-acquired Squeek at its old price");
          }
      }
    • mediumBuyer has no on-chain guard on the unclaimed SQK it pays for; a swap maker can claim it mid-fillsrc/SqueekMatchEscrow.sol:383

      Unclaimed SQK moves with the Squeek and is large compared with plausible prices: live L1 #1 had 6,230.69 SQK claimable. Only the current owner can claim (SqueekMining._requireOwner). fillAsk and fillSwap take no minimum for the maker Squeek's accrued credit, so whatever the taker saw when it decided is not binding. In fillSwap it is worse: line 383 calls the maker's onERC721Received before line 384 moves the maker's Squeek.

      A contract maker still owns it during that callback and can call mining.claim atomically. A private mempool does not help, and an eth_call dry run only reports success. The nonReentrant guard does not stop this because the call goes to SqueekMining, not the escrow.

      The README leaves this to the website ("show buyers what they are getting", line 112), but a displayed value cannot bind a later block or a callback. An EOA maker of an ask can do the same by front-running with claim.

      The reverse also holds: accrual keeps moving to the buyer during a listing, about 0.506 SQK per Squeek block for an L1 at the current total weight of 11,066,000 (5,600 SQK x 1000 / 11,066,000), so claiming first does not protect a low fixed-price ask from an atomic fill-and-claim. Fix needs a scope decision: an added taker parameter such as minUnclaimed, checked after the maker's Squeek has arrived, changes the ABI.

      Run on a Sepolia fork with live contracts.

      1. Maker contract holds L1 #1 with claimable(0,[1]) = 6,230.688893 SQK and calls setApprovalForAll(escrow,true) and createSwap(1, 0, 1, false, 0, 0, 0).
      2. Its onERC721Received calls mining.claim(0,[1]).
      3. Taker, owning L1 #4, approves #4 and calls fillSwap(swapId, 4). Expected: taker receives #1 with the 6,230.69 SQK it could see, or the fill reverts. Actual: the fill succeeds, claimable(0,[1]) = 0, and the maker contract holds 6,230.688893 SQK plus #4. No proof attached: reproduction needs the live claim path on a fork, which the proof format excludes.
    • lowcreateBid and createSwap accept levels no Squeek can satisfy, producing permanently unfillable orders that show as opensrc/SqueekMatchEscrow.sol:255

      NatSpec at lines 244 and 313 documents the level as 1..maximum level, but only 0 is rejected, and the same check is at line 327 for swaps. Live collectionPolicy(0).maximumLevel is 10. Level 11 can never match, and level 10 with mergeReady=true always hits AtMaximumLevel at line 486.

      The escrow can read the policy at create without affecting deployment, because the constructor makes no call. These orders report isOpen = true and emit normal BidCreated events, which feed the brief's Market summary "best bid per level". A maker can show an unbeatable best bid that nobody can fill, at the cost of gas and refundable capital, and takers waste their approval and dry-run effort.

      No funds are lost: the maker can always cancel and get a refund.

      createBid(11, false, 250000e18, 30 days) and createBid(10, true, 250000e18, 30 days) both succeed and isOpen returns true for each.

      A holder of a live L10 Squeek calling fillBid(id, tokenId) gets LevelMismatch on the first and AtMaximumLevel on the second.

      Expected: InvalidLevel at creation. createSwap(id, 0, 11, false, 0, 0, 0) behaves the same way.

      Reproduced with the project's mocks (maximum level 10, same as live).

    • lowisOpen reports orders that cannot fill, so a 1-wei phantom ask can sit as the floor for gas alonesrc/SqueekMatchEscrow.sol:430

      isOpen checks only status and expiry, while fillAsk also needs the maker's ownership and approval. README line 86 groups isOpen with helpers whose checks the fill functions "perform", and the brief's Market summary needs a floor ask. Revoking approval or moving the Squeek leaves an order open with no event, so an event-and-isOpen view shows a floor price nobody can buy at.

      This is the cheapest griefing path on the market data: it costs only gas and needs no capital.

      Fix: add a view such as isFillable that also applies the maker-owner and approval checks, for the website to filter on.

      alice calls approve(escrow,#1), createAsk(1, 1, 30 days), then approve(address(0),#1). isOpen(askId) returns true. fillAsk(askId) reverts EscrowNotApproved.

      Expected: a view the website can rely on reports false.

      Actual: true for up to 30 days, or until alice cancels.

      Reproduced with the project's mocks.

  7. Write foundry testsAgent #11732 files changedsent back

    I added invariant and edge-case tests for SqueekMatchEscrow, and they pass. I found no critical or high defects; two lesser issues are written up in .imd-findings.json. The full suite is 110 tests passing with the verifier's fuzz seed, and forge build is clean. I only added files under test/, and the earlier contributors' tests are unchanged.

    test/SqueekMatchEscrow.invariant.t.sol (the contract holds SQK for bids and swaps)

    • A handler makes random calls with four users and twelve Squeeks. It covers create, fill and cancel for asks, bids and swaps, plus outside events: time passing, Squeek levels, cooldowns and pending power changing, Squeeks moving between wallets, approvals being revoked, and SQK sent straight to the contract.
    • Before every escrow call, the handler works out on its own whether the call should succeed. Afterwards it checks the actual result and the exact SQK and Squeek movements.
    • Five invariants, 256 runs × 60 calls:
      • every call behaved as the handler predicted;
      • the contract's SQK balance equals what open bids and swaps are owed plus any SQK sent to it directly;
      • nothing is paid out beyond what was put in, and no maker is refunded more than they deposited;
      • total SQK and the number of Squeeks never change, and the contract never holds a Squeek;
      • stored orders match what was requested, and filled or cancelled orders never reopen.
    • To check the harness isn't vacuous, I planted a double-refund bug in a scratch copy. Four of the five invariants caught it. The totals check can't see that kind of bug by design, since a double refund moves SQK between users without changing the total.

    test/SqueekMatchEscrow.edges.t.sol (17 tests)

    • Order ids that are unknown, zero, or belong to a different order type.
    • The exact expiry second for fills and for cancels by someone other than the maker, and the "expired" flag in cancel events.
    • Someone approved to manage another holder's Squeeks still can't sell them.
    • Approval for a single Squeek works and is used up by the sale; revoking it blocks a swap fill but the maker can still get their SQK back.
    • A swap that names a specific Squeek, a Squeek that doesn't exist yet, and Squeek id 0.
    • A Squeek's level and live status are read at fill time, not when the order was made.
    • An old ask can't take a Squeek from its new owner.
    • A fuzz test (1,000 runs) that orders can't touch each other's escrowed SQK.

    Findings in .imd-findings.json:

    • Low: old orders stay open and can come back to life. An ask or swap stays open after the maker moves the Squeek away, and isOpen() still returns true. If the Squeek comes back to the maker while their approval for the contract is still set, the old order fills at its old price. Example: createAsk(A1, 1 ether), the maker sends A1 to carol, carol sends it back, then bob buys it with fillAsk for 1 ether. I confirmed this in a scratch test. The website's "floor ask" and match board would also show these listings even though they can't be filled.
    • Info: impossible levels are accepted. createBid and createSwap only reject level 0, although the code comments say 1 up to the maximum level. createBid(255, …) succeeds and creates an order nothing can ever fill. No SQK is lost because cancelling refunds it in full.

    I didn't write tests that assert either behaviour is correct. The one test touching the second finding passes whether the contract rejects those levels or accepts them, as long as the maker gets a full refund.

    ran onclaude · claude-opus-5-5 · 33 turns · 19m 5s · 63 in · 46.4K out · 2.1M cached
    submission97f7ec0c12d24497b87bff1a42ea0db77a411c1856c021600587759f6a3b2ea4
    device5c1e4764d4e20ef59396a25901f46bc91bd6bc928f7285902a1b85ca759b1272
    started from77e69dbf4a43a16dc6f82ace41f13b27582778bd
    bundle63fb54ecf96dcc45f30c0d15e6487c14d785deb03da1aadf4e7f40a110a3db8a · 558 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on2de4afa0809c7a0ee0c3a4526a85760767168fa6961c13d5f2ae2b952a749c81
    changed · 2 files
    test/SqueekMatchEscrow.edges.t.soltest/SqueekMatchEscrow.invariant.t.sol
    may write
    testtest/**
    • lowAsk and swap orders stay Open after the maker's Squeek leaves, and revive at the old terms if it comes backsrc/SqueekMatchEscrow.sol:427

      An ask (or swap) keeps status Open and isOpen() keeps returning true after the maker transfers the listed Squeek away; the only check is ownerOf at fill (line 219 / 372). If the Squeek later returns to the maker while the operator approval set for the escrow is still in place (the normal setApprovalForAll flow), the old order fills at its original price/terms without any new action from the maker.

      A maker who re-buys a Squeek at a higher price can be filled at the stale lower price for up to 30 days. isOpen() also reports these unfillable orders as open, so the Match Board / market summary (floor ask) built on it shows listings that cannot be filled.

      alice createAsk(A1, 1 ether, 30 days); alice transfers A1 to carol -> isOpen(askId) == true (expected false or at least documented as unfillable); carol transfers A1 back to alice (e.g. alice bought it back for 500 ether); bob fillAsk(askId) succeeds and takes A1 for 1 ether (expected: the maker's earlier order not to be silently revived, e.g. by recording an owner nonce or having isOpen check ownerOf/approval). Mitigation today: maker must cancel orders before moving the Squeek; the website should check ownerOf/approval before showing an ask as live.

    • infocreateBid/createSwap accept levels above the collection maximum, creating orders that can never fillsrc/SqueekMatchEscrow.sol:255

      The natspec says level / wantedLevel is 1..maximum level, but only 0 is rejected (lines 255 and 327). createBid(255, true, amount, 0) succeeds and escrows the maker's SQK in an order no Squeek can ever fill (LevelMismatch for every token), and a mergeReady bid at exactly the maximum level is likewise unfillable (AtMaximumLevel).

      Funds are not lost (cancel refunds in full, covered by test_bidAboveMaximumLevel_neverFillsAndIsFullyRefundable), but such orders appear as open bids in the market summary. Either check collectionPolicy(0).maximumLevel at create (a call at create time, not in the constructor, so it does not affect launch) or correct the natspec and filter in the website.

      alice createBid(255, true, 1 ether, 0) -> succeeds (natspec implies revert InvalidLevel); any fillBid on it reverts LevelMismatch; cancelBid refunds 1 ether.

  8. Audit judgeAgent #717found 2 medium, 3 low

    The review is complete. The findings file .imd-findings.json holds five merged findings and twelve coverage rows, and no repository file was changed (the scratch tests I used are removed).

    What I reproduced and kept

    1. Medium: listed Squeek's level is not pinned. Asks and swaps store only the token id. The verified SqueekMining merge raises the survivor's level in place with no transfer, so an open order sells the merged, higher-level Squeek plus its accrued credit at the old price. Three specialists reported this (rated high, medium and low); merged into one finding at medium, since it needs an order left open on the Squeek being merged. Both specialist proofs failed on this tree as stated. One is attached.
    2. Medium: orders revive when the Squeek returns. Nothing closes an ask or swap when its Squeek leaves the maker. Under a standing operator approval, the forgotten order fills again once the token comes back. Four specialists reported this; merged into one finding. Both attached proofs failed as stated. One is attached. The README's "until it is cancelled" guarantee is false in this state.
    3. Low: unfillable levels accepted at creation. Bids and swaps for level 11 or merge-ready at level 10 are created and escrow SQK although no Squeek can fill them. Three reports merged. I wrote and attached a self-contained proof that fails on this code.
    4. Low: accrued SQK is not bindable. A swap maker contract can claim the listed Squeek's accrued SQK inside the receive callback between the two NFT transfers. Reproduced with a mock claim. Recalibrated from medium to low because no contract guarantee is broken and the brief already treats this as a website warning.
    5. Low: isOpen reports unfillable orders. Revoking approval leaves an order open with no event. Reproduced.

    Dropped leads

    • The aderyn reentrancy line is a view read before a state write under nonReentrant.
    • The slither strict-equality and timestamp lines are an enum comparison and the designed expiry.

    Verified clean

    • The manifest has only the schema keys, two string constructor arguments in declaration order, and a 1,037-character note. Live Sepolia reads confirm the NFT's mining contract names the same SQK token and NFT, so the accepted constants are internally consistent. I could not reach the Squeekchain API or docs (Cloudflare challenge), so the deployment-identity note in the manifest stays unverified.
    • The ABI export equals the compiled ABI, runtime is about 10.5 KB with no forbidden opcodes, and all 110 project tests pass.

    Coverage: six of nine entry points carry a finding, three hold, plus three invariant rows. Nothing was marked unreached.

    ran onclaude · claude-fable-5-1 · 38 turns · 10m 8s · 418 in · 37.2K out · 1.3M cached
    submission08664df9ff3c9742fa43ba92a12eef5734428358c11d8219597fd006382c7eda
    device2293d6b19e81ef1a8e62ede461544ff79dea3ad4a1caedea0ea886f0de1ef005
    started from5513947e182e9faa7242deeddb36d4b20ca0ec6c
    bundlenone
    applied on2de4afa0809c7a0ee0c3a4526a85760767168fa6961c13d5f2ae2b952a749c81, d54301df18dd62450c418583782207c09048f8b71e3328322fb2c1b85eb9f0b9, e77c1a3a46ce0ac859594ca249e0a966a850bcfe7568dc3b64a1cc737828b337
    • mediumAsk and swap never pin the listed Squeek's level: after the maker merges it up, the open order sells the level-(L+1) survivor at level-L termssrc/SqueekMatchEscrow.sol:219

      createAsk (lines 206-207) and createSwap (lines 334-344) store only maker, tokenId/makerTokenId, price/difference and expiry. At fill, fillAsk (lines 219-220) and fillSwap (lines 372-373) re-check only that the maker still owns the Squeek and that the escrow is approved; the maker-side Squeek's level is never read, while the taker-side Squeek goes through _requireMatches.

      The verified SqueekMining implementation (Sepolia 0x08890b360C65FD878344Bf6e23d1c36358433B4E, merge()) keeps the survivor's id and owner, executes '++survivor.level', adds the donor's accruedScaled, sets pendingRound and nextMergeBlock, and burns only the donor through SqueekNFT.burnForMerge. No ERC-721 transfer touches the survivor, so even a per-token approve() survives and the ask/swap stays Open and fillable.

      This is the brief's own main flow (list a lone single, find a partner, 'Buy then Merge' choosing the listed Squeek as survivor): the merge and the cancel are separate transactions, so the order remains fillable at least until the cancel lands and for up to 30 days if the maker forgets it. Anyone watching PositionsMerged can fill and takes the upgraded Squeek plus its accrued SQK credit at the pre-merge price.

      The README promises 'Level and eligibility are read live at fill'; for the maker's side nothing is read. Merged from audit_flow 6772cfd0 (medium), audit_economics f160ab21 (high) and audit_permissions 64b7c983 (low): one root cause, one fix.

      Severity medium: a loss of value for the maker that needs a specific but product-encouraged state (an order left open on the Squeek being merged). Fix that preserves the design: record the maker Squeek's level (uint8) in Ask and Swap at creation (mining().position(0, id).level, a call after deployment so the empty-chain constructor rule is unaffected) and in fillAsk/fillSwap revert with LevelMismatch when the live level differs. The attached proof passes with that change.

      Mock setup as in the attached proof (ERC-721 whose mining() returns a position mock with maximumLevel 10). alice owns SURVIVOR=11 (level 1) and DONOR=12 (level 1); sniper owns 21 (level 1), has SQK and approvals.

      1. alice: nft.approve(escrow, 11); escrow.createAsk(11, 100e18, 0) -> askId.

      2. alice merges 12 into 11 (mirrored as burn(12), level(12)=0, level(11)=2; on Sepolia this is SqueekMining.merge(0, 11, 12), which does not transfer 11). isOpen(askId) == true, getApproved(11) still == escrow.

      3. sniper: escrow.fillAsk(askId).

      Expected: revert, the order priced a level-1 Squeek.

      Actual: succeeds, sniper owns the level-2 #11 for 100 SQK.

      Swap: alice createSwap(11, 0, 1, false, 0, 0, 0), same merge, sniper fillSwap(swapId, 21) receives the level-2 #11 for one level-1.

      Ran .imd/reads/proofs/Proof_f160ab21e3d3.t.sol and Proof_6772cfd072ff.t.sol on this tree: all four tests fail ('level-2 survivor sold at the stale level-1 ask price', 'next call did not revert as expected').

      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 {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
      import {SqueekMatchEscrow} from "src/SqueekMatchEscrow.sol";
      import {ISqueekCore, ISqueekMining} from "src/interfaces/ISqueekchain.sol";
      
      contract Sqk is ERC20("Squeek", "SQK") {
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      contract Core is ISqueekCore {
          function state() external pure returns (CoreState memory s) {
              s.blockHeight = 100;
          }
      }
      
      /// @dev Reproduces the only part of SqueekMining.merge that matters here: the survivor keeps its id and owner
      /// and gains a level; the donor is burned. No ERC-721 transfer touches the survivor, so its approval survives.
      contract Mining is ISqueekMining {
          address public immutable core;
          mapping(uint256 => uint8) public levels;
      
          constructor(address core_) {
              core = core_;
          }
      
          function setLevel(uint256 id, uint8 level) external {
              levels[id] = level;
          }
      
          function position(uint8, uint256 id) external view returns (MiningPosition memory p) {
              p.level = levels[id];
          }
      
          function collectionPolicy(uint8) external pure returns (MiningCollectionPolicy memory p) {
              p.maximumLevel = 10;
          }
      }
      
      contract Squeek is ERC721("Squeek", "SQUEEK") {
          address public immutable mining;
      
          constructor(address mining_) {
              mining = mining_;
          }
      
          function mint(address to, uint256 id) external {
              _mint(to, id);
          }
      
          function burn(uint256 id) external {
              _burn(id);
          }
      }
      
      contract StaleMergedOrderTest is Test {
          Sqk sqk;
          Mining mining;
          Squeek nft;
          SqueekMatchEscrow escrow;
          address alice = makeAddr("escrow-alice");
          address sniper = makeAddr("escrow-sniper");
          uint256 constant SURVIVOR = 11;
          uint256 constant DONOR = 12;
          uint256 constant SNIPER_L1 = 21;
      
          function setUp() public {
              sqk = new Sqk();
              mining = new Mining(address(new Core()));
              nft = new Squeek(address(mining));
              escrow = new SqueekMatchEscrow(address(nft), address(sqk));
              nft.mint(alice, SURVIVOR);
              nft.mint(alice, DONOR);
              nft.mint(sniper, SNIPER_L1);
              mining.setLevel(SURVIVOR, 1);
              mining.setLevel(DONOR, 1);
              mining.setLevel(SNIPER_L1, 1);
              sqk.mint(sniper, 1_000e18);
              vm.startPrank(sniper);
              sqk.approve(address(escrow), type(uint256).max);
              nft.setApprovalForAll(address(escrow), true);
              vm.stopPrank();
          }
      
          function _merge() internal {
              vm.prank(alice);
              nft.burn(DONOR);
              mining.setLevel(DONOR, 0);
              mining.setLevel(SURVIVOR, 2);
          }
      
          /// An ask priced for a level-1 Squeek must not sell the level-2 Squeek the maker merged it into.
          function test_askListedAtLevel1CannotSellMergedLevel2() public {
              vm.startPrank(alice);
              nft.approve(address(escrow), SURVIVOR);
              uint256 askId = escrow.createAsk(SURVIVOR, 100e18, 0);
              vm.stopPrank();
              _merge();
              vm.prank(sniper);
              try escrow.fillAsk(askId) {} catch {}
              assertEq(nft.ownerOf(SURVIVOR), alice, "level-2 survivor sold at the stale level-1 ask price");
          }
      
          /// A swap that offered a level-1 Squeek must not hand over the level-2 survivor for one level-1 Squeek.
          function test_swapOfferedAtLevel1CannotSellMergedLevel2() public {
              vm.startPrank(alice);
              nft.approve(address(escrow), SURVIVOR);
              uint256 swapId = escrow.createSwap(SURVIVOR, 0, 1, false, 0, 0, 0);
              vm.stopPrank();
              _merge();
              vm.prank(sniper);
              try escrow.fillSwap(swapId, SNIPER_L1) {} catch {}
              assertEq(nft.ownerOf(SURVIVOR), alice, "level-2 survivor swapped away for one level-1 Squeek");
          }
      }
    • mediumAn ask or swap stays Open after its Squeek leaves the maker and revives at the old terms when the Squeek returns under a standing setApprovalForAllsrc/SqueekMatchEscrow.sol:372

      Nothing closes an order when its Squeek leaves the maker: the only staleness test is the live ownership read at fill (line 219 for asks, line 372 for swaps), and the status stays Open for up to 30 days.

      If the maker sells or swaps the Squeek through another order of this escrow (a second ask on the same token, fillBid, fillSwap) or transfers it elsewhere, and later gets it back (buys it back, receives it in a swap), the forgotten order is fillable again at its original price or terms. A per-token approve() is cleared by the ERC-721 transfer, but isApprovedForAll persists, and the README's own flow needs it because every outgoing trade requires the escrow to be approved.

      README lines 129-131 state the opposite guarantee: 'if a maker lists the same Squeek twice, only one fill can succeed and the other listing reverts with MakerNoLongerOwns until it is cancelled'. That holds only while the maker does not hold the token. Merged from write_foundry_tests 2ec3a9e5 (low), audit_math 3646bc0c (medium), audit_flow 015135be (medium) and audit_economics 1faf31ab (medium): one root cause (an order is not bound to the ownership period it was created in).

      Severity medium: a loss for the maker that needs a reachable but specific state (the Squeek comes back while an old order is still open). Distinct from finding 1: there the token never moves and its level changes; here the token leaves and returns.

      Fix: keep a per-token generation counter that fillAsk, fillBid and fillSwap increment for every Squeek they move, store it in Ask and Swap at creation, and require it unchanged at fill; a transfer outside the escrow still needs a cancel, which the README should say. Alternatively accept only getApproved(tokenId) for the maker's Squeek at fill, since the ERC-721 clears it on every transfer.

      Mock setup as in the attached proof: alice owns A1=11 (level 1), bob B1, carol C1; all three hold SQK, approved the escrow for SQK and setApprovalForAll on the NFT.

      1. alice: createAsk(11, 1e18, 30 days) -> oldAsk.

      2. bob: createBid(1, false, 5e18, 0) -> bobBid; alice: fillBid(bobBid, 11). ownerOf(11) == bob. carol: fillAsk(oldAsk) reverts MakerNoLongerOwns(oldAsk, 11), as documented; the order is still Open.

      3. bob: createAsk(11, 6e18, 0) -> bobAsk; alice: fillAsk(bobAsk). ownerOf(11) == alice again.

      4. carol: fillAsk(oldAsk).

      Expected: revert, the listing ended when the token left.

      Actual: succeeds; carol owns #11 for 1 SQK that alice just bought back for 6 SQK. fillSwap at lines 372-373 performs the same owner-only check, so a swap on makerTokenId revives the same way.

      Ran .imd/reads/proofs/Proof_015135be94ee.t.sol and Proof_3646bc0cb1ae.t.sol on this tree: both fail ('next call did not revert as expected', 'stale 1-SQK ask filled after the Squeek returned to its maker').

      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 {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
      import {SqueekMatchEscrow} from "src/SqueekMatchEscrow.sol";
      import {ISqueekCore, ISqueekMining} from "src/interfaces/ISqueekchain.sol";
      
      contract PSqk is ERC20 {
          constructor() ERC20("Squeek Token", "SQK") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      contract PCore is ISqueekCore {
          function state() external pure returns (CoreState memory s) {
              s.blockHeight = 100;
          }
      }
      
      contract PMining is ISqueekMining {
          address public immutable core;
          mapping(uint256 => MiningPosition) internal positions;
      
          constructor(address core_) {
              core = core_;
          }
      
          function setPosition(uint256 tokenId, uint8 level, uint48 nextMergeBlock) external {
              positions[tokenId].level = level;
              positions[tokenId].activeWeight = uint64(level) * 1000;
              positions[tokenId].nextMergeBlock = nextMergeBlock;
          }
      
          function clearPosition(uint256 tokenId) external {
              delete positions[tokenId];
          }
      
          function position(uint8, uint256 tokenId) external view returns (MiningPosition memory) {
              return positions[tokenId];
          }
      
          function collectionPolicy(uint8) external pure returns (MiningCollectionPolicy memory policy) {
              policy.maximumLevel = 10;
          }
      }
      
      contract PNft is ERC721 {
          address public immutable mining;
      
          constructor(address mining_) ERC721("Squeek", "SQUEEK") {
              mining = mining_;
          }
      
          function mint(address to, uint256 tokenId) external {
              _mint(to, tokenId);
          }
      
          function burn(uint256 tokenId) external {
              _burn(tokenId);
          }
      }
      
      contract RevivedAskTest is Test {
          PSqk sqk;
          PMining mining;
          PNft nft;
          SqueekMatchEscrow escrow;
      
          address alice = makeAddr("alice");
          address bob = makeAddr("bob");
          address carol = makeAddr("carol");
      
          uint256 constant A1 = 11; // alice's lone level-1 Squeek, listed for sale
          uint256 constant B1 = 21; // bob's level-1 Squeek
          uint256 constant C1 = 31; // carol's level-1 Squeek
      
          function setUp() public {
              vm.warp(1_800_000_000);
              sqk = new PSqk();
              mining = new PMining(address(new PCore()));
              nft = new PNft(address(mining));
              escrow = new SqueekMatchEscrow(address(nft), address(sqk));
      
              _mint(alice, A1);
              _mint(bob, B1);
              _mint(carol, C1);
              address[3] memory users = [alice, bob, carol];
              for (uint256 i; i < 3; ++i) {
                  sqk.mint(users[i], 1_000 ether);
                  vm.startPrank(users[i]);
                  sqk.approve(address(escrow), type(uint256).max);
                  nft.setApprovalForAll(address(escrow), true);
                  vm.stopPrank();
              }
          }
      
          function _mint(address to, uint256 id) internal {
              nft.mint(to, id);
              mining.setPosition(id, 1, 0);
          }
      
          /// Alice lists A1 for 1 SQK, sells A1 into Bob's 5 SQK bid, then buys A1 back from Bob's 6 SQK ask.
          /// Her first 1 SQK listing comes back to life and Carol takes A1 for 1 SQK.
          function test_askIsNotRevivedWhenTheSqueekReturnsToTheMaker() public {
              vm.prank(alice);
              uint256 oldAsk = escrow.createAsk(A1, 1 ether, 30 days);
      
              vm.prank(bob);
              uint256 bobBid = escrow.createBid(1, false, 5 ether, 0);
              vm.prank(alice);
              escrow.fillBid(bobBid, A1);
              assertEq(nft.ownerOf(A1), bob);
      
              // While A1 is away the old listing reverts with MakerNoLongerOwns, as the README documents.
              vm.expectRevert(abi.encodeWithSelector(SqueekMatchEscrow.MakerNoLongerOwns.selector, oldAsk, A1));
              vm.prank(carol);
              escrow.fillAsk(oldAsk);
      
              vm.prank(bob);
              uint256 bobAsk = escrow.createAsk(A1, 6 ether, 0);
              vm.prank(alice);
              escrow.fillAsk(bobAsk);
              assertEq(nft.ownerOf(A1), alice);
      
              vm.expectRevert();
              vm.prank(carol);
              escrow.fillAsk(oldAsk);
              assertEq(nft.ownerOf(A1), alice, "listing revived after the Squeek came back");
          }
      }
    • lowcreateBid and createSwap accept levels no Squeek can satisfy (level > maximumLevel, or mergeReady at maximumLevel), escrowing SQK into permanently unfillable orders that report isOpensrc/SqueekMatchEscrow.sol:255

      The NatSpec at lines 244 and 313 documents level / wantedLevel as '1..maximum level', but only the lower bound is enforced (line 255 for bids, line 327 for swaps). Live collectionPolicy(0).maximumLevel on Sepolia is 10 (read from SqueekMining 0x3bee588fC20dE788Bac17d33b41Da7346cb27EC4 through SqueekNFT.mining()).

      A bid or swap for level 11..255 can never match (_requireMatches reverts LevelMismatch for every token), and a mergeReady order at level 10 always reverts AtMaximumLevel at line 486. The escrowed SQK sits in an order that isOpen reports as open and that emits a normal BidCreated event, so it appears as the best bid per level in the brief's Market summary until the maker cancels or anyone cancels it after expiry. No funds are lost (cancel refunds in full).

      Merged from write_foundry_tests 27b09920 (info), audit_math 5a4fae5a (info) and audit_economics 4ef2df08 (low).

      Fix: in createBid and createSwap read mining().collectionPolicy(SQUEEKS_COLLECTION).maximumLevel and revert InvalidLevel when level > maximum, or when mergeReady and level >= maximum. That call runs after deployment, so the empty-chain constructor rule is unaffected; it does make bid/swap creation depend on SqueekMining's view being reachable, which fills already do.

      Mock with maximumLevel 10 (same as live). alice holds SQK and approvals. createBid(11, false, 1e18, 0) -> succeeds, escrow holds 1 SQK, isOpen == true. bob owning a level-10 Squeek: fillBid(id, B10) reverts LevelMismatch(B10, 11, 10). createBid(10, true, 1e18, 0) -> succeeds; fillBid(id, B10) reverts AtMaximumLevel(B10, 10). createSwap(A1, 0, 11, false, 0, 0, 0) -> succeeds; fillSwap(id, B10) reverts LevelMismatch(B10, 11, 10).

      Expected per NatSpec: InvalidLevel at creation.

      Actual: creation succeeds. cancelBid refunds the full amount.

      Reproduced in a scratch test on this tree; the attached proof (expecting InvalidLevel at creation) fails on this code with 'next call did not revert as expected' in all three tests.

      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 {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
      import {SqueekMatchEscrow} from "src/SqueekMatchEscrow.sol";
      import {ISqueekCore, ISqueekMining} from "src/interfaces/ISqueekchain.sol";
      
      contract LSqk is ERC20 {
          constructor() ERC20("Squeek", "SQK") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      contract LCore is ISqueekCore {
          function state() external pure returns (CoreState memory s) {
              s.blockHeight = 100;
          }
      }
      
      /// @dev Maximum level 10, as live collectionPolicy(0).maximumLevel on Sepolia.
      contract LMining is ISqueekMining {
          address public immutable core;
          mapping(uint256 => MiningPosition) internal positions;
      
          constructor(address core_) {
              core = core_;
          }
      
          function setLevel(uint256 tokenId, uint8 level) external {
              positions[tokenId].level = level;
              positions[tokenId].activeWeight = uint64(level) * 1000;
          }
      
          function position(uint8, uint256 tokenId) external view returns (MiningPosition memory) {
              return positions[tokenId];
          }
      
          function collectionPolicy(uint8) external pure returns (MiningCollectionPolicy memory policy) {
              policy.maximumLevel = 10;
          }
      }
      
      contract LNft is ERC721 {
          address public immutable mining;
      
          constructor(address mining_) ERC721("Squeek", "SQUEEK") {
              mining = mining_;
          }
      
          function mint(address to, uint256 tokenId) external {
              _mint(to, tokenId);
          }
      }
      
      /// Orders whose level no Squeek can ever satisfy must be refused at creation, as the NatSpec
      /// ("1..maximum level") states, instead of escrowing SQK into an order that only cancel can close.
      contract LevelBoundTest is Test {
          LSqk sqk;
          LMining mining;
          LNft nft;
          SqueekMatchEscrow escrow;
          address alice = makeAddr("alice");
          uint256 constant A1 = 11;
      
          function setUp() public {
              sqk = new LSqk();
              mining = new LMining(address(new LCore()));
              nft = new LNft(address(mining));
              escrow = new SqueekMatchEscrow(address(nft), address(sqk));
              nft.mint(alice, A1);
              mining.setLevel(A1, 1);
              sqk.mint(alice, 1_000 ether);
              vm.startPrank(alice);
              sqk.approve(address(escrow), type(uint256).max);
              nft.setApprovalForAll(address(escrow), true);
              vm.stopPrank();
          }
      
          function test_createBid_aboveMaximumLevel_reverts() public {
              vm.prank(alice);
              vm.expectRevert(SqueekMatchEscrow.InvalidLevel.selector);
              escrow.createBid(11, false, 1 ether, 0);
          }
      
          function test_createBid_mergeReadyAtMaximumLevel_reverts() public {
              vm.prank(alice);
              vm.expectRevert(SqueekMatchEscrow.InvalidLevel.selector);
              escrow.createBid(10, true, 1 ether, 0);
          }
      
          function test_createSwap_wantedLevelAboveMaximum_reverts() public {
              vm.prank(alice);
              vm.expectRevert(SqueekMatchEscrow.InvalidLevel.selector);
              escrow.createSwap(A1, 0, 11, false, 0, 0, 0);
          }
      }
    • lowTaker cannot bind the unclaimed SQK credit of the Squeek it buys; a swap maker contract can claim it in the onERC721Received callback between lines 383 and 384src/SqueekMatchEscrow.sol:383

      SqueekMining credits rewards to the token, not the owner, so unclaimed SQK moves with a Squeek; only the current owner may claim (verified SqueekMining._requireOwner: 'approvals are not enough'). fillAsk and fillSwap take no parameter for the maker Squeek's accrued credit, so whatever the taker saw in its dry run is not binding. An EOA maker can simply claim right before a fill, which is the design the brief accepts ('warn seller to claim unclaimed SQK first').

      What the contract adds is a same-transaction window: in fillSwap, line 383 delivers the taker's Squeek to the maker and invokes the maker's onERC721Received while the maker still owns makerTokenId; a contract maker can call SqueekMining.claim(0, [makerTokenId]) there (nonReentrant does not apply, the call goes to SqueekMining), and line 384 then hands over a Squeek with zero credit. A private mempool or an eth_call dry run cannot detect it.

      No stated contract guarantee is broken, the taker keeps the Squeek it bargained for, and the maker only takes credit that was theirs to claim; this is a limitation to document and a scope decision for the author, not a loss path. From audit_economics 9d1ca966 (medium), recalibrated to low.

      Options: add a taker-side minimum (e.g. fillSwap/fillAsk minAccrued parameter checked after the maker's Squeek arrives, an ABI change), or swap the order of lines 383 and 384 and keep the website warning for the EOA case. Either way the README should state that accrued SQK is not part of the trade.

      Mock mining whose claim() requires ownerOf(id) == msg.sender, pays accruedScaled in SQK and zeroes it (mirrors SqueekMining.claim).

      Maker contract owns #40 (level 1, accruedScaled 6230e18), calls setApprovalForAll(escrow, true) and createSwap(40, 0, 1, false, 0, 0, 0); its onERC721Received calls mining.claim(0, [40]). bob owns B1 (level 1) and calls fillSwap(swapId, B1).

      Expected from the taker's dry run: bob receives #40 with 6230 SQK claimable.

      Actual: fill succeeds, ownerOf(40) == bob, position(0, 40).accruedScaled == 0, maker contract balance +6230 SQK and it owns B1.

      Reproduced in a scratch Foundry test on this tree (test_lead_makerClaimsAccruedMidFill).

    • lowisOpen reports asks and swaps the fill functions would reject (approval revoked or Squeek moved), so the Match Board floor can be a phantom ordersrc/SqueekMatchEscrow.sol:428

      isOpen (lines 428-434) checks only status and expiry, while fillAsk/fillSwap also need the maker to own the Squeek and the escrow to be approved (lines 219-220, 372-373). README line 86 groups isOpen with helpers for which 'the fill functions perform the same checks'. A maker who revokes approval or moves the Squeek leaves the order Open with no event, so an indexer built on events plus isOpen shows a floor ask or swap nobody can fill.

      It costs only gas and no capital, so it is the cheapest way to distort the brief's Market summary ('floor ask'). Per-token approval revocation also has no ERC-721 event the escrow could key on. From audit_economics 35f4d00e (low); write_foundry_tests 2ec3a9e5 also notes isOpen reporting unfillable orders.

      Not a loss; a view/documentation gap.

      Fix: add a view such as isFillable(orderId) that applies the maker-owner and approval checks (and, with finding 1, the level check) for the website to filter on, or correct README line 86 and the website's use of isOpen.

      alice owns A1 with no operator approval: approve(escrow, A1); createAsk(A1, 1, 30 days) -> askId; approve(address(0), A1). isOpen(askId) returns true. bob: fillAsk(askId) reverts EscrowNotApproved(A1, alice).

      Expected: a view the website can rely on reports false.

      Actual: true for up to 30 days or until cancel.

      Reproduced in a scratch Foundry test on this tree (test_lead_isOpenTrueButUnfillable).

  9. Build contract projectAgent #15736 files changed

    All five findings reproduced and are fixed. Both reviewer proofs now pass, and the full suite passes with and without the verifier's fuzz seed.

    What was wrong. An ask or swap stored only maker, token, price and expiry. The fill re-checked ownership and approval, but a merge raises the survivor's level without any transfer, so a stale listing could sell the upgraded Squeek at the old price. And an order stayed Open after the Squeek left, so it revived at the old terms when the Squeek came back under a standing approval.

    Changes in src/SqueekMatchEscrow.sol:

    • Asks and swaps now record the maker Squeek's level at creation. The fill reverts with LevelMismatch if the live level differs.
    • A per-token tradeCount is bumped by every fill that moves a Squeek. Asks and swaps record it at creation and the fill reverts with StaleOrder once it has changed. The ownership check still runs first, so the documented MakerNoLongerOwns is unchanged while the Squeek is away.
    • Bid and swap creation now reject levels no Squeek can satisfy. That means a level above the collection maximum, or merge-ready at the maximum.
    • Swap settlement delivers the maker's Squeek to the taker before the taker's Squeek reaches the maker, closing the in-hook claim window.
    • A new isFillable view applies the maker-side checks so the website can filter phantom floors. The isOpen view is unchanged and its docs now say what it ignores.
    • The created events carry the recorded level for indexers.

    Tests and docs. The test suite grew from 92 to 107 tests, with new cases for the merge scenario, revival via bid-and-ask and via two swaps, the outside-transfer limit, creation-time level bounds, the claiming maker contract, and the new view. Mocks gained a settable maximum level and a claim stub that enforces the owner rule. The ABI export was regenerated and the README documents the pin, the counter, the fact that accrued SQK is not part of the trade, and that transfers made outside the escrow still need a cancel.

    Verification. The build, the tests under the verifier's fuzz seed, and the format check all pass. Lint output is the same three categories as the starting tree. Runtime is about 12 KB. Each finding is answered as fixed in .imd-responses.json.

    One design note for the reviewer: a transfer made outside the escrow does not bump the counter, so a Squeek that leaves and returns by another route keeps its old listing live. That is documented as a maker responsibility rather than enforced, since the escrow sees no such transfers.

    ran onclaude · claude-fable-5-1 · 68 turns · 12m 28s · 610 in · 55.7K out · 2.1M cached
    submission43b32eaffe2ec057419f6920c102ad5834bf4b4548481ae6d594f975227ae000
    deviced021901979a789977c647737fea87afdf4ca7a1cf9c932febdc856559d77174a
    started from77e69dbf4a43a16dc6f82ace41f13b27582778bd
    bundle2de4afa0809c7a0ee0c3a4526a85760767168fa6961c13d5f2ae2b952a749c81 · 557 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 6 files
    README.mddocs/abi/SqueekMatchEscrow.jsonsrc/SqueekMatchEscrow.soltest/SqueekMatchEscrow.t.soltest/mocks/Attackers.soltest/mocks/SqueekchainMocks.sol
  10. ManifestAgent #12461 file changed

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

    What I found. This revision came with no listed findings, so I checked the accepted manifest against the accepted source instead of guessing. The single contract SqueekMatchEscrow has a nonpayable constructor taking two addresses, SqueekNFT and SqueekToken, and the manifest fills exactly those in declaration order. The contract has no owner, so no $owner argument is needed, which matches the brief's "no owner, no fee, no proxy" requirement. The addresses match the deploy script constants, the README deployment table and the deployment test.

    On-chain confirmation. Both published Squeekchain sources sit behind a Cloudflare challenge and could not be fetched from this machine. I verified the inputs against Sepolia directly through a public RPC instead. The NFT argument reports name Squeek and symbol SQUEEK and points to a SqueekMining contract whose configuration names the same SQK token and the same NFT. The token argument reports symbol SQK with 18 decimals. The alternative token from the earlier review finding also exists and also reports symbol SQK, so only an address comparison can distinguish them.

    What I changed. The contract list and constructor arguments are unchanged from the accepted manifest. I extended the notes with today's on-chain confirmation and with the observation that the two SQK tokens share a symbol, and kept the prior deployment-identity finding and its instruction to refuse trading on mismatch. Notes remain well under the limit.

    Checks run:

    • Scratch schema check: exact keys, string arguments, identifier length, dependency order and notes length all pass.
    • forge build succeeds.
    • forge test with the verifier's fuzz seed: 107 passed, 0 failed.

    The deployment-identity conflict between the documented deployment and the API remains a review item for the services stage, not a manifest defect.

    ran onclaude · claude-fable-5-1 · 22 turns · 2m 13s · 290 in · 8.7K out · 438.3K cached
    submission7ae63428ec26d123b306de90da4e50410f2ec26d88b23e27fcaa67bf3834056e
    device5d667e4b0751bcb55515022399c1ba51448da54deb4eebc7503f9961c70cfde3
    started from5155f868e4b9a94f26ee0e80acb5402cf2dba941
    bundlee77c1a3a46ce0ac859594ca249e0a966a850bcfe7568dc3b64a1cc737828b337 · 558 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on2de4afa0809c7a0ee0c3a4526a85760767168fa6961c13d5f2ae2b952a749c81
    changed · 1 file
    launch.json
    may write
    launch.json
  11. Write foundry testsAgent #8102 files changed

    My test suites compiled again and now pass. With the verifier's seed, forge build succeeds and forge test runs 131 tests in 11 suites with 0 failures. I found no defects in the revised contract, so I wrote no .imd-findings.json. Only test/SqueekMatchEscrow.invariant.t.sol and test/SqueekMatchEscrow.edges.t.sol changed.

    Why work was needed: the implementation changed after my last round. Asks and swaps now record the Squeek's level and a per-Squeek trade count when created, and the maximum level is read from the live mining policy. My invariant file read the old order layouts and no longer compiled.

    Invariant suite

    • Updated model: the handler now predicts:
      • NoLivePosition and InvalidLevel (against the current maximum level) at creation.
      • On fills: the maker's Squeek is still theirs, still approved, untraded here since the order was made (StaleOrder), and at the same level (LevelMismatch).
      • Trade counts going up on every fill, on both sides of a swap.
    • New checks: isFillable and tradeCount must match the model, and the new layouts are compared field by field. A new handler action changes the policy's maximum level.
    • It was barely testing fills. A probe showed only 2 ask fills, 4 bid fills and 0 swap fills in about 8,000 random calls. I steered fills toward live orders, made cancels choose orders at random, and toned down level changes, long time jumps and maximum-level changes. I also added an action that creates a swap and fills it straight away. Swap fills went from 0–3 to about 11 per 4,000 calls, and ask and bid fills went up too.
    • One overflow found along the way was a bug in my handler, not the contract, and is fixed.
    • Proof it catches defects: with the StaleOrder check removed from the contract locally, the invariant failed with "fillAsk: succeeded but must have reverted". I then restored the source with git.
    • All five invariants pass on the verifier seed and on two other seeds.

    Edge tests (22 now)

    • A bid above the maximum level now pins the exact InvalidLevel revert, with nothing escrowed and no id used.
    • Lowering the policy's maximum level makes creation reject levels above it.
    • A merge-ready bid whose level becomes the new maximum can't be filled (AtMaximumLevel), and the maker still gets a full refund.
    • A swap holding the maker's SQK becomes unfillable when the maker sells the offered Squeek (MakerNoLongerOwns). It stays dead (StaleOrder) when the Squeek is bought back here, and expiry cancel refunds the SQK.
    • Selling a Squeek as the taker of a bid also counts as a trade, so the seller's own earlier swap offering it goes stale.
    • A fuzz test checks an ask fills only at its exact listed level, and that any other level, including 0, reverts and moves nothing.

    One known gap the main suite already documents and I did not change: a transfer made outside the escrow doesn't count as a trade. If a Squeek leaves its maker that way and comes back, their old ask fills again until they cancel it.

    ran onclaude · claude-opus-5-5 · 30 turns · 11m 58s · 55 in · 32.1K out · 2.3M cached
    submissionee77aeb262c556ec38b42432e5ba2ef48af3c3d20e8273973fe6999c4eb4ce0b
    device9e17b97b8ac7c97a6c9ce47ee1af9336e92996a8c913fc15d3f5e31ae5a03ff0
    started from637cf39f90c2334cafacddd3e71db5f6b4ce9393
    bundled54301df18dd62450c418583782207c09048f8b71e3328322fb2c1b85eb9f0b9 · 570 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on2de4afa0809c7a0ee0c3a4526a85760767168fa6961c13d5f2ae2b952a749c81
    changed · 2 files
    test/SqueekMatchEscrow.edges.t.soltest/SqueekMatchEscrow.invariant.t.sol
    may write
    testtest/**
  12. Audit judgeAgent #268found nothing

    All five findings from my earlier round are fixed in the revised code, and nothing new reproduces. The report is written to .imd-findings.json with an empty findings list, a settled record for each earlier finding, and a coverage row for all nine entry points plus three invariants. The working tree is clean apart from that file.

    Settled findings, each verified against the code and by running the attached proofs:

    • Level pin (medium). Asks and swaps now record the maker Squeek's live level at creation and the fill reverts with LevelMismatch when it differs. All six tests from the three proofs on this root cause pass on the revised tree after failing on the old one.
    • Order revival (medium). A per-token trade counter is bumped by every fill that moves a Squeek and checked at fill as StaleOrder, after the ownership check so the documented error is unchanged. My proof and audit_flow's proof pass. Audit_math's proof reverts only because its NFT mock has no mining contract, which the fixed createAsk now reads. That is a mock incompatibility, not a defect.
    • Unfillable levels (low). Bid and swap creation now bound the level by the live maximum, strictly below it when merge-ready.
    • Mid-fill claim (low). fillSwap delivers the maker's Squeek first, so a contract maker's receive hook no longer owns the token when it tries to claim. The author's test with a claiming maker mock shows the claim refused and the credit still on the token.
    • Phantom floors (low). isFillable applies the full maker-side checks, handles burned tokens without reverting, and the README tells the website to filter on it.

    Other checks. The full project suite passes with 130 tests. The exported ABI matches the compiled one. The three static-analysis leads are false positives: an enum equality, the documented timestamp expiry, and a struct field sharing a name with a mapping.

    One open item, not a defect. The manifest's constructor addresses match the documented Sepolia deployment record and the live NFT's own mining pointer. The Squeekchain API reports a different, newer installation whose mining contract does not track the Squeeks collection. The manifest notes already record this, and the brief assigns the mismatch refusal to the frontend, so I left it as a coverage row rather than a finding.

    ran onclaude · claude-fable-5-1 · 20 turns · 4m 12s · 226 in · 13.5K out · 506K cached
    submissionab107738b42ca48721c15806916412acae42adea5b74c3eaf2bb56ce8a48cbb4
    device5dd3af20ad7ac0c2de926026089923d87ad708bad8a2190e151476566619bf41
    started from7b7c3d3431aa5d2c8b13070001b8e7ce56ceeede
    bundlenone
    applied on2de4afa0809c7a0ee0c3a4526a85760767168fa6961c13d5f2ae2b952a749c81, d54301df18dd62450c418583782207c09048f8b71e3328322fb2c1b85eb9f0b9, e77c1a3a46ce0ac859594ca249e0a966a850bcfe7568dc3b64a1cc737828b337
  13. Deployed1 contract on Sepoliatransaction
    rebuilt
    SqueekMatchEscrow · verifier 0.1.0 · solc 0.8.26
    gates
    • provenance
    • findings
    • independent review
    • bytecode
    • manifest
    • protected invariants
    • economics
    proof
    commit, attestation, manifest, tree, per-contract hashes
    repository
    identity-md-launches/launch-2897-workflow-contract-stage-context
    commit
    ba6069873530d6484250b8ed574a70b0a7200068
    attestation
    a86c794da8d9c66839004c8c7b1cd997200dc74e401f1a2a20dd84aa323206d4
    manifest
    97578e6917e8cb8bde16c45acbe83bce64dd7d9f829787678b9b021f08665ec8
    constructor
    SqueekMatchEscrow: 0x0eAF3E0d40A0a8E5a6Ac030b2459E9FB0B7905c6, 0x6872BfA5CcC3E9e440C6c16e5fFdAbb8DBA4Cc76
    tree
    7c337a094ac3285b82bad2c860897a0abad39ed6
    compiler
    solc 0.8.26, optimizer 200 runs, reproducible
    contract
    SqueekMatchEscrow
    src/SqueekMatchEscrow.sol · 12454 bytes
    creation 0d770e517802788a0d9e94be0eb5f42028d223087b40c5ed24594d0a82cd19c9
    abi 000dd3c70055e309aeaa0c7ea80a1a8e5c4e0051ac51ad85843d5b5aebdef12b
    metadata 53f755ded0ac57c1666179853b30bd1e22e24e4c7e48193febe4670043ec9a50
    onchain at 0xd6c8…43f9, block 12,051,420 · creation code matches
  14. Frontend for contractAgent #973111 files changed

    Frontend source, static export and validation evidence are ready. Build/typecheck, 41 tests and 16 design checks pass.

    Trading remains paused because API identities conflict with deployment records. Deposited merging is explicitly unavailable.

    Local staging/commit failed because .git is read-only.

    ran oncodex · gpt-6.1-sol · 11 turns · 48m 49s · 383.9K in · 132.9K out · 16.7M cached
    submission5a5e7b26eb925e594dbefcc9ae1a5939d362e67dc3dd5b81ca70bc73603112b8
    device2c736d2f8963f8a507c1f105407e5ba312f42ab34716c5f81b163e655e340023
    started fromba6069873530d6484250b8ed574a70b0a7200068
    bundle9f68d025146daacb48db0cecb6436cff07edc0acc207506ed2eae78fdbe65d61 · 732 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 111 files
    dist/abi/SqueekMatchEscrow.jsondist/assets/_esm-S256KbrO.jsdist/assets/ccip-C_snM0do.jsdist/assets/config-BREQj25u.jsdist/assets/dist-CNdkgSf6.jsdist/assets/dm-sans-latin-ext-wght-normal-BOFOeGcA.woff2dist/assets/dm-sans-latin-wght-normal-Xz1IZZA0.woff2dist/assets/index-Bve8YlG_.jsdist/assets/index-Xj_Z8kRu.cssdist/assets/rolldown-runtime-XKyaHtl9.jsdist/assets/secp256k1-DLGpOtNW.jsdist/assets/utils-DFnHSamt.jsdist/favicon.svgdist/imd-deployment.jsondist/index.htmldist/licenses/RainbowKit-LICENSE.txtdist/licenses/coinbase-wallet-sdk-LICENSE.txtdist/licenses/fontsource-variable-dm-sans-LICENSE.txtdist/licenses/lucide-react-LICENSE.txtdist/licenses/react-LICENSE.txtdist/licenses/react-dom-LICENSE.txtdist/licenses/tanstack-react-query-LICENSE.txtdist/licenses/viem-LICENSE.txtdist/licenses/wagmi-LICENSE.txtdist/licenses/wagmi-connectors-LICENSE.txtdist/licenses/wagmi-core-LICENSE.txtdist/official/SqueekCore.jsondist/official/SqueekMining.jsondist/official/SqueekNFT.jsondist/official/SqueekToken.jsondist/official/provenance.jsondist/official/sepolia.jsondist/partners.svgdist/social.pngdist/wallets/coinbase.svgdist/wallets/metaMask.svgdist/wallets/phantom.svgdist/wallets/rabby.svgdocs/DESIGN.mddocs/frontend/COVERAGE.mddocs/frontend/THIRD_PARTY_NOTICES.mddocs/frontend/VALIDATION.mddocs/frontend/evidence/final-live-desktop.pngdocs/frontend/evidence/final-live-mobile.pngdocs/frontend/evidence/final-wallet-sheet.pngdocs/frontend/export-audit.jsondocs/frontend/interaction-results.jsondocs/frontend/licenses/Better-Interface-and-Impeccable.txtdocs/frontend/licenses/ethskills.txtdocs/frontend/live-read-check.jsondocs/frontend/scope-audit.jsondocs/frontend/taste-results.jsonweb/.gitignoreweb/README.mdweb/deployment-handoff.jsonweb/index.htmlweb/network-handoff.jsonweb/package-lock.jsonweb/package.jsonweb/playwright.config.tsweb/public/abi/SqueekMatchEscrow.jsonweb/public/favicon.svgweb/public/licenses/RainbowKit-LICENSE.txtweb/public/licenses/coinbase-wallet-sdk-LICENSE.txtweb/public/licenses/fontsource-variable-dm-sans-LICENSE.txtweb/public/licenses/lucide-react-LICENSE.txtweb/public/licenses/react-LICENSE.txtweb/public/licenses/react-dom-LICENSE.txtweb/public/licenses/tanstack-react-query-LICENSE.txtweb/public/licenses/viem-LICENSE.txtweb/public/licenses/wagmi-LICENSE.txtweb/public/licenses/wagmi-connectors-LICENSE.txtweb/public/licenses/wagmi-core-LICENSE.txtweb/public/official/SqueekCore.jsonweb/public/official/SqueekMining.jsonweb/public/official/SqueekNFT.jsonweb/public/official/SqueekToken.jsonweb/public/official/provenance.jsonweb/public/official/sepolia.jsonweb/public/partners.svgweb/public/social.pngweb/public/wallets/coinbase.svgweb/public/wallets/metaMask.svgweb/public/wallets/phantom.svgweb/public/wallets/rabby.svgweb/scripts/generate-social.mjsweb/scripts/manifest.mjsweb/scripts/serve.mjsweb/src/App.tsxweb/src/Board.tsxweb/src/Merge.tsxweb/src/Planner.tsxweb/src/Singles.tsxweb/src/Trade.tsxweb/src/Transactions.tsxweb/src/Wallet.tsxweb/src/components.tsxweb/src/config.tsweb/src/domain.tsweb/src/indexer.tsweb/src/main.tsxweb/src/service.tsweb/src/styles.cssweb/src/types.tsweb/src/vite-env.d.tsweb/tests/accessibility.spec.tsweb/tests/app.spec.tsweb/tests/domain.spec.tsweb/tests/mock-chain.tsweb/tsconfig.jsonweb/vite.config.ts
    may write
    web/**dist/**docs/**web/.gitignore
  15. Design criticAgent #783found 3 medium
    AreaResult
    Requirements: 42 groups36 met; 0 missing; 0 faked; 4 need requester; 2 after review
    Broken features3 medium findings
    First screen at 390pxProduct and main action visible
    Taste@116/16 passed
    Copy1 misleading empty state
    Share cardPresent, 1200 × 630

    Recorded three reproducible findings in .imd-findings.json:

    • Accessibility: “Skip to content” navigates to My singles and discards the current form.
    • Bug: Failed order-book reads appear as an empty market.
    • Bug: Editing an offered NFT during a pending preview can prepare a different NFT from the one inspected.

    Build/typecheck passed, the rebuilt export matched exactly, all 41 supplied Chromium tests passed, and eight targeted WebKit checks passed. Asset hashes and the pinned ABI matched. No transaction was signed.

    The visual idea is clear: a warm-paper Squeekchain tool carried by paired hamster illustrations.

    The checklist below combines duplicated workflow requirements. Component names refer to web/src/.

    RequirementStatusEvidence
    “static, no backend”Metdist/, web/vite.config.ts
    “non-custodial, fee-free … Sepolia”MetEscrow source; runtime chain configuration
    “no owner, no proxy … events for every action … Reentrancy guard. Tests for all paths”Met, inherited contract stagesrc/SqueekMatchEscrow.sol, existing contract tests
    “verified on Etherscan”Needs requester: prior-stage verification evidenceExplorer blocked this review; handoff provides no verification result
    Ask: wallet-held NFT with approvalMetTrade.tsx, service.ts approvals
    Bid: escrowed SQK, chosen level, optional merge-ready filterMetTrade.tsx, escrow ABI
    Swap with SQK difference from either sideMetTrade.tsx swap controls
    “create/fill/cancel … my orders”Met, with findings aboveTrade.tsx, interaction tests
    Default seven-day expiry, maximum 30 days, refundsMetTrade form and cancellation review
    Level and eligibility checked at fillMetEscrow source; transaction simulation
    “connect wallet or paste address”MetWallet.tsx, Singles.tsx; both browsers
    Paired/single counts, cooldowns, deposit stateMetSingles.tsx; unavailable custody data disclosed
    “lost power … SQK/day”Needs requester: matching deployment pace dataEstimate implemented; current mismatch displays unavailable
    Planner: most merges, fewest purchases, stated exampleMetdomain.ts, planner tests
    Cost, power gained, cooldown time at hamster paceNeeds requester: matching pace for time estimateCost/power/block estimates present in Planner.tsx
    “one click turns a path into bids”MetPlanner bid queue and review
    All-holder singles from events, IndexedDB, filtersMetBoard.tsx, indexer.ts
    “fair-value tag per listing”MetTrade.tsx comparisons
    Buy then Merge: survivor, power preview, official mergeMetMerge.tsx, post-fill selection
    “Staking mergeDeposited”Needs requester: matching official integrationDeployment supplies no matching Staking address/ABI; limitation disclosed
    Floor ask, best bid by level, last fillsMetApp.tsx market sidebar
    Six named API endpoint families, display onlyMetservice.ts, Trade.tsx
    “every transaction reads the contracts”MetTransactions.tsx
    Verify documentation/API identities; refuse mismatchMetservice.ts; live mismatch correctly pauses trading
    Sepolia-only; dry-run before every transactionMetconfig.ts, Wallet.tsx, Transactions.tsx
    Warn seller about unclaimed SQKMetSeller acknowledgments and reward facts
    Explain withdrawal before trading deposited NFTsMetTrade and Merge notices
    Show cooldown/scheduled power before buying
    ran oncodex · gpt-6.1-sol · 7 turns · 11m 6s · 260.4K in · 24.9K out · 3.6M cached
    submission837297b2839ba82853eacd459b13e2dfc92e6678d591968cd3393762618068d0
    device40b27a75f026cec9465402ac245284bd18e35a7ce6af76fbbf65680b603b1e4e
    started from4837569daecfc78b65ab1c8694ce978a2c96c823
    bundlenone
    applied on9f68d025146daacb48db0cecb6436cff07edc0acc207506ed2eae78fdbe65d61
    may write
    web/**dist/**docs/**web/.gitignore
    • mediumaccessibility: Skip to content navigates away and discards the current formweb/src/App.tsx:281

      Now: The skip link changes the routing hash to #main. currentPage() treats that hash as My singles, so using the keyboard shortcut from Trade, Chain planner, Match board or Merge replaces the current view and discards its local form state.

      Change to: Handle the skip-link activation with preventDefault(), focus the existing main element and scroll it into view while preserving the current route hash.

      Why: A skip link must move past repeated navigation to the current content; it must not navigate to another tool or erase the visitor’s inputs.

      Viewport 1440 x 1000, Chromium: open /preview/#trade, enter 42 in Your Squeek ID, focus Skip to content and press Enter.

      Measured result: location.hash becomes #main, the h1 changes from Trade Squeeks, peer to peer. to My singles, and focus lands on main.

      Return to Trade: the Squeek ID is empty.

      Expected: remain on #trade with 42 preserved and focus moved to its main content.

      This uses no wallet or signature.

    • mediumbug: a failed order-book read is presented as an empty marketweb/src/Trade.tsx:492

      Now: The Orders panel tests only filtered.length. Before the first read completes and after an RPC log failure, it says "No orders to show" and invites the visitor to "create the first order". The actual error appears only in the separate Market pulse sidebar, below the Orders panel on a phone.

      Change to: Pass orderLoading, orderLoaded and orderError from App into Trade. Show "Loading orders…" while reading and "Orders could not be loaded. Refresh orders to try again." on failure; render the empty-market message only after a successful empty response.

      Why: The loading, empty and failure states must say only what the app knows. A failed scan does not establish that there are no orders.

      Viewport 390 x 844 (also reproduced at 1440 x 1000), Chromium: use web/tests/mock-chain.ts setupMock(page, {failLogs:true}), load the export, open Trade and wait for the Market pulse error alert after eth_getLogs fails.

      Scroll to Orders.

      Actual panel text is "No orders to show" / "Refresh the on-chain index or create the first order." even though no order book was obtained.

      The mock has four existing orders.

      Expected: an unavailable state with Refresh orders, without claiming the market is empty.

      The same false empty message is visible during the initial pending read.

    • mediumbug: an in-flight Squeek preview can validate a different offered NFTweb/src/Trade.tsx:621

      Now: The offered-ID field stays editable during inspect(). Changing it clears inspected, but the older request subsequently restores inspected=true and displays the old NFT under "You give". Review trade then prepares the newly entered NFT using the old NFT’s reward, power and cooldown preview.

      Change to: Disable the offered-ID input while pending, bind completed inspection to its captured token ID, and require that ID to match the current input before enabling Review trade. Reset the rewards acknowledgment when the ID changes.

      Why: The live facts and seller acknowledgment must describe the Squeek actually being offered; successful inspection of one token cannot validate another.

      Viewport 1440 x 1000, Chromium, using the supplied mocked Sepolia fixture (no real wallet/signing): connect Mock Squeek Wallet, open Trade, look up swap 3, enter offered ID 2.

      Delay the display request https://api.squeekchain.com/v1/squeeks/2 after clicking Check live Squeek details.

      While it is pending, change the still-enabled offered ID to 3, then release the response.

      Actual: the input is 3, You give shows #2, and Review trade becomes enabled after checking the rewards reminder.

      Click Review trade without signing: the next dialog says "Send #3 and 0 SQK.

      Receive #6 and 3 SQK." and "Approve Squeek #3".

      Expected: changing the ID invalidates the in-flight result and requires a preview of #3 before trade review.

      All transaction counters remain zero.

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