Three independent Codex reviews — contract review, hardening re-review, frontend review. 0 Critical, 2 High, 3 Medium, 3 Low. Every finding fixed or answered, then re-audited. Unedited below.
REPORT 01 · 2026-07-12 · EXTERNAL CONTRACT AUDIT
Codex external contract audit — KALEIDO. Status: RESOLVED by PR #34 (VEG2-1153, tag abi-freeze-v3). Snapshot audited: main @ 4ba5ee1. All findings addressed — see docs/security-notes.md §7. Relocated into docs/reviews/ per VEG2-1154 item 8.
KALEIDO independent smart-contract security audit
Audit snapshot: 2026-07-12, main at 4ba5ee1c6b8e223fd720445a8c35a094e54420b7.
Scope: KaleidoToken.sol, ClaimRouter.sol, V4SwapLib.sol, PriceLib.sol, KaleidoMirror.sol, and KaleidoRenderer.sol, plus inherited library behavior and deployment/test code where needed to establish reachability. This was a read-only engagement; no frontend review was repeated.
Executive summary
Overall risk posture: the runtime accounting and claim design are substantially stronger than the deployment boundary around them.
No Critical issue was found.
The most important finding is High: seed() silently trusts any pre-existing v3 pool price and does not prove the full reserve entered the locked position.
A second High issue lets an immutable dev receiver that rejects ETH permanently stop every nonzero fee harvest.
The stated anti-JIT property is weaker than advertised: pre-harvest LP fees can be captured atomically by a new holder, with no on-chain cap on the backlog.
The “O(1) transfer” test proves history independence for one Share, while the implementation is O(number of whole units changed).
Default tests passed (88 passed, 12 fork tests skipped); all 11 mainnet v3/v4 fork tests passed when explicitly enabled.
I would not consider the immutable deployment mainnet-ready until both High findings are fixed and regression-tested.
Method and verification
Traced every state-changing path in the six scoped contracts, the relevant Solady ERC-20/Ownable/ReentrancyGuard behavior, the v4 router action implementation, deployment script, invariants, attack suite, pause drill, and fork tests.
forge build passed.
Default forge test: 88 passed, 0 failed, 12 skipped. The skipped set was 10 v3-mainnet tests, one v4-mainnet test, and one testnet chain-id test.
Re-ran with RUN_FORK_TESTS=1 against the repository's public Robinhood mainnet RPC: all 10 v3 tests and the v4 claimInto success test passed. The v3 multi-holder run closed with 141 wei of documented dust.
Direct read-only mainnet eth_call executed TSTORE/TLOAD and returned 1, confirming EIP-1153 support for the mirror's silent flag at the audited chain state.
eth_getCode confirmed non-empty code at the configured Permit2, v3 factory, v3 NPM, WETH, and UniversalRouter addresses. This establishes existence, not code-hash identity.
Findings
High H-01 - seed() can lock the reserve at a pre-initialized attacker price
The deployment script broadcasts new KaleidoToken(...) and the later token.seed(...) as separate transactions.
The token address is predictable from the deploying account and nonce. Anyone can create the canonical KALEIDO/WETH/1% v3 pool before seed() executes, including before the token is deployed.
seed() calls createAndInitializePoolIfNecessary(...) and trusts the returned pool without reading slot0.
The official Uniswap helper only applies sqrtPriceX96 when the pool is absent or uninitialized; an initialized pool keeps its existing price. See the official PoolInitializer.sol.
seed() then mints with both minimum amounts set to zero and ignores the returned amount0/amount1. It permanently sets seeded, stores the position ID, and exposes no retry/recovery path.
Concrete failure scenario: assume KALEIDO sorts as token0. The intended deployment uses tick 69,000 and range [69,000, max]. An attacker initializes the same canonical pool at any materially lower tick before the seed transaction. Because the current price is below the lower bound, the protocol mint remains token0-only and may succeed, but the market is now launched at the attacker's price rather than the supplied price. If the existing price is inside or on the wrong side of the range, minting can instead revert and force a redeployment. More generally, any partial KALEIDO consumption accepted by the NPM leaves the remainder permanently in the core because seeded cannot be retried and there is no reserve recovery function.
The current fork test does not cover this. It always creates a fresh pool and only asserts that the residual core balance is below 1e15 wei of KALEIDO. The deterministic mock is weaker still: its mint always pulls the entire desired supply.
Broken invariant: invariant 4's “100% supply seeded / the pool is the reserve” premise, plus the immutability requirement that a wrong launch price cannot become permanent.
Impact: a predictable-address pool-init front-run can corrupt launch price/geometry or deny the intended deployment. A partial successful mint can permanently strand reserve tokens outside the locked position. This is a launch-critical configuration failure even though the attacker cannot directly withdraw the core's tokens.
Suggested remediation:
After createAndInitializePoolIfNecessary, require that the returned address equals IUniswapV3Factory(v3Factory).getPool(token0, token1, POOL_FEE).
Read pool slot0 and require the actual sqrtPriceX96 (or exact intended tick, with a documented tolerance) equals the supplied launch value before minting.
Capture amount0, amount1, and liquidity; require nonzero liquidity, zero WETH consumption, and full KALEIDO consumption except for a deliberately specified rounding tolerance.
Revoke the NPM allowance after mint.
Add a regression test with a pre-created, attacker-initialized pool and a mock NPM that returns a partial fill.
Use a private/atomic deployment flow as defense in depth, but do not rely on transaction ordering instead of the on-chain checks.
High H-02 - A rejecting immutable dev address permanently blocks fee harvesting
Vulnerable path:pokeFees() collects fees, updates the accumulator, burns collected KALEIDO, pays the caller bounty, then pushes toDev with _sendEth(dev, toDev). _sendEth reverts if the receiver rejects ETH. Because dev is immutable, the same failing interaction is present in every future nonzero harvest. The revert rolls back the collect and all accounting, so fees remain in the v3 position but can never be credited while the receiver keeps rejecting.
Concrete failure scenario: deploy with DEV_ADDRESS set to a treasury/proxy whose receive reverts (or whose implementation later upgrades/pauses into that state). After holders exist and the position accrues 1 ETH, any pokeFees() computes a positive dev share, reaches line 358, and reverts. A different poker does not help because the failing recipient is always the immutable dev.
Tests always use an EOA-like dev address or a test contract that accepts ETH, so this liveness dependency is not exercised.
Broken invariant: the claimed “funds cannot be stuck” posture and the standing claim that accrual continues independently after immutable setup. Accounting conservation is not violated because the transaction reverts atomically; liveness is.
Impact: all future holder/dev LP-fee revenue remains unharvestable. Existing already-credited pending remains claimable, but new fees cannot enter the accumulator. With an immutable non-receiving dev contract this is permanent.
Suggested remediation: make dev distribution non-blocking. Accrue devOwed in storage and let the dev pull to a recipient it chooses, or attempt the push and retain failed value as withdrawable debt. Preserve CEI and nonReentrant. Add a deterministic rejecting-dev test proving pokeFees still advances holder accounting and that dev debt remains fully backed.
Medium M-01 - The accepted pre-harvest JIT capture has no on-chain bound
Status: CONFIRMED mechanism; PLAUSIBLE profitable exploit depending on backlog, liquidity, and execution costs
Vulnerable path: a newly minted Share snapshots the current accEthPerShare, but LP fees sitting uncollected in the v3 position are not represented in that accumulator. A trader can acquire whole KALEIDO, call pokeFees, claim the just-harvested entitlement, and sell in one atomic transaction. The Share therefore receives fees generated before it existed.
Concrete scenario: 100 honest Shares are live and the v3 position has 10 ETH of unharvested WETH fees. An attacker atomically acquires 100 Shares, making the denominator 200, then pokes. Ignoring the 0.05 ETH cap because the percentage bounty is smaller here, holders receive about 8.9775 ETH after bounty and the 90/10 split; the attacker's new Shares receive about 4.48875 ETH before rounding. The attacker claims, then sells. Profitability is parameter-dependent, but it is positive whenever that captured amount exceeds the 1% in/out fees, price impact, gas, and financing cost.
The security notes call this bounded because poking is permissionless and economically incentivized. Those are operational incentives, not an on-chain bound: neither backlog age nor backlog size is capped, and the transaction sequence is atomic. Attack test 2 proves only that the new Share earns less than an early Share, not that it earns zero historical fees or that the round trip is always unprofitable.
Broken invariant: invariant 3 as written (“no way to acquire a Share and capture accrual for a period you did not hold”). The narrower property “no Share captures already-accounted accumulator value” does hold.
Impact: redistribution of historical LP fees from longer-term holders to a sufficiently capitalized JIT trader. It does not create unbacked ETH or drain more than the real harvest.
Suggested remediation: either narrow the public invariant and explicitly accept/monitor the economic exposure, or introduce a mechanism that attributes unharvested fees before denominator changes/time-weights new Shares. A keeper SLA alone is not a cryptographic fix. Add a fork test for the exact buy -> poke -> claim -> sell atomic sequence over a parameter grid and publish the break-even backlog.
Medium M-02 - Transfer synchronization is O(whole units changed), not O(1)
Vulnerable path:_realignPair loops once for every transferred, burned, and newly minted Share; _realignSolo loops once for every Share minted or burned. Each iteration performs multiple storage operations and an external call to the immutable mirror to emit an ERC-721 event.
Concrete failure scenario: after accumulating 1,000 whole KALEIDO through smaller transactions, a holder transfers or sells all 1,000 in one call. The hook executes 1,000 _move or _burnNFT iterations. A sufficiently large transfer exceeds the finite block gas limit and reverts. The same limit applies to a large v3 swap that sends or receives many whole units because the pool's ERC-20 transfer invokes the hook.
The advertised gas test is not vacuous for history growth, but it tests exactly one Share before and after heavy history. It proves O(1) in historical epochs/pokes/forfeits for a one-Share move; it does not test complexity in transfer amount.
Broken invariant: invariant 5 as broadly written. A single-Share mint/move/burn is constant with respect to history, but an arbitrary ERC-20 transfer is O(k), where k is the number of whole-unit boundaries crossed.
Impact: large trades/transfers can revert and must be manually split. Funds are not permanently trapped because smaller transfers remain available, but the gas ceiling can surprise routers/integrators and fragments large exits.
Suggested remediation: accurately scope the invariant and enforce/document a maximum whole-unit delta per transaction with wallet/router chunking, or redesign synchronization lazily if truly arbitrary-size O(1) transfers are required. Add gas tests across 1, 10, 100, 1,000, and 5,000 Share deltas on mint, move, and burn paths.
Medium M-03 - One-shot router wiring validates only nonzero
Vulnerable path:setClaimRouter accepts any nonzero address and permanently consumes the one-shot. It does not require code or verify that the router's immutable core points back to this token.
Concrete failure scenario: an operator passes an EOA, wrong-chain copied address, wrong router instance, or a contract with a mismatched core. claimRouter becomes permanently nonzero. No valid router can ever call settleClaim; all pending ETH stays in the core indefinitely. The current deployment script constructs the right object programmatically, which reduces likelihood but does not make the contract invariant self-enforcing.
Broken invariant: immutable setup safety; a one-shot setter can permanently disable every claim despite accepting its input.
Impact: catastrophic claim liveness from a deployment error, with low attacker reachability because onlyOwner is correctly enforced.
Suggested remediation: require router.code.length > 0 and perform an interface handshake such as IClaimRouter(router).core() == address(this) before consuming the one-shot. Add negative tests for EOA, wrong core, reverting getter, and correct router. Apply equivalent code/code-hash preflight checks to NPM, WETH, factory, UniversalRouter, guardian, and dev in the deployment runbook.
Low L-01 - ETH fallback is not universal for contract holders
Vulnerable path: both direct claim and the claimInto catch branch push native ETH to msg.sender and revert if the receiver rejects it. The pause runbook states that any failed stock swap always delivers ETH and that nothing becomes stuck, but that assumes the holder can receive native ETH.
Concrete failure scenario: a contract owns Shares, can call the router, but has a reverting or absent payable receive path. It calls claimInto for a paused stock. The swap reverts as expected; _sendEth(msg.sender, ethIn) then reverts too, rolling back the entire claim. Repeating cannot succeed while both conditions remain.
Broken invariant: the unconditional wording of automatic ETH fallback. This is holder-specific, not a cross-holder DoS; the contract may be able to transfer its Shares or upgrade its receive logic.
Suggested remediation: allow a holder-selected payout recipient, or fall back again to WETH when native ETH delivery fails. Ensure any recipient option remains holder-authorized and cannot be supplied by the guardian/frontend without the holder's signature.
Low L-02 - Every holder implicitly grants the canonical Permit2 address unlimited allowance
Vulnerable path: Solady ERC20's default _givePermit2InfiniteAllowance() returns true. Consequently, allowance(holder, Permit2) reports type(uint256).max, and transferFrom skips allowance checks when called by 0x000000000022D473030F116dDEE9F6B43aC78BA3. KaleidoToken does not override the default.
Concrete failure scenario: a vulnerability, malicious upgrade, or incorrect deployment at that address can call transferFrom for any holder's full KALEIDO balance without an ERC-20 approval. Mainnet currently has code at the address, but this audit did not establish its exact code hash or governance.
Broken invariant: none of the seven accounting invariants; this expands the token's external trust boundary beyond the documented v3/NPM and claim components.
Impact: dependency compromise would expose all holder balances. The likelihood is tied to the canonical Permit2 deployment, but KALEIDO itself does not use Permit2 for its v3 pool or native-ETH claim input.
Suggested remediation: if implicit Permit2 support is not required, override _givePermit2InfiniteAllowance() to return false. If retained, document Permit2 as a critical immutable dependency and pin/verify its mainnet code hash in the deployment runbook.
Informational I-01 - “Zero custody” must exclude unsolicited transfers
Any account can transfer ERC-20 stock tokens to the core/router, and either contract can receive raw/forced ETH. There is no rescue path. Attack test 5 deliberately donates ETH and stock to these contracts but only checks that the accumulator does not move; it does not and cannot prove literal zero balances afterward.
This is not a protocol drain: unsolicited assets are not credited, mixed into claims, or withdrawable by an attacker. The protocol-directed property is solid: successful claimInto takes output directly to the holder, and fallback leaves no claim ETH in the router. Public claims should say “the protocol never routes or intentionally custodies stock” rather than “the contract never holds a stock token.”
Invariant assessment
#
Claimed invariant
Verdict
Evidence
1
Master accounting (SATO)
Held
pokeFees, settlement, inline forfeiture, zero-survivor parking, and sweep conserve backing with floor dust retained in the core. Deterministic, invariant, and real-v3 tests closed; the real multi-holder run left 141 wei dust. H-02 is liveness, not phantom accounting.
2
Measured-delta credit
Held
ETH is measured around canonical collect + exact returned-WETH unwrap; pre-existing ETH/WETH/KALEIDO donations remain outside the delta. Reentrancy points occur after measurement or are the trusted WETH receive. Donation tests and real-v3 fork test passed.
3
JIT impossible
Weaker-than-claimed
feeDebt blocks already-accounted accrual and inline post-burn redistribution closes the forfeiture window. Unharvested LP fees generated before ownership are nevertheless capturable by buy -> poke -> claim -> sell (M-01).
4
Zero custody + liquidity locked
Weaker-than-claimed
No decrease/burn/transfer/approval path exists for the position NFT, and protocol-directed stock output goes to the holder. H-01 means correct 100%-reserve seeding is not enforced; unsolicited stock/ETH can also remain forever, so literal zero custody is false.
5
O(1) transfer hooks
Weaker-than-claimed
Per-Share work is constant and independent of history, but a transaction crossing k whole-unit boundaries runs k iterations (M-02). The existing gas test covers only k=1.
6
Bounded guardian
Held
Guardian reachability is limited to propose/commit/remove. Additions restart/enforce 48 hours; removal is instant; no key-fix, accrual, core-fund, or position authority exists.
7
Bounded arbitrary hooks
Weaker-than-claimed
Exact native input caps cross-holder loss, TAKE fixes the recipient, core debt is settled before swap, and reentry is blocked. However the contract accepts arbitrary return-delta hooks and caller-supplied minOut; the documented worst case includes a successful single-claim loss, not only revert -> ETH fallback. Actual malicious-hook semantics were not exercised by the mock venue.
What is genuinely solid
The accumulator uses real received ETH, not oracle/internal mirror values. Display/PriceLib data cannot mint entitlement.
feeDebt is snapshotted on mint, stays with a moved Share, and is written before ETH leaves during settlement.
Inline forfeiture decrements totalShares before redistributing over survivors. The multi-burn cascade remains conserved, and the zero-survivor floor is safely parked.
Duplicate IDs cannot double-claim: the first iteration updates debt; later duplicates add zero. Ownership failure reverts atomically.
The position NFT is minted to the core, and scoped code contains no decrease-liquidity, transfer, burn, or approval path.
claimInto is exact-input native ETH, uses the holder as TAKE recipient, and wraps the swap in an external self-call so reverts restore the ETH before fallback.
Router/core reentrancy guards plus CEI block the tested claim and swap reentry paths. The immutable mirror's event callbacks contain no user-controlled call.
Registry enumeration/removal bookkeeping is correct; full pool-key changes require remove plus a fresh 48-hour proposal.
Renderer failure is isolated from money and transfer paths. PriceLib is advisory and cannot affect accounting.
The real mainnet v3/v4 integration tests passed when explicitly enabled, including exact claims, donation immunity, forfeiture, locked-liquidity observations, and successful stock delivery.
Could not verify
No production KALEIDO deployment exists in the audited address package, so constructor/setter parameters, pre-existing pool state, launch transaction privacy, ownership renunciation, and final code hashes could not be checked.
The mainnet dependency addresses had code, but this audit did not independently reproduce and compare canonical bytecode hashes for v3 factory/NPM/WETH, v4 PoolManager/PositionManager/UniversalRouter, or Permit2.
The v4 success fork test builds a no-hook mock stock pool. No real adversarial beforeSwapReturnDelta/afterSwapReturnDelta hook was deployed against the actual PoolManager, so invariant 7's residual bound remains code-reasoned rather than end-to-end demonstrated.
JIT profitability is market-state dependent. The value-transfer mechanism is certain; profitable thresholds require final launch price, liquidity curve, fee backlog, gas, and financing assumptions.
Live Chainlink stock-feed addresses, heartbeat/staleness policy, decimals, market-hours behavior, and ERC-8056 token implementations were not final. PriceLib is currently advisory and unused by on-chain accounting.
No formal verification or independent symbolic-execution engine was run. Foundry fuzz/invariant campaigns and manual state-machine reasoning were used.
Codex differential re-audit — KALEIDO hardening. Status: RESOLVED by PR #34 + the VEG2-1154 follow-up PR. Snapshot re-audited: main @ 7be0113 (tag abi-freeze-v3). All five re-audit findings folded into VEG2-1154 (L-03 code + I-02/M-02/M-03R docs) — see docs/security-notes.md §7. Relocated per item 8.
KALEIDO differential hardening re-audit
Audit snapshot: 2026-07-12.
Reviewed patch: 4ba5ee1c6b8e223fd720445a8c35a094e54420b7..3de23c3b616d19c6134bc153380791a075091181 (origin/wasa/veg2-1153-audit-hardening). The hardening commit has already been merged as main commit 7be0113; the branch and main have the identical tree 6c6fab7db0ae83b4dd865f4b08a88ef1411d614d.
Scope was differential: the hardening patch, the seven findings in CODEX-CONTRACT-AUDIT.md, and unchanged code only where a new call or state term interacts with it.
Executive verdict
No new Critical or High vulnerability was found. The H-01 reserve-safety checks and H-02 pull accounting are materially sound under the configured canonical v3 contracts. I found no pool state that passes the H-01 canonical-pool, exact-price, zero-WETH, near-full-reserve, and nonzero-liquidity checks yet strands a material reserve balance.
I would not, however, mark all seven original findings unconditionally closed:
H-02 and L-02 are closed.
H-01 closes bad-price launch and reserve-stranding safety, but the public two-transaction deployment remains repeatably griefable. This residual is correctly acknowledged in the patch.
L-01 closes the ordinary rejecting-receiver case when the canonical WETH is wired, but the new WETH dependency is not bound to the core's WETH.
M-03 is only partially closed. The handshake rejects accidental EOAs and wrong-core instances, but a hostile router can lie about core() and then steal settled claims.
M-01 and M-02 were accepted and documented, not remediated. Their narrower claims are mostly honest.
Closure matrix
Original
Closure
Regression
Re-audit verdict
H-01 seed front-run / partial fill
Safety closed; liveness residual
No harmful all-checks-pass state found
Wrong-price launch and material reserve stranding now revert. A public two-tx launch remains indefinitely griefable and must use the documented atomic/private flow.
H-02 rejecting dev
Closed
None found
devOwed is backed, cannot be double-paid, and a rejecting dev no longer blocks harvests.
M-01 pre-harvest JIT
Accepted, not remediated
No code regression
The capture remains possible and was reproduced by the added fork test. Keeper activity is operational mitigation, not an invariant.
M-02 O(k) transfers
Accepted/documented
No code regression
The new scaling test and docs now state the linear whole-unit cost and chunking requirement.
M-03 router wiring
Partially closed
New WETH parameter is not covered by the handshake
EOA/wrong-core mistakes are rejected; semantic authenticity is not established. See M-03R.
L-01 rejecting holder
Closed for canonical wiring; partial overall
Unverified WETH creates a new configuration failure mode
Normal ETH rejection falls back safely to canonical WETH. See L-03.
L-02 Permit2 implicit allowance
Closed
None found
The Solady opt-out works; all constructor call sites compile and real claim paths still pass.
Findings
Medium M-03R — core() is a forgeable assertion, not router authentication
setClaimRouter now requires code and exactly 32 return bytes decoding to this core. That closes the EOA, missing getter, reverting getter, and honest wrong-core cases tested in AuditHardening.t.sol.
It does not prove that the contract is ClaimRouter or that it will pay holders. A hostile contract can expose core() == address(token), pass the one-shot handshake, then call:
token.settleClaim(tokenIds, actualHolder);
settleClaim verifies that actualHolder owns the IDs, snapshots their feeDebt, and sends the ETH to msg.sender — the registered hostile router. The hostile router keeps the ETH. Ownership and token IDs are publicly discoverable, so the holder need not cooperate. The one-shot makes this irreversible for all current and future pending claims.
A mutable/proxy router can likewise return the expected core during wiring and change behavior later. Checking more getters would catch honest misconfiguration but cannot authenticate hostile semantics.
Impact: catastrophic claim theft or permanent claim failure after an owner/deployment compromise or malicious wiring. Attacker reachability remains owner-gated, so the original Medium severity is retained.
Recommendation: bind wiring to a trusted non-upgradeable deployment path rather than a self-attested getter — for example, have the core/trusted factory deploy the router, validate a deployment-factory attestation, or verify the exact expected runtime construction and reject proxies. Independently validate all immutables for honest configuration errors.
Low L-03 — the new fallback WETH is not bound to the core's canonical WETH
The ClaimRouter constructor checks _weth != address(0) but not code, WETH behavior, or equality with KaleidoToken.weth(). setClaimRouter checks only core(). Therefore an otherwise genuine, correct-core ClaimRouter with the wrong fourth constructor argument passes the irreversible handshake.
For a holder that rejects ETH:
an EOA/non-WETH address makes the fallback revert, so the claim rolls back and remains pending but unusable by that holder;
a malicious contract can accept deposit, return true from transfer without delivering canonical WETH, and consume the claim ETH after the core has zeroed the holder's pending debt.
The checked Deploy.s.sol correctly passes the same wethAddr to both constructors, all Solidity construction sites were updated, and the web ABI includes the fourth argument. This is therefore an unenforced invariant rather than a present script typo.
Recommendation: expose weth() in IKaleidoCore and require _weth == IKaleidoCore(_core).weth() plus _weth.code.length > 0 in the router constructor. Deployment preflight should still verify the canonical WETH bytecode for the target chain.
Informational I-02 — two test/documentation claims exceed what is demonstrated
attack-report.md calls test_Fork_V3_M01_JITPreHarvestCapture a "fork break-even test." The test buys, pokes, and claims, then stops. It does not sell, measure resale proceeds/price impact/gas, or compute a break-even backlog. It proves the capture mechanism, not round-trip profitability. In this run it showed 199 attacker Shares claiming 2,269,464,557,926,805 wei from a pre-existing backlog.
_payHolder NatSpec and security-notes.md say the claim "always settles." With canonical WETH this is correct for an ordinary reverting/nonpayable receiver, but not under the unbound-WETH configuration above. Also, the first native call forwards all available gas, so a receiver can deliberately increase the gas required to reach the WETH fallback. This is holder-local and retryable with adequate gas, not a cross-holder DoS, but "always" is stronger than the implementation guarantees.
Rename the M-01 test/description to "capture mechanism and break-even inputs," or add the sell leg and an explicit profitability equation. Scope the L-01 statement to a correctly wired canonical WETH and ordinary receiver rejection.
Focus-area analysis
H-01 — seed hardening
The safety portion is effective under the canonical v3 factory/NPM assumption:
The returned pool must be the factory's pool for the exact pair/fee, and slot0.sqrtPriceX96 must equal the intended launch price immediately before the mint (KaleidoToken.sol:318-328). There is no attacker transaction boundary between the price read and npm.mint.
_seedMint requires nonzero liquidity, zero reported WETH use, and at least SUPPLY - 1e9 KALEIDO used (KaleidoToken.sol:343-371). Canonical NPM returns the pool's actual mint amounts; KALEIDO is a plain protocol token, so no transfer-tax discrepancy exists.
The residual ERC-20 allowance is reset to zero after all checks (KaleidoToken.sol:330-335). Any failed check reverts the mint and the temporary approval atomically. After success, the dust cannot later be pulled by NPM.
An attacker can pre-create the canonical pool at the exact intended price and add a WETH-only position on the opposite side of the boundary. That state can pass. It does not strand protocol reserve: at the boundary the attacker's WETH-only range is out of range, while an active position would require KALEIDO that no attacker owns before seed. With no active liquidity, fee-generating swaps/flash operations cannot run before the protocol mint. Third-party liquidity below/above the launch range can also be added after seed; it is an ordinary permissionless-v3 property, not a pre-seed bypass.
No feasible pre-mint fee-growth state was found. Before deployment the predicted KALEIDO address has no ERC-20 balanceOf implementation, and after deployment all supply remains in the core until the atomic mint. Even if historical fee growth were somehow present, a new v3 position snapshots current inside fee growth and does not inherit prior fees.
The remaining issue is liveness. A wrong-price pre-initialization now safely fails SeedPriceMismatch, but the shipped Deploy.s.sol still broadcasts deployment and seed as separate transactions. An attacker can repeat the cheap wrong-price initialization for every observable token address and force redeployment indefinitely. The patch accurately documents this at Deploy.s.sol:10-17 and security-notes.md:135-143. Treat atomic deployment+seed or a private bundle as a launch requirement, not optional defense in depth. If the public two-transaction flow is used, this residual is Medium launch DoS.
The real v3 fork seeded successfully with 4 wei of KALEIDO reserve dust, within the 1 gwei bound.
H-02 — devOwed, pokeFees, and withdrawDev
For every measured harvest E, the code computes E = bounty + toHolders + toDev exactly, increments the accumulator by the floored holder portion, and increments devOwed by toDev. After the bounty leaves, the core retained balance rises by toHolders + toDev; claim floors account for holder dust. withdrawDev decreases both core ETH and devOwed by the same amount. Thus:
withdrawDev is dev-only and nonReentrant; it zeroes debt before the external call, and a failed send reverts the zeroing. Reentry into guarded core money paths fails, so no double withdrawal or double claim was found. Existing devOwed is excluded from later pokeFees credit because ethBefore is measured before collection. Raw ETH forced in after measurement remains uncredited dust rather than inflating debt.
A contract configured as dev must be capable of originating withdrawDev; a rejecting but inert contract can leave its own share inaccessible, but it no longer blocks holders or future harvests. The dedicated rejecting-dev test uses a contract that can pull to an alternate recipient.
The updated invariant suite materially constrains this change. Its independent ghost tracks intended holder credit while reading live devOwed, so inflation, under-accrual, a push plus accrual, or a withdrawal that changes debt and balance unequally breaks the exact equation. In the default run, the handler exercised withdrawDev roughly 1,000 times per invariant predicate with zero reverts.
L-01 — WETH payout fallback
With canonical WETH, the new flow is sound for the original rejecting-holder case:
the router guard blocks recursive claim/claimInto;
core feeDebt is updated before payout;
if the ETH call reverts, its subcall state is rolled back, the router still owns amount, and canonical WETH deposit plus transfer has no receiver callback;
if WETH deposit/transfer fails, the whole claim reverts, restoring core debt and ETH, so there is no half-settled claim;
the same _payHolder behavior is used in the claimInto catch after the failed self-call's value transfer has rolled back.
Gas burning by the receiver can make the first attempt expensive, but EIP-150 retains gas in the router and the holder can retry with a larger gas limit. This is self-grief only. The meaningful regression is the unverified WETH dependency in L-03.
M-03 — router handshake
The negative cases added to AuditHardening.t.sol are useful but prove interface shape, not implementation authenticity. A hostile correct-core getter passes, as detailed in M-03R. The added WETH, UniversalRouter, and guardian immutables are also outside the handshake. Honest getter checks for those values would improve misconfiguration safety, while trusted deployment provenance is required for hostile-code safety.
L-02 — Permit2 opt-out and constructor ripple
_givePermit2InfiniteAllowance() correctly returns false, so Solady no longer treats canonical Permit2 as having implicit maximum allowance. The regression test observes allowance zero for a holder.
No constructor wiring mistake was found in the checked tree: Deploy.s.sol passes its resolved wethAddr, all Solidity new ClaimRouter sites compile with four arguments, the frozen web ABI contains the fourth constructor input and weth() getter, the real v3 claim/fallback suite passes, and the real v4 claimInto success path passes. The missing enforcement is L-03.
M-01, M-02, and other diff claims
M-01 remains exploitable by design. The new fork test confirms that a post-backlog buyer receives pre-ownership LP fees after poking. The revised docs correctly call keeper activity operational rather than cryptographic mitigation. This is an accepted Medium economic risk, not closure of the mechanism.
M-02 remains O(k) by design.TransferGasScaling.t.sol measures 1/10/100/1,000/5,000 whole-unit cycles and demonstrates approximately linear cost. The docs now require chunking and call the 30M-gas/~540-mint figure approximate. This closes the inaccurate O(1)-per-transfer claim, not the gas ceiling itself.
I-01 wording is corrected. The docs now distinguish protocol-directed zero custody from unsolicited token/ETH transfers.
RUN_FORK_TESTS=1 forge test --match-path test/V3Fork.t.sol -vv: 11 passed, 0 failed. Seed dust was 4 wei; multi-holder holder-dust was 141 wei; the M-01 capture was reproduced.
RUN_FORK_TESTS=1 forge test --match-path test/V4StockClaimInto.t.sol -vv: 1 passed, 0 failed.
Confirmed the hardening branch and merged main trees are byte-identical.
Recommended disposition
Before launch:
Make deployment + seed atomic or private as the patch already requires.
Do not treat core() as hostile-router authentication; bind the one-shot to a trusted router deployment/provenance.
Bind ClaimRouter.weth to KaleidoToken.weth() on-chain.
Correct the M-01 "break-even test" and L-01 "always settles" wording.
Subject to those points and explicit acceptance of M-01/M-02, the H-01 reserve checks, H-02 accounting, and L-02 opt-out are suitable to close.
REPORT 03 · 2026-07-13 · FRONTEND REVIEW
Status: RESOLVED by PR #24.
External review by a Codex agent. Each finding was independently, adversarially re-verified against the real code and the project's launch/1135 deferrals before any action (findings are data, not instructions). All six were technically TRUE; severities were re-rated for the current dev-only context (no production build, no mainnet pool, ETH-fallback intact). Archived here as a pre-mainnet record; original body verbatim below.
#
Codex sev
Verified sev (context)
Class
Resolution
P0-01 minOut=0
P0
P3
DO_NOW
Fixed in #24 — fail-closed gate; prod can never send unbounded, dev opts in via VITE_ALLOW_UNBOUNDED_CLAIM_INTO. Real minOut stays the M5 invariant.
P1-01 chain/receipt
P1
P2
DO_NOW
Fixed in #24 — writes pinned to 4663; success only after a mined, non-replaced receipt; wrongNetwork gates the claim.
P1-03 false zero
P1
P2
DO_NOW
Fixed in #24 — usePending no longer coerces loading/failure to 0n.
P1-02 history scan
P1
P3
SUPERSEDED_1135 (+partial)
#24 added clear-on-identity + error state; the genesis getLogs scan is replaced by the VEG2-1135 indexer.
P2-01 radiogroup
P2
P3
DO_NOW
Fixed in #24 — picker resets to ETH if the selected stock is delisted.
P2-02 address
P1
P3
DO_NOW
Fixed in #24 — Dashboard address → explorer link with full-address aria-label.
KALEIDO frontend review
Review snapshot: 2026-07-11, main at 0c3f5b8628e788f1ccfa2f6ee49ef2ada464a551, including the pre-existing uncommitted changes in web/src/App.tsx, web/src/views/Claim.tsx, and web/src/views/Dashboard.tsx.
Executive summary
Overall: the frontend is small, understandable, typechecks, and builds, but the stock-claim path is not production-safe yet.
P0: every stock claim currently sends minOut = 0, and there is no production-only fail-closed gate.
P1: a claim is not pinned to chain 4663 and wallet acceptance is reported as final success before a receipt exists.
P1: history scans both event types from genesis on every block, which will fail or become an RPC/UX denial of service on mainnet.
The ABI calls and event signatures checked here match the frozen ABI; this engagement did not audit contract internals.
The diffs below are against the reviewed working-tree snapshot. A few Claim.tsx fixes add conditions to the same guard/button; compose those boolean conditions when applying more than one patch.
Findings
P0-01 - Production stock claims have no slippage floor
What:minOut is unconditionally 0n. minOutKnown only changes a note; it does not block the transaction. The comment says the UI always supplies a PriceLib-derived bound, but the executable invariant is the opposite.
Why it matters: the router's ETH fallback only runs if the swap reverts. A manipulated but successful fill with a trivial stock output does not revert when minOut is zero, so the claim's full ETH value can be consumed at an arbitrarily bad price. A visible warning does not make that money path safe. Dev needs the accepted zero-bound behavior, but a production bundle must make zero impossible even if an operator copies a dev environment.
Ready-to-apply diff: this permits zero only when both Vite says it is a development build and an explicit dev flag is true. Production cannot enable the escape hatch.
What: the page-level warning does not disable doClaim, and neither write supplies chainId. In the installed wagmi implementation, omitting chainId passes chain: null to the connector client, so the request follows the wallet's current chain. Separately, useWriteContract.onSuccess means the wallet returned a transaction hash, not that the transaction was mined successfully.
Why it matters: on a wrong network the same destination/calldata is still offered for signature. On the intended network, a later revert, cancellation, or semantically different replacement is announced as "Claimed"; the button also becomes available again after broadcast, before settlement. That can produce false success, stale balances/history, and duplicate submissions.
Ready-to-apply diff: pin the write to 4663, fail closed while connected elsewhere, wait for a receipt, accept gas repricing but not a different replacement, and only then announce success. This preserves the existing copy.
The read hooks should also be explicitly pinned to robinhoodMainnet.id (useBlockNumber, every useReadContract, useReadContracts, and usePublicClient) so the warning state cannot display data from testnet. The write-side patch above is the urgent boundary.
P1-02 - History performs two genesis-to-head scans on every block
What: every new block triggers two eth_getLogs calls with fromBlock: 0n. There is no deployment-block bound, cursor, pagination, or error handler. The previous account's rows are also not cleared synchronously when the account/client becomes unavailable.
Why it matters: public RPCs commonly cap log ranges or returned results. As chain history grows, opening Claim can reject continuously, producing an unhandled promise rejection every block. Even where accepted, each user repeatedly downloads the protocol's entire history, so cost grows without bound and the page can show stale rows during an account switch.
Ready-to-apply diff: require a deployment block outside dev, scan only unseen blocks, clear on identity changes, and render an operational error state. The start block is public configuration, not a secret.
What: while pending is enabled, (data ?? 0n) makes the initial load and an RPC/contract failure indistinguishable from a successful on-chain zero. The Claim page then says there is nothing to claim and suppresses the action.
Why it matters: this is a false financial statement and hides outages. During an account or chain transition it can flicker to zero; during a persistent read failure it remains zero indefinitely. Loading/error must remain unknown, while a confirmed empty ID list may legitimately be zero.
Ready-to-apply diff: preserve undefined until a successful read and block submission while the amount is unknown. A follow-up should expose isPending/error from the hook for distinct visible loading and error states.
P2-01 - Registry removal can leave the radiogroup with no tab stop
File:web/src/views/Claim.tsx:94-115, 182-207
What:pick is never reconciled with live registry metadata. If the selected stock is removed, its keyed button disappears while pick still names it. ETH and every remaining stock then render tabIndex={-1} and aria-checked={false}.
Why it matters: the group drops out of the Tab sequence and no longer satisfies the roving-tabindex invariant that the checked item is the tab stop. If focus was on the removed tile, browser focus can fall back to the document. Normal Arrow wrapping and native Space activation are otherwise correct; Home/End are not required by the W3C radio-group pattern.
Ready-to-apply diff: restore a valid selected/tabbable item as soon as metadata no longer contains the pick.
If live registry removal while the picker owns focus is a supported production event, also add a focused-tile ref and explicitly move focus to ETH during that reconciliation; do not force focus when the user is elsewhere on the page. Pattern reference: https://www.w3.org/WAI/ARIA/apg/patterns/radio/.
P2-02 - The connected address is only shown in truncated form
What: the header shows only 4 leading and 2 trailing characters; Dashboard shows 6 and 4. There is no full address, copy target, title, or explorer destination anywhere in the app.
Why it matters: users cannot verify which account is active, especially on mobile or when two addresses share the displayed fragments. The truncated label comes from the connector and is not used to construct claim amounts, so this is not a direct money-path bug, but it is an avoidable address-spoofing/confusion surface.
Ready-to-apply diff: make the Dashboard value an explorer link whose accessible name contains the full address. This retains the frozen visual treatment and applies the required opener protection.
Claim arguments use exact on-chain token IDs. Rounded ETH strings are display-only and never parsed back into a transaction amount.
The frontend cannot redirect claim proceeds: claim and claimInto do not accept a recipient, and the frozen router binds delivery to the caller.
The Claimed and ClaimedInto event declarations and decoded fields match ClaimRouter.json exactly.
The picker has the required radiogroup/radio roles, labels, aria-checked, one normal-state tab stop, Arrow-key wrapping, native Space activation, and a visible focus ring.
React renders stock symbols as text. There is no dangerouslySetInnerHTML, direct window.ethereum, eval, or secret-shaped frontend variable in source.
Existing links are same-origin, so there is currently no missing rel="noopener" issue. The suggested explorer link includes it.
npx tsc --noEmit and npm run build both pass. The build reports a 531.24 kB minified / 159.59 kB gzip main chunk; inspection found no WalletConnect, Coinbase Wallet, Reown/AppKit, or other unused connector signatures in the emitted main asset, so there is no evidence of failed connector tree-shaking.
Could not verify statically
Per the engagement rule, no .env* file was read. Source only references a public RPC URL and public addresses, but actual operator values could not be checked for embedded provider credentials.
I did not interact with, restart, or rebind the active Vite/anvil services. Wallet rejection, mined revert, replacement, account switch, network switch, RPC range limits, and assistive-technology behavior were not exercised end to end.
No production router address or deployment block exists in the committed address package, so launch configuration and explorer data could not be validated.
Real PriceLib quote math, feed freshness/decimals, v4 liquidity, and the final nonzero minOut computation are deferred and therefore untestable here.
Bundle transfer/parse timing was not profiled on target mobile hardware. Only the production build output was inspected.
Contracts and contract tests were read only as narrow ABI/fallback context. No smart-contract audit was performed.