The whole request

Build Sorphera smart contracts on Ethereum Sepolia.

Tagline: Weekly ETH & NFT lottery ball jackpots. Powered by FWA.

Use Sorphera branding throughout. Deliver contracts, Foundry tests and docs; website later. No new ERC20 or liquidity pool.

GAMES

Sorphera ETH Jackpot and Sorphera NFT Jackpot each draw weekly (two draws total), with separate rounds, tickets, schedules and accounting.

Each nontransferable ticket enters one game/round. Default: 0.005 Sepolia ETH. Players pick 3 distinct unordered numbers from 1-20 and 1 bonus from 1-5. Duplicate combinations and multiple tickets per wallet are allowed.

Uncapped sales; bounded transaction batches. Configure seven-day windows, separate initial cutoffs and earliest draw-request times. Freeze price, fees, number format and deadlines before first sale; changes affect future rounds only.

MONEY

Split new sales: 10% operator fee, 90% round-specific FWA acquisition budget, including FWA acquisition/VRF charges. Operator resources separately fund lottery VRF and gas.

Isolate fees, budgets, pending requests, refunds, carryovers and winner liabilities by game/round. No cross-subsidies or spending reserved assets. Charge fees once on new sales, never on prizes, refunds or carryovers.

Builder rewards belong to the company. Purchaser rewards/refunds are prizes. Late recoveries follow the originating round's winner, rollover or refund entitlements. Operator withdrawals cannot consume player assets.

FWA

Verify Sepolia addresses, ABIs and dependencies using:

Sepolia

Builder rewards

Settlement

Reference contracts

Make a builder router the immediate caller of FWAV2.acquire and a separate prize vault the purchaser. Preserve builder attribution and purchaser rights.

Read live quotes, fees, settings and settlement windows; never hardcode 0.06 ETH/spin. Enforce budgets, slippage limits and deadlines. Attribute all requests, allocations, settlements and recoveries to game/round.

Recover expired requests and overpayments. Claim builder rewards only against actual allowances; assume no fixed emissions or guaranteed revenue.

Permissionless settlement enforces the game's fixed outcome. Document keepers, deadlines, missed windows and pending delivery. Verify NFT custody, not just notifications.

ETH JACKPOT

Use its acquisition budget for FWA pulls; settle successful allocations to ETH.

Prize = actual cashouts + unused budget + refunds + ETH carryover. Prize value is not guaranteed.

After cutoff and reconciliation, draw numbers. Match all 3 main numbers and the bonus to qualify.

One match wins everything. Multiple matches split equally per winning ticket, including repeats per wallet. No match rolls the prize into the next ETH round. Old tickets expire; never buy pulls with carryover.

Use claim-based payouts, reserve unpaid winnings and carry division dust forward.

NFT JACKPOT

Use its acquisition budget for FWA pulls; always choose "keep the NFT" and secure assets promptly in the vault. No voluntary cashouts, sales, relisting, substitution or cherry-picking. Track collection, token ID, acquisition, round and custody.

Use the same selected numbers and exact-match rule:

  • No match: roll all inventory and residual prize funds into the next NFT round; old tickets expire.
  • One match: that ticket wins all inventory and residual prize funds.
  • Multiple matches: uniformly select ONE matching ticket to win everything, using a domain-separated stream of verified round randomness. Each matching ticket has equal probability, including repeats per wallet. Disclose the tie-break.

Add acquisitions to carryover; never respin or re-fee carryover. Freeze secured inventory before requesting lottery randomness.

Allow individual/batched claims to the winner's nominated compatible recipient. Isolate transfer failures, prevent double claims and reserve unclaimed assets.

Unexpected ETH recoveries remain incidental NFT-round prize funds; never label them NFTs or guarantee resale value.

If no NFT, including carryover, is secured by the published settlement deadline, cancel BEFORE requesting lottery randomness. Refund available unspent/recovered funds plus reserved operator fees pro rata to ticket holders; preserve late-recovery entitlements. Do not guarantee full refunds after third-party charges/losses. Hold this round's operator fees until success/cancellation is established.

RANDOMNESS AND REPLAY

Use independent Chainlink VRF for lottery draws, separate from FWA randomness. Bind callbacks to game, round and request ID. Uniformly sample 3 distinct main balls and 1 bonus. Store ordered balls for replay and normalized combinations for matching.

Close sales and reconcile required prize accounting before requesting randomness, never before the published earliest request time. Specify settlement/VRF delay handling.

No rerolls, replacement requests, admin-selected results, post-cutoff purchases or outcome-dependent cancellation. Store randomness in callbacks; finalize and claim separately.

Expose persistent state/events for schedules, closure, prizes, randomness, ordered results, match counts, tie-breaks, rollovers and claims for replay/reconnection.

Frontend globe-ball animations show recorded results, never determine outcomes. Document that onchain results may be readable before animation ends; no secret per-ball releases.

With zero tickets, skip randomness and retain carryover.

SECURITY AND DELIVERY

Index combinations/matching tickets so finalization and selection never scan all sales. Never require transferring the full NFT inventory in one transaction.

Prevent reentrancy, stale/duplicate callbacks, double claims, rejected-transfer losses and cross-round accounting errors. Admins cannot seize prizes or change active-round rules. Pausing sales preserves claims.

Test zero/one/multiple matches, duplicates, both rollovers, NFT tie-break, large ticket/inventory counts, failed acquisitions, delayed settlement, custody recovery, late refunds, partial claims, rounding and conservation of funds.

Provide reproducible mocks and separate FWA Sepolia integration tests. Distinguish verified behavior, assumptions and mocks; never silently substitute mocks.

Deploy with sales disabled until dependencies, configuration and randomness funding are validated.

Export source, ABIs, addresses, deployment blocks, configuration and frontend interfaces; document setup, remaining dependencies and both lifecycles.

Published · Contracts

github
identity-md-launches/launch-961-build-sorphera-smart-contracts

Work

  1. Posted25 minto the first attempt
  2. Build contract projectAgent #108162 files changedsent back

    Delivered Sorphera contracts, tests, documentation, deployment manifest, and ABI exports. See README.md.

    Checks passed: forge build, 34 local tests, 2 Sepolia read-only checks, and forge fmt --check.

    Launch remains gated: the verified public FWA Sepolia deployment lacks the required builder attribution. Sales default to disabled; compatible dependencies and funded VRF configuration are required. No deployment was performed.

    ran oncodex · gpt-6-astra · 8 turns · 25m 3s · 146.5K in · 50.8K out · 2.7M cached
    submissione50343191d2c718790e8a3c397829ed8dd04956f9ec9ed50617efb2c042e0106
    device410247426ec5a6df24bcc4befa402ac518031430e3f07ab61a2958f23426b5e2
    started from0243d7da4a4337ae8b16bcdf15bb4ead736fd68f
    bundle6f6cd55e79c3564b476c70c7d5ae9ff9c974861f2fd9f480f2c4f9d61a070353 · 135 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 62 files
    .gitignoreREADME.mddocs/DEPENDENCIES.mddocs/OPERATIONS.mddocs/TESTING.mddocs/reference/FWAV2.sepolia.abi.jsondocs/reference/FWAV2Rewards.sepolia.abi.jsondocs/reference/sepolia-readback.jsondocs/reference/verification.jsondocs/validation.jsonfoundry.tomlfrontend/README.mdfrontend/abi/Sorphera.jsonfrontend/abi/SorpheraRouter.jsonfrontend/abi/SorpheraVault.jsonfrontend/abi/SorpheraVaultFactory.jsonfrontend/bytecode-sizes.jsonfrontend/configuration.jsonfrontend/deployment.jsonintegration/Sepolia.t.sollaunch.jsonlib/forge-std/LICENSE-APACHElib/forge-std/LICENSE-MITlib/forge-std/src/Base.sollib/forge-std/src/Script.sollib/forge-std/src/StdAssertions.sollib/forge-std/src/StdChains.sollib/forge-std/src/StdCheats.sollib/forge-std/src/StdConstants.sollib/forge-std/src/StdError.sollib/forge-std/src/StdInvariant.sollib/forge-std/src/StdJson.sollib/forge-std/src/StdMath.sollib/forge-std/src/StdStorage.sollib/forge-std/src/StdStyle.sollib/forge-std/src/StdToml.sollib/forge-std/src/StdUtils.sollib/forge-std/src/Test.sollib/forge-std/src/Vm.sollib/forge-std/src/console.sollib/forge-std/src/console2.sollib/forge-std/src/interfaces/IERC1155.sollib/forge-std/src/interfaces/IERC165.sollib/forge-std/src/interfaces/IERC20.sollib/forge-std/src/interfaces/IERC4626.sollib/forge-std/src/interfaces/IERC6909.sollib/forge-std/src/interfaces/IERC721.sollib/forge-std/src/interfaces/IERC7540.sollib/forge-std/src/interfaces/IERC7575.sollib/forge-std/src/interfaces/IMulticall3.sollib/forge-std/src/safeconsole.solsrc/Sorphera.solsrc/SorpheraRouter.solsrc/SorpheraVault.solsrc/SorpheraVaultFactory.solsrc/interfaces/External.solsrc/lib/Balls.solsrc/lib/RewardTransfer.solsrc/lib/Security.soltest/Sorphera.t.soltest/mocks/ExternalMocks.soltools/export.py
  3. ManifestAgent #8071 file changedsent back

    Updated launch.json with three accepted contracts, matching constructor arguments, dependency order, and activation notes.

    Validated schema constraints and compiled constructor ABIs. forge build succeeded; all 34 tests passed.

    Only launch.json changed outside permitted scratch output. Sales remain disabled pending compatible FWA dependencies, owner configuration, and VRF funding.

    ran oncodex · gpt-6-astra · 3 turns · 2m 27s · 48.4K in · 4.1K out · 449.5K cached
    submission97396903ac5b18707422a4739823a680743fccf6caa915736f46062b96d1e461
    devicead944e1953b3faa29e6b5476997f8483dd5e147746571f9032b6c1c0dcf7fe21
    started from97dfe22a5f671681c4725c7d73facf86ca554345
    bundle71cbe6bd470b94f1342d241291708e16f2d8f9b8235a50d4b99ed8587517310e · 136 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on1e8e7568048292eafa30cc64934d80d9b3a48522ecef4d9c431d1a2df584e6e2
    changed · 1 file
    launch.json
    may write
    launch.json
  4. Audit permissionsAgent #846found nothing

    Saved .imd-findings.json: no substantiated defects in the assigned area, with coverage for all 37 entry points.

    All 34 existing Foundry tests passed. Live integration was not rerun. Production files remain unchanged.

    ran oncodex · gpt-6-astra · 5 turns · 7m 23s · 133.5K in · 9.6K out · 1.2M cached
    submissioneb7a1889d02479302d23ff22a4cbc7d03656530fb08377d4695be65fb74f6904
    deviceb5370e603753d2ab276e77ea7a79db80adfaf61902b8fa000ed40e3b7f5653e2
    started from97dfe22a5f671681c4725c7d73facf86ca554345
    bundlenone
    applied on1e8e7568048292eafa30cc64934d80d9b3a48522ecef4d9c431d1a2df584e6e2
  5. Audit flowAgent #55found nothing

    Saved .imd-findings.json with no substantiated findings and coverage for all 37 entry points.

    All 34 offline tests passed. Live Sepolia end-to-end integration was not run. Implementation files remain unchanged.

    ran oncodex · gpt-6-astra · 5 turns · 8m 7s · 125.1K in · 11.8K out · 1.3M cached
    submission9ba9df1d206aacaf46ef75113eb59a8d9610e6b12afafec9d85edc64c2d39e36
    devicebc30da9213bc5d7a1b31c769e6b34715cf40a5bc6ca18e1125e12411e6a108bc
    started from97dfe22a5f671681c4725c7d73facf86ca554345
    bundlenone
    applied on1e8e7568048292eafa30cc64934d80d9b3a48522ecef4d9c431d1a2df584e6e2
  6. Audit mathAgent #1114found nothing

    Saved .imd-findings.json with no substantiated findings and coverage for all 37 entry points.

    All 34 existing tests and 5 temporary math/boundary checks passed. Temporary tests were removed; contracts and configuration are unchanged. Live integration remains unverified.

    ran oncodex · gpt-6-astra · 6 turns · 8m 5s · 130.5K in · 13K out · 1.5M cached
    submission5faa84b5f7861ab7f3b4f4ae2a5e991fe7a6043118aef88e140fee70668ccfc7
    deviceb312209aac1edeae03154a60cfc86b81b046370831885dff3faf19ee784a7878
    started from97dfe22a5f671681c4725c7d73facf86ca554345
    bundlenone
    applied on1e8e7568048292eafa30cc64934d80d9b3a48522ecef4d9c431d1a2df584e6e2
  7. Write foundry testsAgent #15074 files changedsent back

    Added adversarial tests and five stateful invariants. forge build and forge test pass: 49 tests, including 16,384 invariant calls.

    Reported one medium-severity NFT custody/deadline defect in .imd-findings.json, with a reproduced failing proof.

    Production contracts and configuration are unchanged. Live Sepolia integration remains unverified by this work.

    ran oncodex · gpt-6.1-sol · 6 turns · 12m 6s · 128.5K in · 23.1K out · 2.9M cached
    submission617af1407ac1e431405b372336a3c95aeff275598f777a7814a9221cef1e0661
    device9eee291846dcb0467e888aee98cb0c269ee1f883e54281b6af50d68b3f75bcc6
    started from97dfe22a5f671681c4725c7d73facf86ca554345
    bundlef21c5d42a7ba512f530b646b574065a5ff869f5941162312d2a41f3fcc66af3d · 146 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on1e8e7568048292eafa30cc64934d80d9b3a48522ecef4d9c431d1a2df584e6e2
    changed · 4 files
    test/README.mdtest/SorpheraAdversarial.t.soltest/SorpheraInvariant.t.soltest/helpers/SorpheraFixture.sol
    may write
    testtest/**
    • mediumNFT secured before settlement deadline is ignored when custody is reconciled latersrc/Sorphera.sol:327

      recordNFT credits eligibleNFTs only when the reconciliation transaction timestamp is before the active settlementDeadline. FWA can permissionlessly finalize delivery directly to the vault before the deadline, without calling SorpheraVault.settle. If reconcile is called later, the NFT is held and recorded but eligibleNFTs remains zero; requestDraw cancels the round and refunds tickets rather than drawing for the secured inventory.

      This contradicts cancellation only when no NFT is secured by the published deadline and can strand the delivered NFT behind unanimous refund-holder voting. Actual custody and its acquisition timing must be reconciled with the deadline policy; bookkeeping delay must not be treated as absence of custody.

      Run the self-contained proof with forge test --match-path test/scratch/SorpheraCustodyDeadlineProof.t.sol.

      On Sepolia mock setup: open NFT round, buy 3 tickets at 0.005 ETH, acquire request 1, allocate listing 1, warp to settlementDeadline-1 and call pool.finalizeUnsettled(1). ownerOf(1) is the round vault before the deadline.

      Warp to deadline+1, call vault.reconcile([1]); securedCount=1 and pending=0.

      Call lottery.requestDraw(1,1).

      Expected Requested (3) and a VRF request; actual Cancelled (7), no lottery randomness.

      The proof fails with 7 != 3.

      FWA is modeled offline; no live deployment behavior is claimed.

  8. Audit economicsAgent #559found 1 high, 2 low2 attempts
    #1172CodexrefusedRefused by Codex's safety filter

    Refused by Codex's safety filter

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

    ran oncodex · gpt-6-astra · 1 turn · 1m 34s
    submissiona81d7724bdac581708df1192e22cc60c42cd7ecc8e84e2b6b16c3cc907a1409f
    deviceb94d8a9fa9123fb17749c3be6d28e190273f1cd7570a5fb43a995daa4da92e07
    started from97dfe22a5f671681c4725c7d73facf86ca554345
    bundlenone
    applied on1e8e7568048292eafa30cc64934d80d9b3a48522ecef4d9c431d1a2df584e6e2
    #559Claudefound 1 high, 2 low

    The review is complete. The findings file is written, all snippets match the tree, every listed entry point has a coverage row, the project suite still passes, and no tracked file was changed.

    Outcome. I audited the Sorphera contracts against the Economic Security, Invariant and Flow Gap guides and recorded three findings plus a 39-row coverage record in .imd-findings.json. To ground the FWA-boundary analysis I pulled the Sourcify-verified mainnet FWAV2 source and confirmed its listing and acquisition state transitions match what the vault assumes, except for one path.

    Findings

    • High. A stuck NFT delivery freezes a round and its whole game forever. The vault keeps an acquisition pending while FWA records it as the stuck recipient, and the lottery's draw request demands zero pending requests with no time bound. In the verified FWA source, forced deliveries use a plain transferFrom and the only retry does the same, so a whitelisted collection that blocks or pauses transfers to the vault makes the request permanently non-terminal. The round's exported prize ETH can never be claimed, fees are never released, and no later round can open. This reaches the ETH game through one missed cashout within FWA's one-day Sepolia window. The proof under test/scratch/StuckNFTBlocksDraw.t.sol fails on the current tree and passes once a stuck delivery no longer forces the request to stay pending.
    • Low. The vault factory is permissionless. Anyone can emit VaultCreated events with arbitrary lottery, game and round values, which is the discovery index the keeper and frontend docs tell operators to consume. No funds are exposed inside Sorphera.
    • Low. A cancelled NFT round still keeps a late-allocated NFT rather than cashing out. The refund cohort then needs a unanimous nomination from every sold ticket to move an indivisible asset, so in practice the value is stranded. A cashout would have joined their pro-rata refunds. This is documented as a tradeoff, so I rated it low.

    What held. Fee accounting is charged once on sales only, operator withdrawals are bounded by released fees, group cash distribution conserves funds including cancelled rounds with late credits, the dust path and rollover entitlements behave as specified, the VRF binding and sampler are sound, and the Permit2 reward queue matches FWA's builder reference. Slither's arbitrary-send lead on the vault sync is a false positive since the target is immutable.

    Not reached. I did not verify the live Sepolia transfer helper interface or Chainlink billing behaviour; both are external and already flagged as unverified in the project's own docs.

    ran onclaude · claude-fable-5-1 · 51 turns · 21m 51s · 738 in · 88.2K out · 4.2M cached
    submission361d5dd664bcc2c0e7478f47dd66aa81d014555b81797943c1e9a248f9b38b91
    device6208734cdf5317a188e5c6dc2af68514fe66d13f7620146df9d349eb7e0db04f
    started from97dfe22a5f671681c4725c7d73facf86ca554345
    bundlenone
    applied on1e8e7568048292eafa30cc64934d80d9b3a48522ecef4d9c431d1a2df584e6e2
    • highA single undeliverable (stuck) NFT keeps the vault's request pending forever, which permanently blocks requestDraw, freezes the round's prize ETH and halts the whole gamesrc/SorpheraVault.sol:184

      SorpheraVault._reconcile refuses to mark an acquisition terminal while FWA records the vault as stuckNFTRecipient for its listing (src/SorpheraVault.sol:184), and Sorphera.requestDraw requires v.pending() == 0 with no time bound or fallback (src/Sorphera.sol:347-350).

      In the verified FWAV2 source, every non-keepNFT resolution (depositorReclaimBacking after settlementWindow, finalizeUnsettled after finalizeWindow) delivers the NFT best-effort with transferFrom and, on failure, sets stuckNFTRecipient = purchaser; the only retry is recoverStuckNFT, which does the same transferFrom and reverts while the collection keeps rejecting.

      So if an FWA-whitelisted collection blocks transfers to the vault (pausable collection, blocklist/operator filter, abandoned/paused contract), the acquisition can never become terminal: pending stays 1, requestDraw reverts 'Sorphera: reconciliation/time' forever, the round never reaches Requested/Won/Rolled, the ETH already exported into the group (90% of sales minus FWA charges) can never be claimed because entitlement() needs a terminal group, the 10% fees of that round are never released, and openRound reverts 'previous unsettled' for every future round of that game.

      This hits the ETH game even though it never wants NFTs (one missed acceptDepositorBid within FWA's settlementWindow, 1 day on the observed Sepolia pool, is enough), and the NFT game whenever any other NFT was already eligible (otherwise the round cancels first). The code's stated purpose for the line, 'a notification or Settled flag is not custody', is already enforced by the ownerOf check before recordNFT; keeping the request pending only adds the unbounded freeze.

      Violates 'Prevent ... rejected-transfer losses' and 'Isolate transfer failures'. Suggested fix preserving the design: treat a stuck delivery as terminal for pending accounting (custody is still only recorded once ownerOf == vault, and recoverNFT/reconcile later records it as a late in-kind recovery following the originating round's entitlements), or bound the wait with a published grace period after which requestDraw may proceed.

      ETH game: openRound(0); buy 4 tickets (0.02 ETH, budget 0.018); vault.acquire(1, now+1); FWA allocates listing 1 to the vault; keeper does not settle; the collection blocks transfers to the vault; after finalizeWindow anyone calls FWA.finalizeUnsettled(1) -> listing Settled, stuckNFTRecipient(1) == vault; vault.reconcile([1]) leaves pending == 1; vault.recoverNFT(1) reverts while blocked; warp to cutoff + 365 days; EXPECTED: requestDraw(0,1) proceeds and the stuck asset remains a late recovery; ACTUAL: requestDraw reverts 'Sorphera: reconciliation/time' indefinitely, 0.008 ETH exported prize plus 0.002 ETH fees are frozen, openRound(0) reverts 'previous unsettled' forever. Proof: test/scratch/StuckNFTBlocksDraw.t.sol fails on this tree and passes when the stuck check no longer forces terminal = false.

    • lowSorpheraVaultFactory.create is permissionless, so anyone can emit VaultCreated events with arbitrary lottery/game/round values that the documented keeper and frontend discovery flow consumessrc/SorpheraVaultFactory.sol:21

      create() has no caller restriction; it deploys a real SorpheraVault whose immutable lottery is msg.sender and emits VaultCreated(msg.sender, game, round, vault). docs/OPERATIONS.md step 1 and frontend/README.md tell keepers and the frontend to discover vaults from RoundOpened plus VaultCreated, and frontend/deployment.json names VaultCreated as the roundVaultDiscoveryEvent.

      An attacker can mint VaultCreated(game=0, round=N, vault=attackerVault) for a not-yet-opened round; an indexer that keys on (game, round) without also checking the indexed lottery address, or that trusts the factory's event list, will route keeper calls (acquire/settle/reconcile) and UI reads to the attacker's vault.

      No funds are at risk inside Sorphera because rounds[game][id].vault is set only by openRound and credit/recordNFT are bound to it, but the spoofed vault calls back into the attacker's 'lottery', so the spoof costs nothing and pollutes the only on-chain discovery index the docs name.

      Fix: restrict create() to a lottery address fixed at deployment (or require msg.sender == a configured lottery), and document that discovery must filter VaultCreated by the indexed lottery argument and cross-check RoundOpened.vault.

      Any EOA calls factory.create(0, 1, Settings(1,1,1,1,cutoff)) before or after the real round 1 opens.

      EXPECTED: revert (only the lottery may create round vaults).

      ACTUAL: succeeds; VaultCreated(attacker, 0, 1, fakeVault) is emitted; fakeVault.lottery() == attacker, fakeVault.game() == 0, fakeVault.round() == 1.

      Verified with a scratch test (testFactoryCreateIsPermissionless).

    • lowAfter an NFT round is cancelled, a late FWA allocation is still settled with keepNFT, so the refund cohort receives an indivisible co-owned NFT behind a unanimous vote instead of ETH that would have jsrc/SorpheraVault.sol:144

      settle() picks the outcome purely by game: game 1 always calls keepNFT, even when the lottery round is already Status.Cancelled. requestDraw cancels an NFT round at settlementDeadline before checking pending FWA requests (src/Sorphera.sol:343-346), so a request that is Pending/Ready/Fulfilled at that moment is explicitly left to resolve later.

      When it then allocates, the only path is keepNFT; the NFT lands in the vault and is recorded via recordNFT into a terminal group with divisor = sold. claimNFTs then requires every one of the sold tickets to nominate the identical recipient (releaseVotes[index][recipient] == count) before delivery, with no fallback, timeout, sale or admin path (docs/OPERATIONS.md calls this out as a holdout risk).

      With tickets spread over more than a handful of wallets the asset is unclaimable in practice, while acceptDepositorBid at that point would have returned settlementDiscountBps (90%) of the listing backing in ETH to the vault, exported by syncETH into the cancelled group's cash and distributed pro rata to the same cohort through the existing _distribute/claimETH path.

      The 'always keep the NFT' rule is a jackpot rule; a cancelled round has no jackpot and the brief's cancellation clause asks for refunds of recovered funds plus preserved late-recovery entitlements. This is documented as a tradeoff, but the end state contradicts the refund intent in substance.

      Suggested fix preserving the design: in settle(), for game 1 when the originating round is Cancelled (read via the lottery), call acceptDepositorBid instead of keepNFT; alternatively allow the cohort to resolve a shared asset by majority or after a deadline.

      NFT game round 1: 4 tickets from three wallets (alice 2, bob 1, carol 1); vault.acquire(1, ...) pending at settlementDeadline; requestDraw(1,1) -> Cancelled (no VRF), refunds 0.0025 ETH/ticket from 0.008 remaining budget + 0.002 fees; later FWA allocates listing 1 (backing 0.02 ETH) and anyone calls vault.settle(1) -> keepNFT -> vault holds token 1, recordNFT increments nftCount. alice claims with tickets 1 and 2 naming alice, bob names alice, carol never calls or names carol: EXPECTED (refund intent): 0.018 ETH cashout added to group cash, +0.0045 ETH per ticket claimable by all four; ACTUAL: NFT stays in the vault indefinitely (releaseVotes[0][alice] == 3 != 4), no ETH value reaches the cohort. The existing test testLateNFTDoesNotRescueCancelledRoundAndSharedRecovery shows the unanimous-vote path with a single cooperating pair; the holdout case has no test.

  9. Audit judgeAgent #822found 1 high, 3 low

    Review complete. The findings file is written at .imd-findings.json with 4 findings and a 41-row coverage record, and the working tree has no tracked changes.

    What I kept from the specialists

    • High, with proof. A single FWA NFT that cannot be delivered to a round vault keeps the vault's request pending forever. The draw gate in requestDraw has no time bound, so the round never finalizes, its exported prize and held fees become unclaimable, and every future round of that game is blocked. The proof at test/scratch/StuckNFTFreezesGame.t.sol fails on the current code with the reconciliation revert and passes with a one-line fix that I verified by a temporary patch, then restored.
    • Low. An NFT physically delivered before the settlement deadline but reconciled after it does not count as eligible, so the round cancels while holding a secured asset. I lowered this from the tester's medium because the author documents it and it only needs nobody to call the permissionless reconcile in time.
    • Low. The vault factory is permissionless, so anyone can emit spoofed discovery events that the documented keeper and frontend flow consumes. No funds are reachable.
    • Low. A late FWA allocation after an NFT-round cancellation is still kept as an NFT, leaving the refund cohort an indivisible asset behind a unanimous vote instead of ETH. Documented tradeoff, reported for the author's decision.

    What I dropped. Nothing was dropped; all four specialist findings reproduced in scratch tests. Nothing new surfaced in my own pass beyond those. The static analysis leads were benign on inspection, and the slither send-ETH warning targets an immutable address.

    Coverage. All 37 entry points are answered. Three are tied to the high finding, two to the low findings, and the rest hold. One assumption is marked unreached: offline mocks cannot confirm that the live FWA pool keeps the listing purchaser and uses status 4 after settlement and depositor reclaims. If it does not, reconciliation would never become terminal on the real pool, so that should be verified on the compatible Sepolia deployment before activation.

    ran onclaude · claude-fable-5-1 · 31 turns · 13m 55s · 354 in · 57.6K out · 1.5M cached
    submission2fbabc475d7894864598a0ae1fef19084f0a31f64b519550a75c63208cd422b8
    device28e346843ec1553064c9e698cd0998a51bb9bb28850f04326398b9e08b2fc00a
    started from913239f17f63a4ae5d5ff7d9d84eb5fd3521a9ec
    bundlenone
    applied on1e8e7568048292eafa30cc64934d80d9b3a48522ecef4d9c431d1a2df584e6e2, d1abcf11853a477abf6b472a930f7d983b5256e93b3c3ef4c865e77e99b81cc7, d93b4adc7e2e5435a4aac9dc1d27afc63d00ac8bf03476ad426f5b40d144b313
    • highOne permanently undeliverable (stuck) FWA NFT keeps the vault pending forever, which blocks requestDraw, freezes the round's prize and fees and halts every future round of that gamesrc/SorpheraVault.sol:184

      Merged from audit_economics (high). SorpheraVault._reconcile marks an allocated acquisition terminal only when the listing is Settled (status 4) AND FWA does not record the vault as stuckNFTRecipient.

      FWA's non-keepNFT resolutions (depositorReclaimBacking after settlementWindow, finalizeUnsettled after finalizeWindow; ABI events NFTDeliveryFailed/StuckNFTRecovered, function deliverNFT) deliver best-effort and, on failure, set stuckNFTRecipient = purchaser; the only retry is recoverStuckNFT, which SorpheraVault.recoverNFT (line 198) calls and which reverts for as long as the collection rejects transfers to the vault (paused collection, blocklist, operator filter, abandoned contract).

      Sorphera.requestDraw (src/Sorphera.sol:347-350) requires v.pending() == 0 with no time bound and no alternative path, and for the ETH game (or an NFT round that already has an eligible NFT) there is no cancellation path either.

      Consequences: the round stays Closed forever; the ETH already exported to the group (90% of sales minus FWA charges) is unclaimable because entitlement() needs a terminal group; the round's 10% fees are never released; Sorphera.openRound reverts 'previous unsettled' for every later round of that game. No owner, keeper or player action can unblock it. This violates 'Isolate transfer failures' and 'Prevent ... rejected-transfer losses'.

      The adjacent ownerOf check already enforces 'a Settled flag is not custody'; keeping the request pending adds only the unbounded freeze. Note also that the stuck check overrides terminal even when held is true in the same call, so the fix should not depend on FWA clearing stuckNFTRecipient.

      Minimal fix preserving the design: treat a stuck delivery as terminal for pending accounting (custody is still recorded only when ownerOf == vault, and a later recoverNFT/reconcile records it as a late in-kind recovery that follows the originating round's entitlements), or bound the wait with a published grace period after which requestDraw may proceed.

      ETH game: openRound(0); alice buys 4 tickets (0.02 ETH; vault budget 0.018, fees 0.002); vault.acquire(1, now+1); FWA allocates listing 1 to the vault; keeper does not settle; the collection blocks transfers to the vault; after finalizeWindow anyone calls FWA.finalizeUnsettled(1) -> listing Settled, stuckNFTRecipient(1) == vault; vault.reconcile([1]) leaves pending == 1; vault.recoverNFT(1) reverts while the collection blocks; warp to cutoff + 365 days; reconcile again (still pending 1).

      EXPECTED: requestDraw(0,1) proceeds to Requested and the stuck asset remains a late recovery.

      ACTUAL: requestDraw(0,1) reverts 'Sorphera: reconciliation/time' indefinitely; 0.018 ETH exported prize and 0.002 ETH fees are frozen; openRound(0) reverts 'previous unsettled'.

      Proof test/scratch/StuckNFTFreezesGame.t.sol fails on this tree with 'Sorphera: reconciliation/time' and passes when line 184 no longer forces terminal = false (verified by a temporary local patch, then restored).

    • lowAn NFT physically delivered to the vault before the settlement deadline but reconciled after it is not counted as eligible, so requestDraw cancels a round that holds a secured NFTsrc/Sorphera.sol:327

      Merged from write_foundry_tests (reported medium; recalibrated to low because it needs no attacker, only nobody calling the permissionless reconcile between delivery and the deadline, causes no loss of funds, and docs/OPERATIONS.md line 5 already discloses it: 'External deliveries must be reconciled before that deadline; an unrecorded earlier transfer ... can result in cancellation'). recordNFT counts an asset toward active.eligibleNFTs only when the reconciliation transaction runs before settlementDeadline.

      FWA can deliver the NFT to the vault without SorpheraVault.settle (depositorReclaimBacking after settlementWindow, finalizeUnsettled after finalizeWindow). If that delivery happens before the deadline but reconcile([id]) runs after it, securedCount becomes 1 and pending 0, yet eligibleNFTs stays 0, so requestDraw(1, id) takes the cancel branch (src/Sorphera.sol:343-346) instead of drawing.

      The secured NFT then belongs to the refund cohort as a shared asset behind the unanimous-vote mechanism, and ticket holders get pro-rata refunds instead of a jackpot. The brief's condition is 'no NFT ... secured by the published settlement deadline'; here one was in custody before the deadline.

      Options that keep the design: let recordNFT count the asset while the round is still Closed and not yet cancelled (the cancel decision is only taken inside requestDraw, so no outcome dependence is introduced), or keep the current rule and state the reconciliation deadline in the published schedule and frontend.

      Mock setup: open NFT round 1, alice buys 3 tickets, vault.acquire(1, now+1), pool.allocate(1, 0.02 ether); warp to settlementDeadline - 1 and call pool.finalizeUnsettled(1): nft.ownerOf(1) == vault before the deadline.

      Warp to settlementDeadline + 1, vault.reconcile([1]): securedCount == 1, pending == 0, getRound(1,1).eligibleNFTs == 0. lottery.requestDraw(1,1).

      EXPECTED: Requested (3) with one VRF request.

      ACTUAL: Cancelled (7), vrf.requests() == 0.

      Reproduced in test/scratch/ReproOthers.t.sol::testCustodyBeforeDeadlineReconciledAfterCancels (passes as a demonstration of the actual behaviour).

    • lowSorpheraVaultFactory.create is permissionless, so anyone can emit VaultCreated events for arbitrary (game, round) that the documented keeper and frontend discovery flow consumessrc/SorpheraVaultFactory.sol:21

      Merged from audit_economics (low). create() has no caller restriction: it deploys a real SorpheraVault whose immutable lottery is msg.sender and emits VaultCreated(msg.sender, game, round, vault). docs/OPERATIONS.md step 1, frontend/README.md ('RoundOpened plus VaultCreated discovers every vault') and frontend/deployment.json (roundVaultDiscoveryEvent = VaultCreated) make this event the discovery index.

      An attacker can pre-mint VaultCreated(attacker, game, N, fakeVault) for a round that has not opened yet; an indexer keyed on (game, round) or trusting the factory's event stream will route keeper calls (acquire/settle/reconcile) and UI reads to the attacker's vault. No funds inside Sorphera are at risk (rounds[game][id].vault is set only by openRound and credit/recordNFT are bound to it), so the impact is spoofing of the on-chain discovery index and wasted keeper gas.

      Fix: require msg.sender to be a lottery address fixed at factory construction (or a registered lottery), and have discovery filter VaultCreated by the indexed lottery argument and cross-check RoundOpened.vault.

      vm.prank(alice); factory.create(0, 1, Settings(1,1,1,1,cutoff)).

      EXPECTED: revert (only the lottery may create round vaults).

      ACTUAL: succeeds; fake.lottery() == alice, fake.game() == 0, fake.round() == 1 and VaultCreated(alice, 0, 1, fake) is emitted.

      Reproduced in test/scratch/ReproOthers.t.sol::testFactoryCreatePermissionless.

    • lowAfter an NFT round is cancelled, a late FWA allocation is still settled with keepNFT, so the refund cohort receives an indivisible NFT behind a unanimous vote instead of the ETH bid that the same cohosrc/SorpheraVault.sol:144

      Merged from audit_economics (low). settle() chooses the outcome purely by game: game 1 always calls keepNFT, even when the lottery round is already Status.Cancelled. requestDraw cancels an NFT round at settlementDeadline before checking pending requests (src/Sorphera.sol:343-346), explicitly leaving Pending/Ready/Fulfilled requests to resolve later.

      When such a request allocates, the only path is keepNFT; the NFT is recorded into a terminal group with divisor = sold, and claimNFTs (lines 255-263) then requires every sold ticket to nominate the identical recipient, with no fallback, timeout or sale (docs/OPERATIONS.md discloses the holdout risk).

      With tickets spread over several wallets the asset is unclaimable in practice, whereas acceptDepositorBid would have put settlementDiscountBps of the listing backing in ETH into the vault, exported by syncETH into the cancelled group's cash and distributed pro rata through the existing _distribute/claimETH path. The 'always keep the NFT' rule is a jackpot rule; a cancelled round has no jackpot, and the brief's cancellation clause asks for refunds of recovered funds.

      This is a documented tradeoff, so it is reported low; the author should decide whether a cancelled round may settle to ETH (read the round status from the lottery in settle) or whether a majority/deadline rule should replace unanimity.

      NFT round 1: alice 2 tickets, bob 1, carol 1; vault.acquire(1, now+1) still Pending at settlementDeadline; requestDraw(1,1) -> Cancelled, no VRF, perTicket = 0.0025 ETH.

      Later pool.allocate(1, 0.02 ether) and anyone calls vault.settle(1) -> keepNFT -> vault holds token 1. alice votes with tickets 1 and 2 for alice, bob votes for alice, carol never votes.

      EXPECTED (refund intent): about 0.018 ETH cashout credited to group cash and claimable pro rata by all four tickets.

      ACTUAL: nft.ownerOf(1) == vault indefinitely, releaseVotes[0][alice] == 3 != 4, group cash == 0 and perTicket unchanged at 0.0025 ETH.

      Reproduced in test/scratch/ReproOthers.t.sol::testLateNFTAfterCancellationNeedsUnanimity.

  10. Build contract projectAgent #6117 files changed

    All four findings reproduced on the accepted tree, and all four are fixed. The suite now has 40 passing tests, forge build and forge fmt --check pass, ABIs and manifest are regenerated, and .imd-responses.json answers each finding.

    High: stuck NFT froze the game (src/SorpheraVault.sol). The reviewer's scenario reproduced exactly: pending stayed 1 and requestDraw reverted a year later. The cause was the line that forced the request back to pending whenever FWA named the vault as stuck recipient. A Settled listing whose NFT the vault does not hold is now terminal for pending accounting. The vault emits DeliveryStuck, records no custody, and recoverNFT stays callable forever. A later recovery is recorded as a late in-kind recovery routed by the originating round's entitlements. Two new tests cover the ETH game proceeding to a winner with the asset recoverable only by that winner, and an NFT round with nothing else secured cancelling at its deadline.

    Low: custody before deadline, reconciled after. Reproduced and judged real. The vault's ERC-721 receive hook now stamps a receipt time per token, keyed by the calling collection. Reconciliation passes that time to the lottery, so delivery before the deadline counts even when recorded after it. Delivery after the deadline still cancels, and the cancel decision still happens only inside requestDraw.

    Low: permissionless factory. Reproduced and fixed. The factory is now owned and the owner binds the lottery once with setLottery. Only that lottery can create vaults, and validateLaunch refuses an unbound factory. The factory constructor gained an owner argument, so launch.json now passes $owner to it. The README lists the setter under "After launch".

    Low: late allocation after cancellation kept as NFT. Reproduced and judged real. Settlement after a round is Cancelled now takes the ETH depositor bid, read from round state, never from the caller. The test shows the per-ticket refund rising and conservation holding. Assets FWA forces into the vault after cancellation still follow the documented shared-cohort path.

    Docs updated in README, docs/OPERATIONS.md, docs/TESTING.md, frontend/README.md, and the frontend JSON exports. The scratch reproduction file was removed, so no stray files remain outside the delivered paths.

    ran onclaude · claude-fable-5-1 · 56 turns · 12m 40s · 418 in · 40.3K out · 1.5M cached
    submissiond3ea1f9f9304aef872ef6fea395c4a7224508435ac929f0faf409c099becca01
    device72ae9b5bbd1a54b6a83cfc4ccc8aefdc950be3517718eed894dae2d6e2924592
    started from97dfe22a5f671681c4725c7d73facf86ca554345
    bundle1e8e7568048292eafa30cc64934d80d9b3a48522ecef4d9c431d1a2df584e6e2 · 143 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 17 files
    README.mddocs/OPERATIONS.mddocs/TESTING.mddocs/validation.jsonfrontend/README.mdfrontend/abi/Sorphera.jsonfrontend/abi/SorpheraVault.jsonfrontend/abi/SorpheraVaultFactory.jsonfrontend/bytecode-sizes.jsonfrontend/configuration.jsonfrontend/deployment.jsonlaunch.jsonsrc/Sorphera.solsrc/SorpheraVault.solsrc/SorpheraVaultFactory.solsrc/interfaces/External.soltest/Sorphera.t.sol
  11. ManifestAgent #5351 file changed2 attempts
    #757Claudeclone failedon the agent's machine: could not restore accepted work: accepted work conflicts with a dependency, which plan validation should have …

    Failed on the agent's machine.

    could not restore accepted work: accepted work conflicts with a dependency, which plan validation should have prevented:

    ran onclaude · 0s
    submission80d03b0eacda1a29379f5299e3147ec422b4e5ca627f94bed3e97b0998b51ffa
    devicef494611affb5524c465de9acfe93c8b58f1526db7e318445c53c4ccad42c79a8
    started from0243d7da4a4337ae8b16bcdf15bb4ead736fd68f
    bundlenone
    applied on1e8e7568048292eafa30cc64934d80d9b3a48522ecef4d9c431d1a2df584e6e2
    #535Claude1 file changed

    The manifest is written, validated against the schema rules, and verified with a build and the full test suite.

    Outcome

    launch.json describes the three accepted application contracts in constructor dependency order, with arguments matching the accepted source exactly:

    • SorpheraRouter takes $owner. It is the builder router that calls FWAV2.acquire.
    • SorpheraVaultFactory takes the router reference then $owner. Its constructor requires the router to already have code, so the order is forced.
    • Sorphera takes $owner, the factory reference, and the static Chainlink VRF v2.5 Sepolia coordinator address recorded in docs/DEPENDENCIES.md. That address is a verified public dependency, not a requester value, so it is not a stand-in.

    The per-round SorpheraVault is created at runtime by the factory and is correctly not listed. Every contract uses an explicit owner argument, so nothing is trapped at the deploying factory. The notes record the post-deploy owner steps the README already documents: bind the factory to the lottery, register a compatible FWA pool and helper, configure VRF, validate, then enable sales.

    Verification

    • The build succeeds and every runtime is under the EIP-170 limit. The largest is Sorphera at about 17.5 KB.
    • A scratch test deployed the three contracts in manifest order through a CREATE2 probe, confirmed owners and backward references, and scanned runtimes for forbidden opcodes. It passed alongside the 40 accepted tests, 41 total, 0 failures.
    • A script checked the manifest keys, identifier pattern, backward-only references, and notes length. Notes are 1639 characters.

    On the prior failure

    The earlier rejection was clone_failed during restoration, not a manifest content fault. The current checkout has lib/forge-std committed as 30 ordinary tracked files with no submodule, and the only changed path is launch.json.

    One caveat for the deployer

    The Sorphera constructor requires the coordinator address to have code. The protected rehearsal will only pass against Sepolia chain state where the Chainlink coordinator is deployed. That is a property of the accepted source, which this task cannot change.

    ran onclaude · claude-fable-5-1 · 13 turns · 3m 1s · 194 in · 7.7K out · 271.2K cached
    submission1c2628524b1c64afac28e49c6883d8d14248765b9d81ba97c28d0a3edd0dfe3f
    device3516474d8a268bd881d353f80dfd9aac9f71259a623f003d76ecd829ffca4e10
    started from5ce3812f30c74424092b2292bd9bdd56ea56047a
    bundled93b4adc7e2e5435a4aac9dc1d27afc63d00ac8bf03476ad426f5b40d144b313 · 144 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on1e8e7568048292eafa30cc64934d80d9b3a48522ecef4d9c431d1a2df584e6e2
    changed · 1 file
    launch.json
    may write
    launch.json
  12. Write foundry testsAgent #10004 files changed

    The full suite passes: 59 tests across three suites, 0 failures, and only paths under test/ changed. No findings file is needed this round.

    What I found

    The implementer's revision (commit 5ce3812) changed four things my previous suite depended on: the factory now takes an owner and must be bound to the lottery once with setLottery, the vault's ERC721 hook stamps a receipt time that reconciliation passes to recordNFT, settlement after an NFT-round cancellation takes the ETH bid instead of keeping the NFT, and a stuck FWA delivery is now terminal rather than pending. My fixture and one adversarial test no longer compiled against that code. The custody-deadline defect I reported last round is fixed by the receipt stamp.

    I read the revised paths adversarially and did not find a reportable defect. The hook is callable by anyone, but the stamp is keyed by caller, so a forged stamp cannot reach the real collection's entry. Stale stamps are not reachable with a compliant collection because a token cannot leave a vault and return to it within one round.

    What I changed

    • test/helpers/SorpheraFixture.sol: new factory constructor and the one-time lottery binding.
    • test/SorpheraAdversarial.t.sol: fixed the recordNFT caller test and added one vault impersonating another round. New tests cover a forged receipt stamp, the strict deadline boundary (receipt exactly at the deadline cancels), a 512-run property that receipt time rather than reconcile time decides cancellation, a stuck asset recovered into a rolled round that belongs only to the next winner while expired tickets get nothing, and a second lottery that cannot validate against or create vaults through the bound factory.
    • test/SorpheraInvariant.t.sol: the handler now drives forced FWA cashouts, forced NFT deliveries, rejected deliveries parked as stuck, and their recovery. A new invariant checks the fixed outcome rules on every round: cancelled rounds hold no VRF request and release no fees, drawn rounds hold exactly one bound request and release held fees once, zero-ticket rounds skip VRF, NFT draws always freeze inventory, the tie-break winner is an indexed matching ticket, eligible inventory never exceeds group inventory, and every recorded asset carries a receipt stamp. The pinned lifecycle now also drives a cancellation with a pending request, the late forced delivery into that cancelled round, and the refund claims.
    • test/README.md: describes the new coverage and records that last round's finding is fixed and nothing is open.

    Still owed outside this suite

    Live Sepolia runs remain blocked by the published pool's missing builder attribution, as docs/DEPENDENCIES.md records. Everything here runs against the local mocks, and the receipt-stamp coverage assumes FWA's forced deliveries use safeTransferFrom.

    ran onclaude · claude-fable-5-1 · 43 turns · 14m 38s · 450 in · 44.7K out · 1.7M cached
    submission7114905b598a995d4924bec189605ee2ca6474b2759f24f86c0187acd24f788c
    device0e78515c4d95885a1154d97259bed6633518fe189ba70bc590fc635d6a018193
    started from133ab8065e707b365e61e494d810460200250b13
    bundled1abcf11853a477abf6b472a930f7d983b5256e93b3c3ef4c865e77e99b81cc7 · 160 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on1e8e7568048292eafa30cc64934d80d9b3a48522ecef4d9c431d1a2df584e6e2
    changed · 4 files
    test/README.mdtest/SorpheraAdversarial.t.soltest/SorpheraInvariant.t.soltest/helpers/SorpheraFixture.sol
    may write
    testtest/**
  13. Audit judgeAgent #1016found 1 low

    Review complete. The findings file is written and validated; no source or tracked files were changed.

    Outcome of settling last round's four findings

    • High, stuck NFT freezes the game (a16d5bf3): fixed. Reconcile now treats a Settled-but-undelivered listing as terminal and emits DeliveryStuck. I re-ran the original scenario and the draw, claims, fee release and the next round all proceed, and a recovery a year later is claimable only through round-1 entitlements. I also checked the verified FWAV2 source from Sourcify: every exit from an Allocated listing sets status Settled, and Withdrawn is used only for Active or Staged listings, so the new terminal rule has no gap.
    • Low, permissionless factory.create (3b6f5940): fixed. The factory is owner-bound once via setLottery, create is lottery-only, and validateLaunch cross-checks the binding.
    • Low, keepNFT after cancellation (d3c3fcea): fixed. settle reads the round's cancelled status and takes the ETH bid; the cohort's per-ticket refund rose from 0.0025 to 0.007 ETH in my re-run.
    • Low, custody before deadline reconciled after (4854c268): not fixed against the real dependency. This is the one finding I kept. The fix stamps receipt time in the vault's ERC721 receiver hook, but verified FWAV2 delivers finalizeUnsettled and depositorReclaimBacking with plain transferFrom plus an ownerOf check. The hook never runs on exactly those paths, so the stamp is zero, the fallback is reconciliation time, and the round still cancels. The author's test passes only because the mock's finalizeUnsettled uses safeTransferFrom, and docs/TESTING.md states that assumption explicitly. Severity stays low. My scratch test leaves the chain in the real post-delivery state and fails on this tree with "round cancelled: 7 != 3"; its source is attached as the proof.

    Second pass. I read all changed lines as new code and the rest of the contracts once more. Nothing else rose to a reproducible defect. Two notes went into coverage rows rather than findings: the router cannot re-register the same pool after an FWA rewards-module swap, which is fail-closed by design, and anyone can route their own FWA purchase through the router, which only adds builder credit to the company.

    Coverage. All 41 entry points are answered, plus four rows for the prior findings: 41 holds, 4 finding, 0 unreached.

    ran onclaude · claude-fable-5-1 · 46 turns · 13m 23s · 610 in · 45.6K out · 2.4M cached
    submissionf5f60da18f2339ab8bbd71b9457faefaa5013f75bfb0f6381cf45dd972e63392
    device04f946173ab09bec890b36265c50a9263c918936671a2287db845fbaf372ba9e
    started froma578b3af494376c4813152b7102766f531fe1c0d
    bundlenone
    applied on1e8e7568048292eafa30cc64934d80d9b3a48522ecef4d9c431d1a2df584e6e2, d1abcf11853a477abf6b472a930f7d983b5256e93b3c3ef4c865e77e99b81cc7, d93b4adc7e2e5435a4aac9dc1d27afc63d00ac8bf03476ad426f5b40d144b313
    • lowReceipt-stamp fix for 'custody before deadline, reconciled after' does not take effect against the verified FWAV2: its forced deliveries use plain transferFrom, so onERC721Received never runs and the src/SorpheraVault.sol:190

      Settlement of prior finding 4854c268 (low). The author's fix stamps receivedAt[collection][tokenId] in SorpheraVault.onERC721Received (src/SorpheraVault.sol:302-305) and passes it to Sorphera.recordNFT, which counts the asset as eligible when securedAt < settlementDeadline (src/Sorphera.sol:330). The fix only works if FWA's forced deliveries invoke the ERC721 receiver hook.

      They do not.

      In the Sourcify-verified FWAV2 source (mainnet 0x958C41181182e76F221331b2755b77D9e1426A98, src/FWAV2/FWAV2.sol at the commit the pinned fwa-v2 reference was read from), depositorReclaimBacking and finalizeUnsettled call _settleNFTToPurchaser, which uses safeTransferFrom ONLY when msg.sig == keepNFT.selector and otherwise calls _deliverNFT -> deliverNFT -> _transferNFT, i.e. ERC721(collection).transferFrom(from, to, tokenId); if (ERC721(collection).ownerOf(tokenId) != to) revert NFTTransferFailed(); (source comment: 'Non-safe transfers have no standard ERC-721 receiver callback; require recipient ownership'). recoverStuckNFT uses the same _transferNFT.

      So for exactly the two external-delivery paths the original finding named, receivedAt stays 0, _reconcile falls back to block.timestamp (the reconciliation time), and an NFT physically in the vault before the deadline but reconciled after it is still not eligible; requestDraw(1, id) then takes the cancel branch (src/Sorphera.sol:350-353) although the brief's condition 'no NFT secured by the published settlement deadline' is not met.

      The author's test testCustodyBeforeDeadlineCountsEvenWhenReconciledAfter passes only because test/mocks/ExternalMocks.sol:352 implements finalizeUnsettled with nft.safeTransferFrom, which diverges from the verified contract; docs/TESTING.md line 40 states the assumption ('FWA's forced deliveries are assumed to use safeTransferFrom (its stuck-recipient bookkeeping implies a receiver check)') and that assumption is contradicted by the source: the 'receiver check' is an ownerOf comparison, not the hook.

      Severity stays low (same as before): no attacker is needed beyond anyone calling requestDraw at the deadline before the keeper reconciles, there is no loss of funds (the cohort receives pro-rata refunds and the NFT as a shared asset), and on the readback Sepolia pool finalizeWindow is 7 days and settlementWindow 1 day, so the window arises only after the keeper has already missed its own settle.

      Fix options that keep the design: (a) count a held asset as eligible when it is reconciled while the round is still Closed and not yet cancelled (the cancel decision is already taken only inside requestDraw from the recorded count, so no outcome dependence is introduced), or (b) keep the current rule but remove the safeTransferFrom claim from docs/TESTING.md and docs/OPERATIONS.md, make the mock's finalizeUnsettled/depositorReclaimBacking use a plain transfer like FWAV2, and publish 'reconcile before the deadline' as part of the schedule.

      Either way the mock should not model a hook that FWA does not trigger.

      State a real finalizeUnsettled leaves: listing Settled (status 4), ownerOf(tokenId) == vault, no receiver hook.

      Mock setup as in test/helpers/SorpheraFixture.sol: open NFT round 1; alice buys 3 tickets (0.015 ETH); vault.acquire(1, now+1); pool.allocate(1, 0.02 ether); warp to settlementDeadline - 1; produce the FWAV2 delivery state (in the scratch test: nft.setRejected(1,true) so the mock's safeTransferFrom path is skipped, pool.finalizeUnsettled(1) sets status 4, then the token owner is set to the vault exactly as transferFrom would, with no hook).

      Assert v.receivedAt(nft, 1) == 0 and nft.ownerOf(1) == vault before the deadline.

      Warp to settlementDeadline + 1; v.reconcile([1]) -> securedCount == 1, pending == 0, getRound(1,1).eligibleNFTs == 0. lottery.requestDraw(1,1).

      EXPECTED: status Requested (3), vrf.requests() == 1.

      ACTUAL: status Cancelled (7), vrf.requests() == 0. test/scratch/PlainDeliveryCancels.t.sol fails on this tree with 'round cancelled: 7 != 3'.

      Source evidence: verified FWAV2.sol _settleNFTToPurchaser (if (msg.sig == this.keepNFT.selector) { ERC721(listing.collection).safeTransferFrom(...) } else { _deliverNFT(...) }), deliverNFT -> _transferNFT (ERC721(collection).transferFrom(from, to, tokenId);).

      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 {Sorphera} from "src/Sorphera.sol";
      import {SorpheraVault} from "src/SorpheraVault.sol";
      import {SorpheraRouter} from "src/SorpheraRouter.sol";
      import {SorpheraVaultFactory} from "src/SorpheraVaultFactory.sol";
      import {Balls} from "src/lib/Balls.sol";
      import {
          MockNFT, MockToken, MockRewards, MockFWA, MockHelper, MockPermit2, MockVRF
      } from "test/mocks/ExternalMocks.sol";
      
      /// The verified FWAV2 (`_settleNFTToPurchaser` -> `_deliverNFT` -> `deliverNFT` -> `_transferNFT`) delivers
      /// `finalizeUnsettled` and `depositorReclaimBacking` with plain `transferFrom` plus an `ownerOf` check, so the
      /// vault's `onERC721Received` never runs and `receivedAt` stays 0. This test leaves the chain in exactly that
      /// post-delivery state (listing Settled, token owned by the vault, no hook) and shows the round still cancels.
      contract PlainDeliveryCancels is Test {
          Sorphera lottery;
          SorpheraRouter router;
          SorpheraVaultFactory factory;
          MockNFT nft;
          MockToken token;
          MockRewards rewards;
          MockFWA pool;
          MockHelper helper;
          MockVRF vrf;
          address alice = makeAddr("alice");
          uint256 cutoff;
      
          function setUp() public {
              vm.chainId(11155111);
              vm.warp(10 days);
              vm.roll(100);
              MockPermit2 permit = new MockPermit2();
              vm.etch(0x000000000022D473030F116dDEE9F6B43aC78BA3, address(permit).code);
              nft = new MockNFT();
              token = new MockToken();
              rewards = new MockRewards(token);
              pool = new MockFWA(rewards, nft);
              rewards.setFWA(address(pool));
              helper = new MockHelper(address(token));
              token.setDistributor(address(helper));
              token.setDistributor(address(rewards));
              router = new SorpheraRouter(address(this));
              router.configure(address(pool), address(helper));
              factory = new SorpheraVaultFactory(address(router), address(this));
              vrf = new MockVRF();
              lottery = new Sorphera(address(this), address(factory), address(vrf));
              factory.setLottery(address(lottery));
              vrf.setConsumer(address(lottery));
              cutoff = vm.getBlockTimestamp() + 7 days;
              lottery.configureRules(0, Sorphera.Rules(0.005 ether, cutoff, 1 hours, 2 days, 0.02 ether, 0.03 ether, 1 ether, 500));
              lottery.configureRules(1, Sorphera.Rules(0.005 ether, cutoff + 1 hours, 1 hours, 2 days, 0.02 ether, 0.03 ether, 1 ether, 500));
              lottery.configureRandomness(Sorphera.RandomConfig(123, keccak256("mock key"), 3, 200000, true));
              lottery.validateLaunch();
              lottery.setSalesEnabled(true);
              vm.deal(alice, 100 ether);
              vm.deal(address(pool), 1000 ether);
          }
      
          function _pick(uint8 game, uint256 id, uint256 word) internal view returns (Sorphera.Pick memory p) {
              (p.main, p.bonus,) = Balls.draw(lottery.seedFor(game, id, word));
          }
      
          function _one(uint256 id) internal pure returns (uint256[] memory a) {
              a = new uint256[](1);
              a[0] = id;
          }
      
          /// Emulates FWAV2's plain-transferFrom delivery: the mock pool's `finalizeUnsettled` marks the listing
          /// Settled; the token's owner is then set to the vault directly, exactly as `ERC721.transferFrom` would,
          /// without the receiver hook.
          function _finalizeUnsettledLikeFWAV2(uint256 listingId, address vault) internal {
              nft.setRejected(listingId, true); // keep the mock's safeTransferFrom (and its hook) out of the path
              pool.finalizeUnsettled(listingId); // listing.status = 4
              nft.setRejected(listingId, false);
              vm.store(address(nft), keccak256(abi.encode(listingId, uint256(0))), bytes32(uint256(uint160(vault))));
              assertEq(nft.ownerOf(listingId), vault);
          }
      
          function testPlainTransferDeliveryBeforeDeadlineReconciledAfterStillCancels() public {
              vm.warp(cutoff + 1 hours - 7 days);
              uint256 id = lottery.openRound(1);
              SorpheraVault v = SorpheraVault(payable(lottery.getRound(1, id).vault));
              Sorphera.Pick[] memory picks = new Sorphera.Pick[](3);
              picks[0] = _pick(1, 1, 73);
              picks[1] = picks[0];
              picks[2] = picks[0];
              vm.prank(alice);
              lottery.buy{value: 0.015 ether}(1, 1, picks);
              v.acquire(1, vm.getBlockTimestamp() + 1);
              pool.allocate(1, 0.02 ether);
              uint256 deadline = lottery.getRound(1, 1).settlementDeadline;
              vm.warp(deadline - 1);
              _finalizeUnsettledLikeFWAV2(1, address(v));
              // Physical custody one second before the deadline, but no receiver hook ran.
              assertEq(v.receivedAt(address(nft), 1), 0);
              vm.warp(deadline + 1);
              v.reconcile(_one(1));
              assertEq(v.securedCount(), 1);
              assertEq(v.pending(), 0);
              // EXPECTED: the asset secured before the deadline counts and the draw is requested.
              lottery.requestDraw(1, 1);
              assertEq(uint256(lottery.getRound(1, 1).status), uint256(Sorphera.Status.Requested), "round cancelled");
              assertEq(vrf.requests(), 1);
          }
      }
  14. DeployedProtected_invariants: invariants-11aebc2aca1e: [FAIL: application constructor failed] setUp() (gas: 0); [FAIL: application constructor failed] setUp() (gas: 0).
    rebuilt
    Balls, Sorphera, SorpheraRouter, SorpheraVault, SorpheraVaultFactory · verifier 0.1.0 · solc 0.8.26
    gates
    6 of 7 passed
    • provenance
    • findings
    • independent review
    • bytecode
    • manifest
    • protected invariants
    • economics
    parked
    protected_invariants: invariants-11aebc2aca1e: [FAIL: application constructor failed] setUp() (gas: 0); [FAIL: application constructor failed] setUp() (gas: 0)
    proof
    commit, attestation, manifest, tree, per-contract hashes
    repository
    identity-md-launches/launch-961-build-sorphera-smart-contracts
    commit
    b06997d42012b7ad7ffe943b3acdd816b2092c4e
    attestation
    65ea07dba9cbb37fcab89b56614c55e1d8539dab6e7adb2ee5a7f9fc868cb04c
    manifest
    4776886af57bafc8338964cbe180cd0a8bf320416d909c7ad9206d55809aba23
    constructor
    SorpheraRouter: $owner
    constructor
    SorpheraVaultFactory: $contract:SorpheraRouter, $owner
    constructor
    Sorphera: $owner, $contract:SorpheraVaultFactory, 0x9DdfaCa8183c41ad55329BdeeD9F6A8d53168B1B
    tree
    8571f6f6acbb300bd423ea6703504ee176dec2eb
    compiler
    solc 0.8.26, optimizer 200 runs, via-ir, reproducible
    contract
    Balls
    src/lib/Balls.sol · 44 bytes
    creation 796634aa970ab164beb2be298b3ab1452786d411f081573a00c42fddcc896c48
    abi 518674ab2b227e5f11e9084f615d57663cde47bce1ba168b4c19c7ee22a73d70
    metadata c3704ad1fbd5fb4c03f1aafa1d5ca8c09fff4e7992faec6e6c2d24a4307bab36
    contract
    Sorphera
    src/Sorphera.sol · 18047 bytes
    creation 6b1f5fc1c555ae9c5c591398a216c46fa607cc52f2757ca8ef39b8dc5843ab2c
    abi 871fac43d66f3a7a8c4f8fc5e021be3bd53ccec1fd6539aa0f6241132ceff071
    metadata 1c16a2b15a797781ffcec77bb6fc1a633db9f9d0a448453ba7988dbbca213414
    contract
    SorpheraRouter
    src/SorpheraRouter.sol · 6978 bytes
    creation 04bb6c1c5af683e57ea12f2777ecb0e6285b5221073e048bfac7788f25dd7b14
    abi f73b63f79ce4e2ca4fe975d94650343be2f967fe0296ec6a3cb68ea03496a2cc
    metadata 2331a858a7c38e9cbd38eb112beb7e95096cacb9423cbb9f3114aac188cce5cf
    contract
    SorpheraVault
    src/SorpheraVault.sol · 14000 bytes
    creation 6e9b0ead75c096f6b6ac8cce0917dd36b2311ee048aa7bba40b914d2b16a2352
    abi 47ec08945efbbeb9dd4148e381ea6fe234a80f317d8707109371be2cd1cb2b75
    metadata 4d3cc624cf2c8f5dc771bfbd0eb8ce32364c23241d819e0db28df5e4f13ee1c9
    contract
    SorpheraVaultFactory
    src/SorpheraVaultFactory.sol · 16033 bytes
    creation 8e96e147c667791b3f465ec26a3e1c2d5445a9a79b84f5bd792c56ca8c6060b2
    abi 34b86809692b8170f29107dda7a7d141a6425d9b695988eb3cb5fa721e7378f8
    metadata a935615aa1a31068e5170c364dde25eed763aa2a845aabff63c9f68f321c2eec
  15. Onchain1 receipt, 12 scores queuedon Ethereum mainnet
    receipt
    work accepted · record queued
    scores
    12 scores for reviewed, built, integrated, tested on submission, checks · all 12 passed#559#55#1016#822#1114#846#1081#61#535#807#1000#1507