{"assessments":[],"deployments":[{"attestationHash":"09c28cc5f3ccd9c0eb5cbd0f0c1441255a8718183ae5bb01ca8d23f64a660dfe","chainId":1,"contracts":[{"address":"0x3fc72adfed89284e4f3f378646a5338f2f1e14ea","blockNumber":26213429,"name":"AskSwarmRegistry","role":"other","txHash":"0xf2280fbd1e5c63eaffe6aa53cfc3a3ada0accabd1ed2db4459a0a29e5b86c8b9"}],"id":"11fd096c-23fe-45a1-a957-20dd29747efa","manifestHash":"647622e05f94641f5c315bada02cc3a50077acc0527b36709dafd676f4f43039","status":"live"}],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"64fa31ee-3f0c-493f-b7ad-b2d93eb8b852","kind":"shape:chain","nodes":[{"acceptedSubmissionHash":"3b092ade317094b0d2868f202ec5a22560a9fad746a4f7f72fe15af367253dfa","dependsOn":["audit_imported_code"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"2d7d84a1a9972f1497e5bc68a704cbf42294d08c62d2868f102ffe3b909b9b51","skillId":"adapt-contract-project","tools":[]},"key":"adapt_contract_project","kind":"code","role":"implement","skillHash":"2d7d84a1a9972f1497e5bc68a704cbf42294d08c62d2868f102ffe3b909b9b51","skillId":"adapt-contract-project","state":"accepted"},{"acceptedSubmissionHash":"eef59d584c9b16d7731f894f83f46fd3570e81b678a5f4e62eb302d36b8e3d52","dependsOn":["adapt_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"53fb4c4b44529c203ff1e03cd676a7f5eed01f3695b7d57942274999255c6085","dependsOn":["adapt_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"a4289452ad4d3accbd4dde1cde692b73e1d599eca83d57a180701502dad52e80","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"9897597976b0e72440db7ca402d225aea20f3d37205ba53e7df2400ff80f29cc","skillId":"audit-imported-code","tools":[]},"key":"audit_imported_code","kind":"code","role":"review","skillHash":"9897597976b0e72440db7ca402d225aea20f3d37205ba53e7df2400ff80f29cc","skillId":"audit-imported-code","state":"accepted"},{"acceptedSubmissionHash":"5481ecac09bfc9b1749108514c6169d036207f3dd7b4c3f5a01cf47c3d9be795","dependsOn":["adapt_contract_project","write_foundry_tests","manifest","audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"42d0965257f1949ddf31d951bbdc66a570690548be74f8384b37d42710defe29","dependsOn":["adapt_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"804b1f0036b29360a73692ab668bae260f2abee909aa483ebc6003a985c7626d","dependsOn":["adapt_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"156dd9f78e3ac6d29da4398de5a1b60f66e14667f0cef2b2029de90970d1996d","dependsOn":["adapt_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"tools":[]},"key":"manifest","kind":"code","role":"integrate","skillHash":null,"skillId":null,"state":"accepted"},{"acceptedSubmissionHash":"78bce931021526182d4dc22fe641615bfc1eaf4ac3bf90a9e4572dcd8fe22a9f","dependsOn":["adapt_contract_project"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"4c76766b00591dfe982fe85d3de589fdb2f810e5137c301155d1c5e36a1d3b23","skillId":"write-foundry-tests","tools":[]},"key":"write_foundry_tests","kind":"code","role":"tests","skillHash":"4c76766b00591dfe982fe85d3de589fdb2f810e5137c301155d1c5e36a1d3b23","skillId":"write-foundry-tests","state":"accepted"}],"objective":"Audit and deploy AskSwarmRegistry (contracts/src/AskSwarmRegistry.sol, Foundry project in contracts/) on Ethereum mainnet, unchanged if it passes review.\n\nWhat it is: a public, verified memory of what the IMD swarm has attested. Anyone calls ask(bytes body) with an oracle.request JSON body; the contract pulls exactly Intake's price in IMD from the caller, forwards it in the same call to Intake v2 (0xa43e6f75ee006411f79ac1c84120606c2330de82) via requestWithFailure with itself as callback, and holds nothing between calls. When the panel reaches quorum, Intake delivers (bytes32 requestId, OracleAttestation a, bytes signature) to onOracleResult; the contract recomputes the EIP-712 digest under domain {\"IdentityMD Oracle\", \"2\", chainId, address(this)}, recovers the attester 0x5598aa9146215bc13eb26f2c692ad1461fd32982, requires agreed >= quorum and not expired, and stores the answer in three storage slots indexed by questionHash. Refusals and split panels land in onFailure and are recorded by status. Reads are free: answerOf(questionHash), get(requestId), latestFor.\n\nConstructor: (address intake, address imd, address signer) = (0xa43e6f75ee006411f79ac1c84120606c2330de82, 0xd34a99bc0f67ae1bbd63c660e6d0b0dd03e263b7, 0x5598aa9146215bc13eb26f2c692ad1461fd32982). No owner, no admin, no upgrade path, no ETH handling, no loops. The callback is measured at ~105k gas, under the 200k stipend. Fork tests in contracts/test cover delivery, tampering, wrong domain, quorum/expiry gating, failure hooks, access control, long answers and gas.\n\nIt is the onchain half of AskSwarm (github.com/ChromiaAgents/askswarm): a Telegram bot where people pay in CHR through Chromia Pay or in IMD from their own wallet, and every paid answer becomes public verifiable memory through this contract.","parentJobId":null,"planHash":"9f7792ca6dd3f47ec2234226e45dfa3e61700fb7885a9a7fc8ed935e116b8938","previousHash":"05c494670912919f11e0d64b89684bccbf64b78e5b5dbce8cddcd47f9e623187","projectId":"64fa31ee-3f0c-493f-b7ad-b2d93eb8b852","publication":{"commit":"85f69baacf2141df249477cf1c5d926b9b4e2e7c","deliveredAt":"2026-10-17T16:18:49.711Z","repoUrl":"https://github.com/identity-md-launches/launch-2088-src-askswarmregistry-sol"},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[],"site":null,"snapshotHash":"e510c0ebf852e43cdc275a0bd9f748f91112040a8ad71e6e6d51a96c76c9d607","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":"9fdbcb76b3f167e639cd9394dedff420c1ea57e45ee619ce335bbeaa808ec7a7","device":"e5ed95d8889ee194","findings":[],"hash":"156dd9f78e3ac6d29da4398de5a1b60f66e14667f0cef2b2029de90970d1996d","nodeId":"644a75eb-f8ad-469a-a15c-5f6d7a6770a3","outcome":"completed","summary":"Created [launch.json](/home/ubuntu/.identitymd/work/64fa31ee-3f0c-493f-b7ad-b2d93eb8b852/644a75eb-f8ad-469a-a15c-5f6d7a6770a3/launch.json) with the approved constructor addresses, verified against the compiled ABI.\n\n`forge build` passed. All 23 tests passed, including 256 fuzz cases with the required seed.\n\nOnly `launch.json` is changed for submission.","treeHash":"16be77b0f95ac2230bfe831bee7c75cfe6868bf9","usage":{"cachedInputTokens":253440,"inputTokens":35933,"model":"gpt-6-astra","outputTokens":3835,"runtime":"codex","turns":4,"wallClockMs":106806}},{"artifacts":[],"attempt":1,"bundleHash":"fb4970d558595fc26ee9babb044612fab863bbc6a2f902639dd3cdf48e3bad03","device":"e0f64c10e30e33e2","findings":[],"hash":"3b092ade317094b0d2868f202ec5a22560a9fad746a4f7f72fe15af367253dfa","nodeId":"f11883c7-abc9-4eb6-a61b-2a6f46e10319","outcome":"completed","summary":"AskSwarmRegistry is ready for the mainnet launch. It needed three small changes rather than going out unchanged: one launch rule and both audit findings, which reproduced against the original code. `forge build` and `forge test` pass: 23 tests, with no environment variables, no network and the given fuzz seed. The registry's 19 tests also pass on a mainnet fork against the real Intake v2 and IMD. The project is at the repository root, not in `contracts/`, and I left its configuration untouched.\n\n**Contract changes (`src/AskSwarmRegistry.sol`):**\n1. **No call in the constructor (launch rule).** The constructor used to give Intake an unlimited IMD approval, but launch constructors run on an empty chain and must not call other contracts. `ask` now approves Intake for exactly the price just before forwarding it, so nothing is left approved afterwards. That adds one cheap `approve` per ask.\n2. **Expired answers are no longer marked verified (audit finding, low).** An answer that had already expired on arrival was stored as `verified = true` with status `Answered`, though the brief says it must not be expired. It is now recorded as `Unverified`; an answer arriving exactly at its expiry time still counts. One existing test asserted the old behaviour, so it now asserts the new one, and ADAPTATION.md explains why.\n3. **A minority answer can't take over a question (audit finding, medium).** The audit's exact case reproduced: 11 votes out of a 100-seat panel with quorum 2 replaced an answer 11 of 15 had agreed on.\n   - Now an answer becomes the one `answerOf` returns only if more than half its panel agreed. The existing rule that the replacing panel must be at least as large, with at least as much agreement, stays.\n   - I chose this over the audit's other suggestion, comparing agreement ratios, because that would let a cheap 2-of-2 answer block a 99-of-100 one until it expires.\n   - Minority answers are still stored and verified for the record.\n\nAll five new or changed tests for these fixes fail on the original contract and pass now. The fixes cost about 345 gas in the callback, and delivery through the real Intake still fits the 200k gas limit. Runtime is about 8.2 KB, with none of the forbidden opcodes.\n\n**Tests and docs:**\n- **Offline tests:** the original suite only ran on a mainnet fork, so it couldn't pass on the verifier, which has no network. When there is no fork it now swaps in stand-ins for Intake and IMD at the real addresses (`test/mocks/IntakeMocks.sol`); with `--fork-url` it uses the real contracts as before.\n- **New tests:** a fuzz test, failure-hook and unknown-request tests, and the oracle protocol's conformance test (`test/OracleConsumerConformance.t.sol`). The conformance test confirms the contract's signature check matches the protocol's published example.\n- **Docs:** `ADAPTATION.md` lists each change, its file and the rule or finding behind it, plus the points I reviewed and left alone. `README.md` describes the new approval and majority rules and how to run the tests.\n\nNothing is committed, and there is no `launch.json`, since the next step writes it.","treeHash":"965c055f3fd9e3f01ff9915ff16cb622449c9d8f","usage":{"cachedInputTokens":1247990,"inputTokens":36,"model":"claude-opus-5-5","outputTokens":22983,"runtime":"claude","turns":21,"wallClockMs":371137}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"ee2cc12e7dfe571a","findings":[{"citation":"resolved","description":"Seam: expiry boundary x 'latestFor is the latest verified answer' invariant. The index decision is made only at delivery time and compares strength (panelSize, agreed) but never expiry. A verified majority answer N that arrives while a stronger answer H still holds the index is never indexed, and nothing re-evaluates the index when H later expires. From then on isCurrent(qh) is false and answerOf(qh, 0, 0) returns no request id, although the registry holds a verified, unexpired, majority answer for that question in _answers[n]. The AskSwarm bot's free tier reads answerOf before charging, so it will report 'nothing current' (or hand back the expired H from the one-argument answerOf) and charge for a question the registry already has a valid answer to. The same seam appears with equal strength: a later equal-strength answer with an earlier expiresAt displaces one that would have stayed valid longer. Minimal fix that keeps the design: when the held answer is current but the new one is not stronger, still take the new one if cur.expiresAt < a.expiresAt and the new one is at least as strong as the asked minimum you want to protect, or keep a second 'longest-lived verified majority' pointer that readers fall back to when the primary has expired. Alternatively let answerOf(qh,minPanel,minAgreed) and isCurrent be documented as 'may be false while get(requestId) holds a current answer'.","line":226,"path":"src/AskSwarmRegistry.sol","reproduction":"Mock Intake etched as in test/AskSwarmRegistry.t.sol (chain 1, timestamp T). 1) ask() -> h; deliver attestation H {questionHash Q, panelSize 15, quorum 11, agreed 15, expiresAt T+3600}, signed by SIGNER: latestFor[Q] == h. 2) ask() -> n; deliver N {Q, 15, 11, agreed 11, expiresAt T+30 days}: ok == true, majority (22 > 15), but held == true and 11 >= 15 fails, so latestFor[Q] stays h; get(n).verified == true. 3) warp to T+3601. Expected: a reader finds the current verified answer N. Actual: isCurrent(Q) == false, answerOf(Q,0,0) returns requestId 0, answerOf(Q) returns the expired H, and latestFor[Q] never moves to n (no later call re-evaluates it). Reproduced in a scratch Foundry test (test_indexHoldsExpiredWhileFreshMajorityExists).","severity":"low","snippet":"            bool held = cur.verified && block.timestamp <= cur.expiresAt;\n            if (!held || (a.panelSize >= cur.panelSize && a.agreed >= cur.agreed)) {","title":"Index keeps an expired answer while a fresh, verified, majority answer for the same question sits unindexed"},{"citation":"resolved","description":"Seam: two counts on different denominators are compared as if on the same scale. 'Strength' is (panelSize >=, agreed >=), so any panel at least as large whose plurality reaches the held answer's absolute agreed count wins, even though its agreement ratio is lower. The asker chooses panelSize and quorum for a flat 0.5 IMD, so for a contested question a payer can repeatedly ask with panelSize 20, quorum 2; the first run in which 11 of 20 panelists (55%) back a different answer flips the canonical answer away from an 11-of-15 (73%) result. The majority gate added for finding ddc3dd9c (agreed*2 > panelSize) stops sub-50% pluralities but not this band between 50% and the held ratio. The invariant text ('a weak panel cannot overwrite a strong one') is only true under the contract's own absolute definition of strength. Minimal fix preserving the design: in the displacement test also require the new ratio not to be lower, e.g. uint256(a.agreed) * cur.panelSize >= uint256(cur.agreed) * a.panelSize (cross-multiplication, no division, fits in uint256 since both operands are uint16).","line":227,"path":"src/AskSwarmRegistry.sol","reproduction":"Mock Intake as in the suite. 1) ask() -> h; deliver H {questionHash Q, answer 'CHF', panelSize 15, quorum 11, agreed 11, expiresAt T+1 day}: latestFor[Q] == h. 2) ask() -> n; deliver N {Q, answer 'EUR', panelSize 20, quorum 2, agreed 11, expiresAt T+1 day}. Check: ok == true, 22 > 20 majority, held == true, 20 >= 15 and 11 >= 11 both true. Expected (per the stated invariant): the 73% answer keeps the index. Actual: latestFor[Q] == n, answerOf(Q).answer == 'EUR' with agreed/panelSize = 55%. Reproduced in a scratch Foundry test (test_lowerRatioLargerPanelDisplaces).","severity":"low","snippet":"            if (!held || (a.panelSize >= cur.panelSize && a.agreed >= cur.agreed)) {","title":"Displacement compares absolute agreement across panels of different sizes: a 55% plurality (11 of 20) replaces a 73% supermajority (11 of 15)"},{"citation":"resolved","description":"Boundary x invariant: the early return for long answers skips signature verification and storage entirely, so a paid, quorum-reaching, correctly signed answer is recorded as Unverified, its Answered event carries an empty answer, get(requestId) returns a zero record (answerHash keccak256(''), verified false) and the question is never indexed. abi.encode(address[] n) is 64 + 32n bytes, so 126 entries (4096 bytes) are kept and 127 entries (4128 bytes) are dropped; the same holds for bytes32[]. The asker has paid 0.5 IMD for an answer the registry cannot return. The cap is justified in the comment as gas protection, but the measured cold minimum gas for the callback is ~113k at 32 bytes and ~150k at 4096 bytes (about 9 gas per extra byte, dominated by the 8 gas/byte of emitting the raw answer), so hashing alone is not what threatens the 200k stipend. Minimal fix keeping the three-slot layout: on the long path still compute keccak256(a.answer) (calldata keccak is ~6 gas per word plus a linear copy; 16 KiB costs under ~10k), verify the signature and store the hash with long = true, and emit the hash rather than the raw bytes above the cap. Or raise MAX_ANSWER_BYTES toward ~8 KiB where the raw-emit path still fits with margin.","line":185,"path":"src/AskSwarmRegistry.sol","reproduction":"Mock Intake as in the suite. ask() -> id. Build list = address[127] (any distinct addresses), answer = abi.encode(list) (4128 bytes), attestation {questionHash Q, answerType 4, panelSize 15, quorum 11, agreed 11, expiresAt T+1 day}, signed by SIGNER over digest(a). writer calls Intake.complete(id, 0, 0, '', abi.encode(id, a, sig)). Expected: verified == true, answerHash == keccak256(answer), latestFor[Q] == id. Actual: asks[id].status == Unverified (5), get(id).verified == false, get(id).answerHash == keccak256('') (0xc5d2...a470), latestFor[Q] == 0. With 126 entries (4096 bytes) the same delivery is stored and indexed. Reproduced in a scratch Foundry test (test_listLengthAtCap).","severity":"low","snippet":"        if (a.answer.length > MAX_ANSWER_BYTES) {","title":"A validly signed list answer of 127 or more entries (answer > 4096 bytes) is discarded as Unverified with nothing stored"},{"citation":"resolved","description":"Boundary: when latestFor[questionHash] is zero, or the request has no Stored record (never answered, failed, or dropped for length), _read builds an empty bytes and hashes it, so Answer.answerHash is 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 rather than bytes32(0). The three-argument answerOf returns a zeroed struct (answerHash 0) on its filtered path, so the two read forms disagree on the 'no answer' sentinel. A reader testing answerHash != 0 for 'has an answer' is misled. Fix: return bytes32(0) as answerHash when !s.long && s.answerLen == 0 && !s.verified, or document that callers must test requestId != 0 / verified.","line":305,"path":"src/AskSwarmRegistry.sol","reproduction":"Fresh deployment. answerOf(bytes32(1)) returns (requestId 0, a) with a.answerHash == keccak256('') and a.verified == false. get(anyUnansweredId) likewise. answerOf(bytes32(1), 0, 0) returns a.answerHash == 0. Reproduced in a scratch Foundry test (test_emptyReadsHashOfEmpty).","severity":"info","snippet":"            h = keccak256(ans);","title":"Reads of an unindexed question or an unstored request return keccak256(\"\") as answerHash instead of zero"},{"citation":"resolved","description":"Boundary on the uint8 status: Intake v2 routes any non-zero status to the failure selector and only requires args[32:64] == status. Today it emits 1 (refused) and 2 (no result), but a future status 3+ would be recorded as NoResult ('panel split') rather than as an unknown outcome. The raw status is in the Failed event, so the record is recoverable off chain; nothing moves value. Fix: keep NoResult only for status == 2 and add an Unknown/Other status, or document the mapping.","line":258,"path":"src/AskSwarmRegistry.sol","reproduction":"Mock or live Intake: writer calls complete(id, 3, 0, '', abi.encode(id, uint8(3), bytes32('x'), 0, 0, '')). Expected: a status that says the outcome was not one the registry understands. Actual: asks[id].status == NoResult (4).","severity":"info","snippet":"        k.status = status == 1 ? Status.Refused : Status.NoResult;","title":"onFailure maps every status other than 1 to NoResult, including statuses Intake may define later"}],"hash":"42d0965257f1949ddf31d951bbdc66a570690548be74f8384b37d42710defe29","nodeId":"ec7e5634-e125-4f07-ae62-98083879fc0e","outcome":"completed","summary":"The review is complete. `.imd-findings.json` holds five findings and nine coverage rows, the scratch tests are removed, and git reports a clean tree apart from the ignored findings file.\n\n**Outcome: no critical, high or medium defects in the math, boundary or numerical-gap area.** The contract has no division, every arithmetic step is widened or bounded, and the expiry boundary is treated the same way on write and read. I cross-checked against the live chain: Intake v2 source, its current callback stipend, price and writer, plus the IMD token source.\n\nFindings written, all substantiated with a reproduction that I ran:\n\n- **Low, index vs expiry seam.** A verified majority answer that arrives while a stronger answer holds the index is never indexed. When the stronger one expires, `isCurrent` and the filtered `answerOf` report nothing, though a valid answer sits in storage. Nothing re-evaluates the index later.\n- **Low, cross-scale comparison.** Displacement compares absolute `agreed` across panels of different sizes. An 11-of-20 result at 55 percent replaces an 11-of-15 result at 73 percent. The majority gate added for the earlier audit does not close this band.\n- **Low, answer cap boundary.** A correctly signed list answer of 127 or more entries exceeds 4096 bytes and is dropped as Unverified with nothing stored. Measured cold callback gas is 150k at the cap, so hashing on that path would fit the stipend.\n- **Info.** Empty reads return the hash of empty bytes rather than zero, and the two `answerOf` forms disagree on the sentinel.\n- **Info.** Any failure status other than 1 is recorded as NoResult.\n\nVerified as holding: the 200k stipend at the answer cap (113k at 32 bytes, 150k at 4096 bytes, cooled storage), the majority arithmetic, the `_left32` mask and downcasts, the three-slot packing, signature edge handling, exact pull and approve in `ask` against the real Intake, and constructor arguments against the live contracts. The one static-analysis lead in my area, reentrancy in `ask`, does not hold because IMD is a hook-free OpenZeppelin ERC-20 and Intake forwards to a fixed treasury.\n\nNot reached: nothing within the assigned area. Trust-gap items such as the unpaired oracle request UUID and the owner-settable Intake stipend are documented design assumptions, outside this area, and left to the other specialists.","treeHash":null,"usage":{"cachedInputTokens":1970851,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":37628,"runtime":"claude","turns":37,"wallClockMs":563568}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"f768e94767a9dde3","findings":[{"citation":"resolved","description":"latestFor[questionHash] moves to any verified majority answer whose panelSize and agreed are at least the held answer's. Strength does not include expiresAt. The asker of a body chooses panelSize, quorum and validForSeconds (60 s to 30 days) at the flat 0.5 IMD price, and the same question text, definitions and absolute window give the same questionHash. So anyone can re-ask a question that already has a 24-hour, 11-of-15 canonical answer with panelSize 100, quorum 51 and a validity just above the plane's delivery latency (e.g. 600 s). When the panel agrees (any majority answer, the content does not matter), the new attestation takes latestFor because 100 >= 15 and 60 >= 11. Ten minutes later isCurrent(questionHash) is false and answerOf(questionHash, 15, 11) returns a zero request id, although the original 11-of-15 answer is verified and valid for another ~23 h and still sits in _answers. The README invariant ('a weak panel cannot overwrite a strong one') is kept, but the index the free-tier readers rely on (the bot reads answerOf before charging anyone) is degraded from a day to minutes for 0.5 IMD, and no reader can recover the still-valid answer through answerOf or latestFor; the question has to be paid for again. No funds leave the contract. Minimal fix that keeps the design: also require the newcomer to be at least as fresh when a current answer is held, e.g. `!held || (a.panelSize >= cur.panelSize && a.agreed >= cur.agreed && a.expiresAt >= cur.expiresAt)`; alternatively treat the held answer's remaining validity as part of strength. The existing suite never delivers two answers for one questionHash with different expiries, so this edge is untested.","line":226,"path":"src/AskSwarmRegistry.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {AskSwarmRegistry} from \"src/AskSwarmRegistry.sol\";\nimport {OracleAttestation} from \"src/IIntake.sol\";\n\n/// Fails on the current AskSwarmRegistry: a verified, majority answer of equal-or-larger panel strength\n/// but a far shorter validity displaces a long-lived one from `latestFor`, and minutes later the question\n/// has no current answer although the long-lived one is still valid. Passes once displacement also\n/// requires the newcomer to be at least as fresh (a.expiresAt >= cur.expiresAt) or an equivalent rule.\ncontract MiniIntake {\n    uint256 public constant PRICE = 0.5 ether;\n    uint256 nonce;\n    struct Callback { address target; bytes4 selector; }\n    function priceOf(bytes32, address) external pure returns (uint256) { return PRICE; }\n    function requestWithFailure(bytes32, bytes calldata, Callback calldata, bytes4, address asset, uint256 amount)\n        external payable returns (bytes32 id)\n    {\n        require(amount == PRICE, \"PriceNotMet\");\n        (bool ok,) = asset.call(abi.encodeWithSelector(0x23b872dd, msg.sender, address(this), amount));\n        require(ok, \"pull\");\n        id = keccak256(abi.encode(block.chainid, address(this), ++nonce));\n    }\n}\n\ncontract MiniToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; }\n    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }\n    function transferFrom(address f, address t, uint256 a) external returns (bool) {\n        uint256 al = allowance[f][msg.sender];\n        if (al != type(uint256).max) allowance[f][msg.sender] = al - a;\n        balanceOf[f] -= a; balanceOf[t] += a; return true;\n    }\n}\n\ncontract IndexEvictionTest is Test {\n    uint256 constant PK = 0xA11CE;\n    address user = makeAddr(\"user\");\n    address griefer = makeAddr(\"griefer\");\n    MiniToken imd;\n    MiniIntake intake;\n    AskSwarmRegistry reg;\n\n    function setUp() public {\n        vm.chainId(1);\n        vm.roll(26_300_000);\n        vm.warp(1_792_300_000);\n        imd = new MiniToken();\n        intake = new MiniIntake();\n        reg = new AskSwarmRegistry(address(intake), address(imd), vm.addr(PK));\n        imd.mint(user, 10 ether);\n        imd.mint(griefer, 10 ether);\n    }\n\n    function _att(bytes32 id, uint16 panel, uint16 quorum, uint16 agreed, uint64 expiresAt)\n        internal view returns (OracleAttestation memory a)\n    {\n        a = OracleAttestation({\n            requestId: id, chainId: 1, questionHash: bytes32(uint256(0xeeee)), answerType: 2, answer: \"CHF\",\n            figure: 0, fromBlock: uint64(block.number - 10), toBlock: uint64(block.number),\n            blockHash: bytes32(uint256(1)), panelJobId: bytes32(uint256(7)), panelSize: panel, quorum: quorum,\n            agreed: agreed, issuedAt: uint64(block.timestamp), expiresAt: expiresAt\n        });\n    }\n\n    function _ask(address who) internal returns (bytes32 id) {\n        vm.startPrank(who);\n        imd.approve(address(reg), type(uint256).max);\n        id = reg.ask(\"{}\");\n        vm.stopPrank();\n    }\n\n    function _deliver(bytes32 id, OracleAttestation memory a) internal {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(PK, reg.digest(a));\n        vm.prank(address(intake));\n        reg.onOracleResult(id, a, abi.encodePacked(r, s, v));\n    }\n\n    function test_longLivedAnswerStaysCurrentAgainstShortLivedStrongerOne() public {\n        bytes32 qh = bytes32(uint256(0xeeee));\n        uint256 t0 = block.timestamp;\n\n        // honest asker: 15 seats, 11 agreed, valid 24 hours\n        bytes32 honest = _ask(user);\n        _deliver(honest, _att(honest, 15, 11, 11, uint64(t0 + 24 hours)));\n        assertEq(reg.latestFor(qh), honest);\n\n        // griefer, same question hash, flat price: 100 seats, 60 agreed, valid 10 minutes\n        bytes32 g = _ask(griefer);\n        _deliver(g, _att(g, 100, 51, 60, uint64(t0 + 10 minutes)));\n\n        vm.warp(t0 + 11 minutes);\n        // the honest answer is still valid for ~23h49m ...\n        (, AskSwarmRegistry.Answer memory h) = reg.get(honest);\n        assertTrue(h.verified && block.timestamp <= h.expiresAt, \"precondition: honest answer still valid\");\n        // ... so the question must still have a current answer and a 15/11 filtered read must find one.\n        assertTrue(reg.isCurrent(qh), \"question lost its current answer to a 10-minute attestation\");\n        (bytes32 rid,) = reg.answerOf(qh, 15, 11);\n        assertTrue(rid != bytes32(0), \"filtered read returns nothing although a valid 15/11 answer exists\");\n    }\n}","reproduction":"State: registry deployed; chain id 1; T0 = block.timestamp. 1) user approves and calls ask(body) -> id A. Intake delivers onOracleResult(A, a1, sig1) with a1 = {questionHash Q, panelSize 15, quorum 11, agreed 11, issuedAt T0, expiresAt T0+24h}, signed by SIGNER for this contract. Expected and actual: latestFor[Q] == A, isCurrent(Q) == true. 2) griefer calls ask(body') with the same question/definitions/window but panelSize 100, quorum 51, validForSeconds 600 -> id G; Intake delivers onOracleResult(G, a2, sig2) with a2 = {questionHash Q, panelSize 100, quorum 51, agreed 60, expiresAt T0+600}. Actual: latestFor[Q] == G (100 >= 15 and 60 >= 11). 3) warp to T0+660. Actual: isCurrent(Q) == false; answerOf(Q, 15, 11) returns (bytes32(0), empty); get(A) still shows verified == true and expiresAt == T0+24h. Expected: a question that still has a valid 11-of-15 verified answer keeps a current canonical answer; a 600-second attestation should not shorten the question's canonical validity from 24 h to 10 min. The proof test (test/scratch/IndexEviction.t.sol) fails on the current code with 'question lost its current answer to a 10-minute attestation'.","severity":"low","snippet":"            bool held = cur.verified && block.timestamp <= cur.expiresAt;\n            if (!held || (a.panelSize >= cur.panelSize && a.agreed >= cur.agreed)) {","title":"Index displacement ignores validity: a short-lived answer of equal or larger panel strength evicts a long-lived one, blanking answerOf/isCurrent for the question minutes later"},{"citation":"resolved","description":"ask reads price() = Intake.priceOf(ACTION, IMD) inside the transaction and pulls exactly that amount from msg.sender with transferFrom. The caller has no way to state the price they agreed to. Intake's price is owner-settable (verified Intake v2 source, setPrice onlyOwner, used from _request as `if (amount < price) revert PriceNotMet`). A wallet or the AskSwarm bot typically approves the registry for more than one ask (the project's own test helper approves type(uint256).max). If the price is raised between the user's approval/quote and the ask transaction, the user is charged the new price without consent; a pending ask transaction in the mempool is charged at whatever the price is when it is mined. The oracle-consumer reference's own example treats the price as a caller-side ceiling for exactly this reason ('the price is the owner's ceiling ... which is what makes it safe'). Impact is bounded by the caller's allowance and requires a privileged repricing by Intake's owner, so this is a trust/UX defect, not a theft path. Minimal fix: `ask(bytes calldata body, uint256 maxPrice)` with `if (p > maxPrice) revert PriceTooHigh(p)`, or an overload that keeps the current signature for callers that accept the live price. Untested in the suite: the mock Intake has a constant price, so no test exercises a price change between quote and ask.","line":162,"path":"src/AskSwarmRegistry.sol","reproduction":"State: registry pointed at an Intake whose priceOf returns 0.5e18 IMD. 1) user calls IMD.approve(registry, 5e18) after reading registry.price() == 0.5e18. 2) Intake owner calls setPrice(ACTION, IMD, 5e18) (or the user's ask tx is simply mined after a repricing). 3) user calls registry.ask(body). Actual: transferFrom pulls 5e18 IMD from the user and forwards it to Intake; user balance drops by 5 IMD for a 0.5 IMD quote; no revert. Expected: the ask reverts, or pulls at most the price the caller agreed to. Reproduced in test/scratch/IndexAndPrice.t.sol::test_askHasNoPriceCeiling with a settable-price mock (user balance 100e18 -> 95e18).","severity":"low","snippet":"        uint256 p = price();\n        if (p == 0) revert NotSold();\n        _call(IMD, abi.encodeWithSelector(0x23b872dd, msg.sender, address(this), p));","title":"ask() has no price ceiling: the caller pays whatever Intake quotes when the transaction lands, bounded only by their IMD allowance"},{"citation":"resolved","description":"MAX_ANSWER_BYTES exists so a long answer cannot run the callback out of Intake's stipend and leave the ask Pending. Measured on a mainnet fork through the real Intake.complete (writer impersonated): a 4096-byte address[] answer is delivered (status Answered) and costs 199,118 gas from the caller's frame (about 196k inside the callee) against the 200,000 stipend; 3,500 bytes costs 191,531; 352 bytes 149,879; a bytes32 answer 125,658. 4,097 bytes takes the early-return path (59,761 gas, Unverified). So the cap holds today but with under 1% headroom at the top of the allowed range: any gas repricing of LOG data, calldata copy or SSTORE, or a lower stipend, turns a legitimate 100+ address answer into delivered == false and a permanently Pending ask with the price spent. Separately, callbackGas in the verified Intake v2 is a uint64 the Intake owner sets with setCallbackGas; the registry hardcodes its budget assumptions and has no owner to adapt, so a stipend below ~125k would strand every answer. Not a defect in the code as written; recorded so the author can decide whether to lower the cap (e.g. 2048 bytes, ~172k) for margin and document the callbackGas dependency.","line":98,"path":"src/AskSwarmRegistry.sol","reproduction":"Fork of Ethereum mainnet (block ~26,213,288). Deploy AskSwarmRegistry(Intake v2, IMD, testKey); user asks; sign an attestation with answerType 4 and answer = new bytes(4096) under the registry's digest; vm.prank(Intake.writer()); Intake.complete(id, 0, hash, uri, abi.encode(id, a, sig)). Actual: Completed event delivered == true, get(id).status == Answered, direct onOracleResult measurement 199,118 gas. With answer = new bytes(4097): early return, 59,761 gas, status Unverified. See test/scratch/Fork4096.t.sol (fork-only) and test/scratch/Gas4096.t.sol (offline sweep: 4096 bytes 198,761 gas, delivered under a 200k call stipend).","severity":"info","snippet":"    uint256 public constant MAX_ANSWER_BYTES = 4096;","title":"The 4096-byte answer cap leaves about 1-4k gas of headroom under Intake's callbackGas, which Intake's owner can lower at any time"}],"hash":"53fb4c4b44529c203ff1e03cd676a7f5eed01f3695b7d57942274999255c6085","nodeId":"2da57429-09c3-4513-9a34-bc8573ee065b","outcome":"completed","summary":"The review is complete and `.imd-findings.json` holds the final result: three findings with validated file, line and snippet anchors, one embedded Foundry proof, and nine coverage rows answering all three entry points.\n\n**What I found**\n\n- **Low, index eviction by a short-lived answer.** `latestFor` moves to any verified majority answer whose panel and agreement are at least the held one's, with no regard to validity. Anyone can re-ask a question that has a 24-hour 11-of-15 answer with a 100-seat panel and a 10-minute validity at the same flat price. The new answer takes the index, and ten minutes later `isCurrent` is false and the filtered `answerOf` returns nothing, although the original answer is still valid in storage. The proof in `test/scratch/IndexEviction.t.sol` fails on the current code with \"question lost its current answer to a 10-minute attestation\".\n- **Low, no price ceiling in `ask`.** The caller pays whatever Intake quotes when the transaction lands, bounded only by their allowance. Intake's `setPrice` is owner-only in the verified source, so this needs a privileged repricing, but the project's own tests approve unlimited allowances. Reproduced with a settable-price mock: a 0.5 IMD quote charged 5 IMD.\n- **Info, thin stipend headroom.** On a mainnet fork through the real `Intake.complete`, a 4096-byte answer is delivered at about 199k gas against the 200k stipend. The cap holds today, but with under 1% headroom, and `callbackGas` is owner-settable on Intake while the registry has no owner to adapt.\n\n**What holds**\n\nEvery entry point was traced against the verified Intake v2 source and the real IMD token on a fork. Access checks, EIP-712 digest against both the protocol vector and the published plane signature, high-s and zero-recovery rejection, quorum and expiry gating, the three-slot store, the long and short answer paths, exact approval with no residual, and atomic revert when Intake rejects the body all behave as the brief states. Once-only delivery relies on Intake's `AlreadyCompleted` rather than a local Pending check, which the verified source enforces. IMD is a plain OpenZeppelin ERC-20 under a LayerZero OFT with no hooks, fees or pause. All five static-analysis leads were dismissed with a reason in the coverage record.\n\nNothing in `src/`, `test/` outside `test/scratch/`, or any configuration file was changed. The existing 23 tests pass both offline and on the fork.","treeHash":null,"usage":{"cachedInputTokens":1607334,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":43309,"runtime":"claude","turns":35,"wallClockMs":638495}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8a05ec1ea94411ea","findings":[{"citation":"resolved","description":"Merged from audit_economics 5f9c0b86, write_foundry_tests fa10e844 and audit_permissions ba54c7e9 (one root cause). The header invariant (line 21) says delivery happens 'only once per request (Intake enforces both)'. The verified Intake v2 source at 0xa43e6f75ee006411f79ac1c84120606c2330de82 (eth.blockscout.com, read during this review) enforces once-only on ITS request id and binds args[0:32] to that id only when status != 0; for status 0 it does `bytes.concat(selector, args)` and calls the target as given. The registry keys every write on the first word of those args and only checks `k.asker != 0`, never `k.status == Status.Pending` (line 184, and line 257 in onFailure). So a second registry ask's Intake completion can carry args naming a first, already settled registry id. Because `_answers[requestId]` is written (line 208) before `cur = _answers[latestFor[a.questionHash]]` is read (line 225), a re-delivery under the indexed id compares the newcomer against itself and always passes the displacement rule, so a 2-of-2 or even an unsigned attestation replaces a 100-of-100 canonical answer in place, or `latestFor` is left pointing at a record whose `verified` is now false, which answerOf(qh) and answerOf(qh,minPanel,minAgreed) (which never checks `verified`) then hand out as canonical while isCurrent says false. A Refused ask can likewise be turned into Answered (reproduced). The ask whose Intake id carried the stray args stays Pending forever. Trigger is Intake's writer (a trusted role, or a pairing bug on the plane's side), so the severity is low; but the registry's own stated guarantees (once per request, latestFor only indexes verified majorities, settled records are immutable) are not under its own control. Minimal fix that keeps the design: at the top of both hooks `if (k.status != Status.Pending) revert UnknownAsk(requestId);` (a revert inside Intake's try is recorded as delivered == false and changes nothing), and read `cur` before writing `_answers[requestId]`.","line":184,"path":"src/AskSwarmRegistry.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {AskSwarmRegistry} from \"src/AskSwarmRegistry.sol\";\nimport {OracleAttestation} from \"src/IIntake.sol\";\n\n/// Minimal ERC-20 for the IMD stand-in.\ncontract Token {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 a) external returns (bool) {\n        allowance[msg.sender][s] = a;\n        return true;\n    }\n\n    function transferFrom(address f, address t, uint256 a) external returns (bool) {\n        uint256 al = allowance[f][msg.sender];\n        if (al != type(uint256).max) allowance[f][msg.sender] = al - a;\n        balanceOf[f] -= a;\n        balanceOf[t] += a;\n        return true;\n    }\n}\n\n/// Intake v2 as deployed at 0xa43e...de82: `complete` runs once per Intake request id and, on status 0,\n/// forwards the writer's `args` to the callback without checking which request id they name.\ncontract Intake {\n    struct Callback {\n        address target;\n        bytes4 selector;\n    }\n\n    struct Req {\n        address payer;\n        address target;\n        bytes4 sel;\n        bool completed;\n    }\n\n    bytes32 constant ACTION = bytes32(\"oracle.request@oracle-1\");\n    address public immutable writer;\n    address public immutable imd;\n    mapping(bytes32 => Req) public reqs;\n    uint256 nonce;\n\n    constructor(address w, address i) {\n        writer = w;\n        imd = i;\n    }\n\n    function priceOf(bytes32 a, address asset) public view returns (uint256) {\n        return a == ACTION && asset == imd ? 0.5 ether : 0;\n    }\n\n    function requestWithFailure(bytes32 a, bytes calldata, Callback calldata cb, bytes4, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        require(amount >= priceOf(a, asset), \"price\");\n        require(Token(asset).transferFrom(msg.sender, address(this), amount), \"pull\");\n        id = keccak256(abi.encode(block.chainid, address(this), ++nonce));\n        reqs[id] = Req(msg.sender, cb.target, cb.selector, false);\n    }\n\n    function complete(bytes32 id, uint8 status, bytes calldata args) external {\n        require(msg.sender == writer, \"NotWriter\");\n        Req storage r = reqs[id];\n        require(r.payer != address(0) && !r.completed, \"bad\");\n        r.completed = true;\n        if (status == 0) {\n            bytes memory data = bytes.concat(r.sel, args);\n            address t = r.target;\n            assembly {\n                pop(call(200000, t, 0, add(data, 0x20), mload(data), 0, 0))\n            }\n        }\n    }\n}\n\ncontract OnceOnlyProof is Test {\n    uint256 constant PK = 0xA11CE;\n    address user = makeAddr(\"user\");\n    address writer = makeAddr(\"writer\");\n    Token imd;\n    Intake intake;\n    AskSwarmRegistry reg;\n\n    function setUp() public {\n        vm.chainId(1);\n        vm.roll(26_300_000);\n        vm.warp(1_792_300_000);\n        imd = new Token();\n        intake = new Intake(writer, address(imd));\n        reg = new AskSwarmRegistry(address(intake), address(imd), vm.addr(PK));\n        imd.mint(user, 10 ether);\n    }\n\n    function _ask() internal returns (bytes32 id) {\n        vm.startPrank(user);\n        imd.approve(address(reg), type(uint256).max);\n        id = reg.ask(\"{}\");\n        vm.stopPrank();\n    }\n\n    function _att(bytes32 id, bytes memory ans, uint16 panel, uint16 quorum, uint16 agreed)\n        internal\n        view\n        returns (OracleAttestation memory a)\n    {\n        a = OracleAttestation({\n            requestId: id,\n            chainId: 1,\n            questionHash: bytes32(uint256(0xabcd)),\n            answerType: 0,\n            answer: ans,\n            figure: 0,\n            fromBlock: uint64(block.number - 10),\n            toBlock: uint64(block.number),\n            blockHash: blockhash(block.number - 1),\n            panelJobId: bytes32(uint256(7)),\n            panelSize: panel,\n            quorum: quorum,\n            agreed: agreed,\n            issuedAt: uint64(block.timestamp),\n            expiresAt: uint64(block.timestamp + 1 days)\n        });\n    }\n\n    function _sign(OracleAttestation memory a) internal view returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(PK, reg.digest(a));\n        return abi.encodePacked(r, s, v);\n    }\n\n    /// Two registry asks (two Intake request ids). The first is answered and indexed. The second Intake\n    /// request is completed with args that name the first registry id again. Expected: the settled id1\n    /// record is untouched (a settled ask takes no second delivery). Actual: id1's stored answer is\n    /// overwritten by an unsigned 1-of-2 attestation, status flips to Unverified, and latestFor still\n    /// points at id1, so answerOf hands out an unverified record as the canonical answer.\n    function test_settledAskTakesNoSecondDelivery() public {\n        bytes32 id1 = _ask();\n        bytes32 id2 = _ask();\n        OracleAttestation memory good = _att(id1, hex\"01\", 15, 11, 15);\n        bytes memory sigGood = _sign(good);\n        vm.prank(writer);\n        intake.complete(id1, 0, abi.encode(id1, good, sigGood));\n        assertEq(reg.latestFor(bytes32(uint256(0xabcd))), id1, \"indexed\");\n\n        OracleAttestation memory bad = _att(id1, hex\"00\", 2, 1, 1);\n        vm.prank(writer);\n        intake.complete(id2, 0, abi.encode(id1, bad, \"\"));\n\n        (AskSwarmRegistry.Ask memory k1, AskSwarmRegistry.Answer memory after1) = reg.get(id1);\n        assertEq(uint8(k1.status), uint8(AskSwarmRegistry.Status.Answered), \"settled status must not change\");\n        assertTrue(after1.verified, \"settled answer must not be overwritten\");\n        assertEq(after1.agreed, 15);\n        assertEq(reg.latestFor(bytes32(uint256(0xabcd))), id1);\n        assertTrue(reg.isCurrent(bytes32(uint256(0xabcd))), \"canonical answer must stay verified\");\n    }\n}","reproduction":"Offline mock mirroring the verified Intake v2 `complete` (once per Intake id, status-0 args forwarded unchecked). State: registry with test signer, chain 1. 1) user calls ask() twice -> id1, id2, both Pending. 2) writer: Intake.complete(id1, 0, 0, '', abi.encode(id1, A, sigA)) with A = {questionHash Q, panelSize 15, quorum 11, agreed 15, expiresAt now+1d} signed by SIGNER -> asks[id1].status == Answered, latestFor[Q] == id1, isCurrent(Q) == true. 3) writer: Intake.complete(id2, 0, 0, '', abi.encode(id1, B, '')) with B = {Q, panelSize 2, quorum 1, agreed 1} and an empty signature. Expected: id1 untouched (Answered, verified, agreed 15, latestFor[Q] == id1 current). Actual: asks[id1].status == Unverified (5), _answers[id1].verified == false, agreed == 1, latestFor[Q] still id1, isCurrent(Q) == false, answerOf(Q).verified == false; asks[id2] stays Pending although Intake marked id2 completed. `forge test --match-path test/scratch/Proof_5f9c0b867dbd.t.sol` fails on this code with 'settled status must not change: 5 != 2'. Variant reproduced in test/scratch/JudgeChecks.t.sol::test_refusedAskCanBeTurnedAnsweredByAnotherCompletion: id1 refused (status 1 via onFailure, Refused == 3), then complete(id2, 0, abi.encode(id1, signed 11/15 attestation)) -> asks[id1].status == Answered (2).","severity":"low","snippet":"        if (k.asker == address(0)) revert UnknownAsk(requestId);","title":"onOracleResult and onFailure never require Status.Pending: a settled ask is rewritten by a later status-0 completion of another ask, because Intake v2 forwards status-0 args without binding their firs"},{"citation":"resolved","description":"Merged from audit_flow 395b0157 and audit_math d09f929d: both are the same seam, expiry is not part of the index decision and nothing re-evaluates the index later. (a) Eviction: the asker chooses panelSize, quorum and validForSeconds (60 s to 30 days) at the flat 0.5 IMD price, and the same question, definitions and absolute window give the same questionHash. Anyone can re-ask a question that holds a 24-hour 11-of-15 answer with panelSize 100, quorum 51 and validForSeconds just above delivery latency; any majority answer then takes latestFor (100 >= 15, 60 >= 11) and minutes later isCurrent(Q) is false and answerOf(Q, 15, 11) returns a zero id, although the original answer is verified and valid for another ~23 h and still in _answers. The bot's free tier reads answerOf before charging, so the question is paid for again. (b) Shadowing: when a stronger answer H holds the index and a weaker-but-majority answer N (longer validity) arrives, N is never indexed; after H expires isCurrent is false and answerOf(Q,0,0) returns nothing although N is a current verified majority answer. No funds leave the contract; the index's promise ('latest verified answer') is what breaks. Minimal fix that keeps the design: when a current answer is held, also require the newcomer to be at least as fresh (`a.expiresAt >= cur.expiresAt`), and/or let a verified majority answer take the index whenever the held one is weaker-or-expired at read time (or document that answerOf/isCurrent may be false while get(requestId) holds a current answer). The suite never delivers two answers for one questionHash with different expiries.","line":227,"path":"src/AskSwarmRegistry.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {AskSwarmRegistry} from \"src/AskSwarmRegistry.sol\";\nimport {OracleAttestation} from \"src/IIntake.sol\";\n\n/// Fails on the current AskSwarmRegistry: a verified, majority answer of equal-or-larger panel strength\n/// but a far shorter validity displaces a long-lived one from `latestFor`, and minutes later the question\n/// has no current answer although the long-lived one is still valid. Passes once displacement also\n/// requires the newcomer to be at least as fresh (a.expiresAt >= cur.expiresAt) or an equivalent rule.\ncontract MiniIntake {\n    uint256 public constant PRICE = 0.5 ether;\n    uint256 nonce;\n    struct Callback { address target; bytes4 selector; }\n    function priceOf(bytes32, address) external pure returns (uint256) { return PRICE; }\n    function requestWithFailure(bytes32, bytes calldata, Callback calldata, bytes4, address asset, uint256 amount)\n        external payable returns (bytes32 id)\n    {\n        require(amount == PRICE, \"PriceNotMet\");\n        (bool ok,) = asset.call(abi.encodeWithSelector(0x23b872dd, msg.sender, address(this), amount));\n        require(ok, \"pull\");\n        id = keccak256(abi.encode(block.chainid, address(this), ++nonce));\n    }\n}\n\ncontract MiniToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; }\n    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }\n    function transferFrom(address f, address t, uint256 a) external returns (bool) {\n        uint256 al = allowance[f][msg.sender];\n        if (al != type(uint256).max) allowance[f][msg.sender] = al - a;\n        balanceOf[f] -= a; balanceOf[t] += a; return true;\n    }\n}\n\ncontract IndexEvictionTest is Test {\n    uint256 constant PK = 0xA11CE;\n    address user = makeAddr(\"user\");\n    address griefer = makeAddr(\"griefer\");\n    MiniToken imd;\n    MiniIntake intake;\n    AskSwarmRegistry reg;\n\n    function setUp() public {\n        vm.chainId(1);\n        vm.roll(26_300_000);\n        vm.warp(1_792_300_000);\n        imd = new MiniToken();\n        intake = new MiniIntake();\n        reg = new AskSwarmRegistry(address(intake), address(imd), vm.addr(PK));\n        imd.mint(user, 10 ether);\n        imd.mint(griefer, 10 ether);\n    }\n\n    function _att(bytes32 id, uint16 panel, uint16 quorum, uint16 agreed, uint64 expiresAt)\n        internal view returns (OracleAttestation memory a)\n    {\n        a = OracleAttestation({\n            requestId: id, chainId: 1, questionHash: bytes32(uint256(0xeeee)), answerType: 2, answer: \"CHF\",\n            figure: 0, fromBlock: uint64(block.number - 10), toBlock: uint64(block.number),\n            blockHash: bytes32(uint256(1)), panelJobId: bytes32(uint256(7)), panelSize: panel, quorum: quorum,\n            agreed: agreed, issuedAt: uint64(block.timestamp), expiresAt: expiresAt\n        });\n    }\n\n    function _ask(address who) internal returns (bytes32 id) {\n        vm.startPrank(who);\n        imd.approve(address(reg), type(uint256).max);\n        id = reg.ask(\"{}\");\n        vm.stopPrank();\n    }\n\n    function _deliver(bytes32 id, OracleAttestation memory a) internal {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(PK, reg.digest(a));\n        vm.prank(address(intake));\n        reg.onOracleResult(id, a, abi.encodePacked(r, s, v));\n    }\n\n    function test_longLivedAnswerStaysCurrentAgainstShortLivedStrongerOne() public {\n        bytes32 qh = bytes32(uint256(0xeeee));\n        uint256 t0 = block.timestamp;\n\n        // honest asker: 15 seats, 11 agreed, valid 24 hours\n        bytes32 honest = _ask(user);\n        _deliver(honest, _att(honest, 15, 11, 11, uint64(t0 + 24 hours)));\n        assertEq(reg.latestFor(qh), honest);\n\n        // griefer, same question hash, flat price: 100 seats, 60 agreed, valid 10 minutes\n        bytes32 g = _ask(griefer);\n        _deliver(g, _att(g, 100, 51, 60, uint64(t0 + 10 minutes)));\n\n        vm.warp(t0 + 11 minutes);\n        // the honest answer is still valid for ~23h49m ...\n        (, AskSwarmRegistry.Answer memory h) = reg.get(honest);\n        assertTrue(h.verified && block.timestamp <= h.expiresAt, \"precondition: honest answer still valid\");\n        // ... so the question must still have a current answer and a 15/11 filtered read must find one.\n        assertTrue(reg.isCurrent(qh), \"question lost its current answer to a 10-minute attestation\");\n        (bytes32 rid,) = reg.answerOf(qh, 15, 11);\n        assertTrue(rid != bytes32(0), \"filtered read returns nothing although a valid 15/11 answer exists\");\n    }\n}","reproduction":"State: registry deployed, chain 1, T0 = block.timestamp, test signer. (a) user asks -> A; deliver onOracleResult(A, a1, sig1) with a1 = {questionHash Q, panelSize 15, quorum 11, agreed 11, expiresAt T0+24h}: latestFor[Q] == A, isCurrent(Q) == true. griefer asks -> G; deliver a2 = {Q, panelSize 100, quorum 51, agreed 60, expiresAt T0+600}: latestFor[Q] == G. warp to T0+660. Expected: isCurrent(Q) true and answerOf(Q,15,11) returns A (still valid 23 h). Actual: isCurrent(Q) == false, answerOf(Q,15,11) returns bytes32(0); get(A).verified == true with expiresAt T0+24h. `forge test --match-path test/scratch/Proof_395b01575141.t.sol` fails on this code with 'question lost its current answer to a 10-minute attestation'. (b) deliver H = {Q, 15, 11, agreed 15, expiresAt T+3600} -> latestFor[Q] == h; deliver N = {Q, 15, 11, agreed 11, expiresAt T+30d} -> verified, majority (22 > 15), but 11 >= 15 fails so latestFor stays h; warp T+3601: isCurrent(Q) == false, answerOf(Q,0,0) returns 0, latestFor never moves to n.","severity":"low","snippet":"            if (!held || (a.panelSize >= cur.panelSize && a.agreed >= cur.agreed)) {","title":"The latestFor index decides only at delivery time and only on (panelSize, agreed), never on expiry: a 10-minute attestation evicts a 24-hour one and the question goes dark minutes later, and an expire"},{"citation":"resolved","description":"Merged from audit_flow 20e88901, audit_economics 17049f6f and audit_permissions 9446828f. ask reads INTAKE.priceOf(ACTION, IMD) inside the transaction and pulls exactly that from msg.sender with transferFrom; the caller has no way to state the price they agreed to. The verified Intake v2 has `setPrice(action, asset, amount)` onlyOwner and `_request` reverts only when `amount < price`. The intended callers (the Telegram bot wallet and the Pay-with-IMD page) approve once for many asks; the project's own helpers approve type(uint256).max. An ask in the mempool, or any ask after a repricing, silently pays the new price: no event carries the price and nothing reverts. The oracle-consumer reference's own example treats the price as a ceiling held by the contract for exactly this reason. Impact is bounded by the caller's allowance and needs Intake's owner (trusted) to reprice, so this is a trust/UX defect, not theft. The brief says 'pulls exactly Intake's price', which the fix keeps: add `ask(bytes body, uint256 maxPrice)` reverting when `p > maxPrice` (keeping ask(bytes) for callers that accept the live price, or routing it through maxPrice = type(uint256).max), and emit `p` in Asked so the asker's record shows what was paid.","line":162,"path":"src/AskSwarmRegistry.sol","reproduction":"Mock Intake with a settable price (as the deployed one has). user: IMD.approve(registry, type(uint256).max); registry.price() == 0.5e18; user balance 100e18. Intake owner: setPrice(ACTION, IMD, 50e18). user: registry.ask(body). Expected: revert, or at most the 0.5 IMD the user saw leaves the wallet. Actual: the ask succeeds and 50 IMD leave: balance 100e18 -> 50e18. test/scratch/PriceCeiling.t.sol::test_askHasNoPriceCeiling asserts the 50e18 balance and passes, i.e. the behaviour is as described; the suite's own test_priceChangesAreReadOnEveryAsk and test_priceIncreaseBeyondApprovalCannotChargeOldQuote show the author relies on the allowance alone as the bound.","severity":"low","snippet":"        uint256 p = price();","title":"ask(bytes) has no caller-side price bound: a wallet with a standing allowance is charged whatever Intake's owner has set when the transaction lands"},{"citation":"resolved","description":"From audit_math cb372485, reproduced. The early return skips signature verification and storage entirely, so a paid, quorum-reaching, correctly signed answer is recorded as Unverified; get(requestId) returns a zero record (questionHash 0, answerHash keccak256(''), verified false), the Answered event has answer '' and verified false, and the question is never indexed. abi.encode(address[] n) is 64 + 32n bytes, so 126 entries (4096 bytes) are kept and 127 entries (4128 bytes) are dropped; the same holds for bytes32[]. The asker paid 0.5 IMD for an answer the registry cannot return and cannot tell it from a forged delivery. The cap is justified as stipend protection, but the measured cold minimum gas for the callback is ~150k at 4096 bytes (binary search in test/scratch/JudgeChecks.t.sol::test_minGasAtCap: 150,048), dominated by emitting the raw bytes at 8 gas/byte; a calldata keccak over 16 KiB costs well under 10k. Minimal fix keeping the three-slot layout: above the cap still compute keccak256(a.answer), verify the signature and store the hash with long = true, emitting the hash instead of the raw bytes; or raise MAX_ANSWER_BYTES toward the size the raw-emit path still fits with margin.","line":185,"path":"src/AskSwarmRegistry.sol","reproduction":"Mock Intake as in the suite, chain 1, test signer. ask() -> id. list = address[127] (distinct addresses), answer = abi.encode(list) (4128 bytes), attestation {questionHash Q, answerType 4, panelSize 15, quorum 11, agreed 11, expiresAt T+1 day}, signed by SIGNER over digest(a). writer: Intake.complete(id, 0, 0, '', abi.encode(id, a, sig)); delivered == true. Expected: verified == true, answerHash == keccak256(answer), latestFor[Q] == id. Actual: asks[id].status == Unverified (5), get(id).verified == false, get(id).answerHash == keccak256('') == 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470, get(id).questionHash == 0, latestFor[Q] == 0. With 126 entries (4096 bytes) the same delivery is stored and indexed. Reproduced in test/scratch/JudgeChecks.t.sol::test_listLengthAtCapAndGas (passes, asserting exactly this).","severity":"low","snippet":"        if (a.answer.length > MAX_ANSWER_BYTES) {","title":"A validly signed answer longer than 4096 bytes (an address[] of 127 or more entries) is discarded as Unverified with nothing stored, not even its hash, and the Answered event carries an empty answer"},{"citation":"resolved","description":"From write_foundry_tests 9334c5b2, reproduced. The canonical OracleAttestationConsumer (oracle-consumer REFERENCE.md, `_verifyAttestation`) rejects `a.issuedAt > block.timestamp + 5 minutes` as AttestationNotYetValid. The registry checks only expiresAt, so a correctly signed attestation with a future issuedAt (a signer clock error, or a pre-signed answer) is recorded Answered, verified, indexed by latestFor and reported current, and its stored issuedAt misleads any reader that uses it for freshness. Requires the signer's key, so an ordinary user cannot cause it; low. Fix: add `a.issuedAt <= block.timestamp + 5 minutes` to `ok` (record as Unverified rather than revert, to stay inside the stipend-and-no-retry model).","line":205,"path":"src/AskSwarmRegistry.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\nimport {Test} from \"forge-std/Test.sol\";\nimport {AskSwarmRegistry} from \"src/AskSwarmRegistry.sol\";\nimport {IIntake, OracleAttestation} from \"src/IIntake.sol\";\n\ncontract ProofToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 amount) external { balanceOf[to] += amount; }\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount; return true;\n    }\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n// The relevant Intake v2 transport behavior: complete is writer-only, marks each OUTER\n// id completed once, and forwards opaque success args without validating their first word.\ncontract ProofIntake {\n    struct Request { address target; bytes4 selector; bool completed; }\n    ProofToken public immutable token;\n    address public immutable writer;\n    uint256 private nonce;\n    mapping(bytes32 => Request) public requests;\n    constructor(ProofToken t, address w) { token = t; writer = w; }\n    function priceOf(bytes32, address) external pure returns (uint256) { return 0.5 ether; }\n    function requestWithFailure(bytes32, bytes calldata, IIntake.Callback calldata cb,\n        bytes4, address, uint256 amount) external returns (bytes32 id) {\n        require(token.transferFrom(msg.sender, writer, amount));\n        id = keccak256(abi.encode(block.chainid, address(this), ++nonce));\n        requests[id] = Request(cb.target, cb.selector, false);\n    }\n    function complete(bytes32 outerId, bytes calldata args) external returns (bool called) {\n        require(msg.sender == writer, \"only writer\");\n        Request storage r = requests[outerId];\n        require(r.target != address(0) && !r.completed, \"not pending\");\n        r.completed = true;\n        (called,) = r.target.call{gas: 200_000}(abi.encodePacked(r.selector, args));\n    }\n}\n\ncontract FutureIssuedProof is Test {\n    uint256 constant KEY = 0xA11CE;\n    AskSwarmRegistry registry;\n    ProofToken token;\n    ProofIntake intake;\n    function setUp() public {\n        vm.chainId(1);\n        vm.warp(1_800_000_000);\n        token = new ProofToken();\n        intake = new ProofIntake(token, address(this));\n        registry = new AskSwarmRegistry(address(intake), address(token), vm.addr(KEY));\n        token.mint(address(this), 10 ether);\n        token.approve(address(registry), 10 ether);\n    }\n    function attestation(bytes32 id) internal view returns (OracleAttestation memory a) {\n        a = OracleAttestation({requestId: keccak256(abi.encode(\"oracle UUID\", id)), chainId: 1,\n            questionHash: keccak256(\"question\"), answerType: 0, answer: abi.encode(true), figure: 1,\n            fromBlock: 100, toBlock: 200, blockHash: keccak256(\"block\"), panelJobId: keccak256(\"job\"),\n            panelSize: 15, quorum: 11, agreed: 11, issuedAt: uint64(block.timestamp),\n            expiresAt: uint64(block.timestamp + 1 days)});\n    }\n    function sign(OracleAttestation memory a) internal view returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, registry.digest(a));\n        return abi.encodePacked(r, s, v);\n    }\n    function test_futureIssuedAttestationIsNotCurrent() public {\n        bytes32 id = registry.ask(\"{}\");\n        OracleAttestation memory a = attestation(id);\n        a.issuedAt = uint64(block.timestamp + 301); // beyond canonical 5-minute tolerance\n        bytes memory signature = sign(a);\n        intake.complete(id, abi.encode(id, a, signature));\n        (, AskSwarmRegistry.Answer memory result) = registry.get(id);\n        assertFalse(result.verified, \"attestation issued beyond clock tolerance must not verify\");\n        assertFalse(registry.isCurrent(a.questionHash));\n    }\n}","reproduction":"Chain 1, warp to 1800000000, registry with test signer. ask() -> id. Attestation {questionHash Q, panelSize 15, quorum 11, agreed 11, issuedAt 1800000301, expiresAt 1800086400}, signed by SIGNER over digest(a). Deliver through the Intake fixture under 200,000 gas. Expected: verified == false and isCurrent(Q) == false (issuedAt is 301 s ahead, past the 300 s tolerance). Actual: verified == true, status Answered, latestFor[Q] == id, isCurrent(Q) == true. `forge test --match-path test/scratch/Proof_9334c5b20df3.t.sol` fails on this code at 'attestation issued beyond clock tolerance must not verify'.","severity":"low","snippet":"        bool ok = rec != address(0) && rec == SIGNER && a.agreed >= a.quorum && block.timestamp <= a.expiresAt;","title":"issuedAt is never checked: an attestation dated beyond the protocol's 5-minute clock tolerance is stored verified, indexed and current"},{"citation":"resolved","description":"From write_foundry_tests 6e31209c, reproduced. The domain is computed once with block.chainid in the constructor and reused by _digest without comparing the current chain id. If the deployed state survives a fork that changes the chain id, every attestation signed for the chain-1 registry remains valid on the fork copy, contrary to the EIP-712 intent that the domain names the current chain (OpenZeppelin's EIP712 recomputes the separator when block.chainid differs from the cached one for this reason). ADAPTATION.md records the opposite half only ('answers there would then fail to verify'): new answers signed for the fork's chain id fail, but old chain-1 signatures are accepted. Conditional on such a fork and on an authorized delivery there, so low. Fix: cache the deployment chain id alongside the separator and recompute `_domain(address(this))` when `block.chainid` differs.","line":146,"path":"src/AskSwarmRegistry.sol","reproduction":"vm.chainId(1); deploy registry (test signer); ask() -> id; sign attestation {Q, 15, 11, 11, expiresAt now+1d} under registry.digest(a) (domain chainId 1). vm.chainId(8453) keeping the deployed state. Deliver onOracleResult(id, a, sig) from the Intake fixture. Expected: verified == false (signature was for chain 1). Actual: verified == true and latestFor[Q] == id. test/scratch/JudgeChecks.t.sol::test_cachedDomainAcceptsOldChainSignatureAfterFork fails on this code with 'chain-1 signature accepted on chain 8453'. For contrast, the suite's test_wrongDomainChainNameAndVersionAreRejected signs under chain 8453 for a chain-1 registry and is rightly rejected; the difference here is that the registry's cached domain, not the signature, is the stale one.","severity":"low","snippet":"        DOMAIN_SEPARATOR = _domain(address(this));","title":"DOMAIN_SEPARATOR is cached at deployment, so after a chain-id-changing fork the copied registry accepts signatures made for the original chain"},{"citation":"resolved","description":"From audit_math e639754c, reproduced. Strength is (panelSize >=, agreed >=) in absolute terms plus the strict-majority gate, so any at-least-as-large panel whose majority reaches the held absolute agreed count wins even with a lower agreement ratio. The asker chooses panelSize and quorum at a flat price, so for a contested question repeated asks with panelSize 20, quorum 2 flip an 11-of-15 answer the first time 11 of 20 back another answer. ADAPTATION.md shows the author weighed a pure-ratio rule and rejected it (a 2-of-2 would lock a question); a combined rule was not considered. Recorded as informational because the code does exactly what README step 4 and the invariants header define, and the choice is a documented design decision. If the author wants the ratio protected too, add the cross-multiplication `uint256(a.agreed) * cur.panelSize >= uint256(cur.agreed) * a.panelSize` to the displacement test (no division, fits uint256).","line":227,"path":"src/AskSwarmRegistry.sol","reproduction":"Mock Intake as in the suite. ask() -> h; deliver H {questionHash Q, answer 'CHF', panelSize 15, quorum 11, agreed 11, expiresAt T+1d}: latestFor[Q] == h. ask() -> n; deliver N {Q, 'EUR', panelSize 20, quorum 2, agreed 11, expiresAt T+1d}: majority 22 > 20, held, 20 >= 15 and 11 >= 11. Expected under the invariant text: h keeps the index. Actual: latestFor[Q] == n, answerOf(Q).answer == 'EUR'. test/scratch/JudgeChecks.t.sol::test_lowerRatioLargerPanelDisplaces fails on this code with '55% displaced 73%'.","severity":"info","snippet":"            if (!held || (a.panelSize >= cur.panelSize && a.agreed >= cur.agreed)) {","title":"Displacement compares absolute counts across panels of different sizes, so an 11-of-20 (55%) majority replaces an 11-of-15 (73%) answer; this follows the documented rule but is weaker than 'a weak pan"},{"citation":"resolved","description":"From audit_economics 5e51a4fc, reproduced. The majority gate and the displacement rule only protect an answer already held; for an empty questionHash the smallest body Intake accepts (panelSize 2, quorum 2, agreed 2) is indexed with expiresAt up to 30 days out, and the unfiltered answerOf(bytes32), latestFor and isCurrent all present it as the verified current answer. The oracle-consumer reference says a small panel is cheap to fill with seats one holder controls and tells consumers to require panel minimums. The README documents that readers should check panelSize and agreed, and answerOf(qh, minPanel, minAgreed) exists for that, so this is a documented trust assumption rather than a defect; recorded so the bot's free tier (which reads prior answers before charging) uses the filtered getter or a documented floor. A constant floor on indexing (no owner needed) would close it if the project wants the unfiltered getters to mean something.","line":224,"path":"src/AskSwarmRegistry.sol","reproduction":"ask(body with panelSize 2, quorum 2, validForSeconds 2592000); writer completes with a signed attestation {Q, panelSize 2, quorum 2, agreed 2, expiresAt now+30d}. Then latestFor(Q) == id, answerOf(Q).verified == true with panelSize 2, isCurrent(Q) == true. test/scratch/JudgeChecks.t.sol::test_twoOfTwoIsCurrent passes asserting exactly this.","severity":"info","snippet":"        if (ok && uint256(a.agreed) * 2 > a.panelSize) {","title":"No floor for the first indexed answer: a 2-of-2 panel bought for 0.5 IMD is the verified, current, canonical answer of a question for up to 30 days until anyone pays for a panel at least as strong"},{"citation":"resolved","description":"From audit_math bda659f4, reproduced. When latestFor[questionHash] is zero or the request has no Stored record (never answered, failed, or dropped for length), _read builds an empty bytes and hashes it, so Answer.answerHash is 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470 rather than 0. The three-argument answerOf returns a zeroed struct on its filtered path. A reader testing answerHash != 0 for 'has an answer' is misled. Fix: return bytes32(0) when `!s.long && s.answerLen == 0 && !s.verified`, or document that callers must test requestId != 0 / verified.","line":305,"path":"src/AskSwarmRegistry.sol","reproduction":"Fresh deployment. answerOf(bytes32(1)) returns (0, a) with a.answerHash == keccak256('') and a.verified == false; get(anyUnansweredId) likewise; answerOf(bytes32(1), 0, 0) returns a.answerHash == 0. test/scratch/JudgeChecks.t.sol::test_emptyReadSentinels passes asserting this.","severity":"info","snippet":"            h = keccak256(ans);","title":"Reads of an unindexed question or an unstored request return keccak256('') as answerHash from answerOf(bytes32)/get, but bytes32(0) from the filtered answerOf, so the two read forms disagree on the 'n"},{"citation":"resolved","description":"From audit_math 7a15c260, reproduced. The verified Intake v2 routes any non-zero status to the failure selector and only requires args[32:64] == status. Today it emits 1 (refused) and 2 (no result); a future status 3+ would be recorded as NoResult ('panel split') rather than as an unknown outcome. The raw status is in the Failed event, so the record is recoverable off chain and nothing moves value. Fix: keep NoResult only for status == 2 and add an Other status, or document the mapping.","line":258,"path":"src/AskSwarmRegistry.sol","reproduction":"Mock Intake mirroring the deployed failure-args binding: writer calls complete(id, 3, 0, '', abi.encode(id, uint8(3), bytes32('x'), 0, 0, '')). Expected: a status that says the outcome is not one the registry understands. Actual: asks[id].status == NoResult (4). test/scratch/JudgeChecks.t.sol::test_unknownFailureStatusIsNoResult passes asserting this.","severity":"info","snippet":"        k.status = status == 1 ? Status.Refused : Status.NoResult;","title":"onFailure maps every status other than 1 to NoResult, including statuses Intake may define later"},{"citation":"resolved","description":"Merged from audit_economics 0335d444, audit_permissions 84e2d7e0, audit_flow 41e8db0c and audit_economics 9b2cf894; none is a permission bypass or a defect in the agreed no-owner design, recorded so the judge and the author can weigh them. (a) Intake.callbackGas is owner-settable (setCallbackGas in the verified source, 200,000 today). Measured here with an exact-gas caller: a cold 4096-byte delivery needs about 150,048 gas (test/scratch/JudgeChecks.t.sol::test_minGasAtCap), so the margin under the stipend is ~50k, not the 1-4k audit_flow reported (its 199k figure is the test frame's own memory-expansion cost; the suite's 200k-stipend delivery at the cap passes cold). Below ~150k long legitimate answers, and below ~115k every answer, would be recorded not delivered and never retried: the ask stays Pending with the price spent and the registry has no clear path. (b) Intake.setWriter changes who may call complete; the registry trusts whatever the writer pairs (finding 1). (c) SIGNER is immutable; after an attester rotation ask keeps charging 0.5 IMD and every delivery lands Unverified (documented lifecycle; Intake.signer() on chain is the quote signer, not the attester, so nothing on chain exposes the attester to read). (d) questionHash covers the resolved block window, so with the README's example body (window {hours:24}) two asks of the same question an hour apart get different hashes; the strength ordering and the free-tier lookup only engage when an absolute window is reused. (e) Tokens sent straight to the registry are stuck by design. The bot should check Intake.callbackGas() and the attester before charging, and prefer absolute windows.","line":182,"path":"src/AskSwarmRegistry.sol","reproduction":"Not a failing input on this code. (a) Etch an Intake mock with callbackGas = 110_000 and complete any registry ask with a valid 32-byte attestation: the callback call returns false, asks[id].status stays Pending, no Answered event, price already forwarded. (c) Deploy with signer S1, deliver an attestation signed by S2: status Unverified, latestFor unchanged (the suite's test_badSignatureStoredUnverifiedAndNotIndexed). (d) The published a9a6d603 attestation carries the resolved window 26205587..26212764 inside its questionHash input.","severity":"info","snippet":"        if (msg.sender != address(INTAKE)) revert NotIntake();","title":"Trust assumptions outside the registry that decide whether paid asks ever resolve: Intake's owner (callbackGas, price, writer), the immutable attester key, the writer's pairing, and relative-window qu"}],"hash":"5481ecac09bfc9b1749108514c6169d036207f3dd7b4c3f5a01cf47c3d9be795","nodeId":"9150b9d6-ece7-40ce-ab7c-d462db731308","outcome":"completed","summary":"The review is complete and the findings file is written at the repository root. No tracked file was changed; only the ignored `.imd-findings.json` and `test/scratch/` were created.\n\n**Outcome:** the contract is sound in its core paths and the manifest is correct, but six low-severity defects reproduce and should go back to the author. Nothing rises to medium or above, and nothing blocks deployment by the factory. The full suite passes offline: 64 tests plus the invariant campaign.\n\n**Findings kept, after merging 19 specialist reports into 11:**\n\n- **No Pending guard in either hook** (low, proof attached). Merged from three specialists. I read the verified Intake v2 source from Blockscout: `complete` binds `args[0:32]` to its request id only for failures. For status 0 the writer's args go through unchecked, so a second ask's completion can name a settled id and overwrite it. Since `_answers[id]` is written before `cur` is read, the overwrite always passes the displacement rule. A Refused ask can even be turned into Answered. Trigger is the trusted writer, so low.\n- **Index is blind to expiry** (low, proof attached). Merged two specialists with the same root cause. A 10-minute 60-of-100 attestation evicts a 24-hour 11-of-15 one, and an expired held answer keeps the index while a fresh majority stays unindexed.\n- **No caller-side price bound in ask** (low). Merged from three specialists. A standing allowance pays whatever Intake's owner sets at inclusion time.\n- **Answers over 4096 bytes are dropped entirely**, not even their hash (low). 127 addresses lose a paid answer.\n- **issuedAt never checked** (low, proof attached). The canonical consumer rejects attestations dated past 5 minutes ahead.\n- **Cached domain separator** (low). I wrote my own test: a chain-1 signature verifies after the chain id changes to 8453.\n\nFive informational notes cover the absolute-count displacement rule, the missing first-answer floor, the empty-read sentinel, the failure-status mapping, and merged trust assumptions on Intake's owner and the attester key.\n\n**Dropped or corrected:** the claim of only 1 to 4k gas headroom at the 4096-byte cap did not hold. My binary search shows the cold callback needs about 150k, so the margin is about 50k. The inflated figure came from the measuring test frame's own memory expansion. The relative-window questionHash observation was folded into the trust note since it is not contract misbehaviour. Slither's reentrancy lead in ask is benign: Intake is trusted and the request id must come from the call.\n\n**Coverage:** all three entry points are answered as `finding`, plus four invariant and manifest rows marked `holds`. The manifest has the three constructor addresses in the right order, no extra keys, and the constructor makes no external calls.","treeHash":null,"usage":{"cachedInputTokens":2497173,"inputTokens":546,"model":"claude-fable-5-1","outputTokens":35287,"runtime":"claude","turns":35,"wallClockMs":536793}},{"artifacts":[],"attempt":1,"bundleHash":"9c758a115ddd6550af6c3c40399189e25b5d3491073260e2514eb717106d8f5f","device":"18527ba42d5b89d7","findings":[{"description":"onOracleResult checks only that the request was once asked; it never requires Status.Pending. Intake v2 prevents completing the outer request id twice, but for status 0 it forwards the writer-supplied callback arguments without binding their first word to that outer id. Therefore a mistaken or malicious authorized writer can complete a different pending request B with callback arguments naming settled request A. An invalid signature then overwrites A as Unverified while latestFor still points to A. The registry thus does not enforce the once-per-request memory guarantee independently of correct writer argument pairing. This requires the authorized writer; no permissionless exploit or stolen signer key is claimed. The canonical supplied consumer checks pending before either hook. Add a local Pending guard before updating a record. The separate onFailure hook also lacks such a guard, although live Intake validates the first two failure-argument words.","line":184,"path":"src/AskSwarmRegistry.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\nimport {Test} from \"forge-std/Test.sol\";\nimport {AskSwarmRegistry} from \"src/AskSwarmRegistry.sol\";\nimport {IIntake, OracleAttestation} from \"src/IIntake.sol\";\n\ncontract ProofToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 amount) external { balanceOf[to] += amount; }\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount; return true;\n    }\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n// The relevant Intake v2 transport behavior: complete is writer-only, marks each OUTER\n// id completed once, and forwards opaque success args without validating their first word.\ncontract ProofIntake {\n    struct Request { address target; bytes4 selector; bool completed; }\n    ProofToken public immutable token;\n    address public immutable writer;\n    uint256 private nonce;\n    mapping(bytes32 => Request) public requests;\n    constructor(ProofToken t, address w) { token = t; writer = w; }\n    function priceOf(bytes32, address) external pure returns (uint256) { return 0.5 ether; }\n    function requestWithFailure(bytes32, bytes calldata, IIntake.Callback calldata cb,\n        bytes4, address, uint256 amount) external returns (bytes32 id) {\n        require(token.transferFrom(msg.sender, writer, amount));\n        id = keccak256(abi.encode(block.chainid, address(this), ++nonce));\n        requests[id] = Request(cb.target, cb.selector, false);\n    }\n    function complete(bytes32 outerId, bytes calldata args) external returns (bool called) {\n        require(msg.sender == writer, \"only writer\");\n        Request storage r = requests[outerId];\n        require(r.target != address(0) && !r.completed, \"not pending\");\n        r.completed = true;\n        (called,) = r.target.call{gas: 200_000}(abi.encodePacked(r.selector, args));\n    }\n}\n\ncontract RepeatedCallbackProof is Test {\n    uint256 constant KEY = 0xA11CE;\n    AskSwarmRegistry registry;\n    ProofToken token;\n    ProofIntake intake;\n    function setUp() public {\n        vm.chainId(1);\n        vm.warp(1_800_000_000);\n        token = new ProofToken();\n        intake = new ProofIntake(token, address(this));\n        registry = new AskSwarmRegistry(address(intake), address(token), vm.addr(KEY));\n        token.mint(address(this), 10 ether);\n        token.approve(address(registry), 10 ether);\n    }\n    function attestation(bytes32 id) internal view returns (OracleAttestation memory a) {\n        a = OracleAttestation({requestId: keccak256(abi.encode(\"oracle UUID\", id)), chainId: 1,\n            questionHash: keccak256(\"question\"), answerType: 0, answer: abi.encode(true), figure: 1,\n            fromBlock: 100, toBlock: 200, blockHash: keccak256(\"block\"), panelJobId: keccak256(\"job\"),\n            panelSize: 15, quorum: 11, agreed: 11, issuedAt: uint64(block.timestamp),\n            expiresAt: uint64(block.timestamp + 1 days)});\n    }\n    function sign(OracleAttestation memory a) internal view returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, registry.digest(a));\n        return abi.encodePacked(r, s, v);\n    }\n    function test_anotherCompletionCannotRewriteAnAlreadySettledRequest() public {\n        bytes32 first = registry.ask(\"{}\");\n        OracleAttestation memory a = attestation(first);\n        require(intake.complete(first, abi.encode(first, a, sign(a))), \"first delivery\");\n        (AskSwarmRegistry.Ask memory beforeAsk, AskSwarmRegistry.Answer memory beforeAnswer) = registry.get(first);\n        require(beforeAnswer.verified, \"fixture must begin with a verified answer\");\n        bytes32 saved = keccak256(abi.encode(beforeAsk, beforeAnswer));\n        bytes32 second = registry.ask(\"{}\");\n        // A writer mistake or malicious writer can put first in the opaque success arguments\n        // of a different, still-pending outer request. Neither outer request is completed twice.\n        intake.complete(second, abi.encode(first, a, bytes(\"invalid signature\")));\n        (AskSwarmRegistry.Ask memory afterAsk, AskSwarmRegistry.Answer memory afterAnswer) = registry.get(first);\n        assertEq(keccak256(abi.encode(afterAsk, afterAnswer)), saved,\n            \"a settled record must be immutable even when a later callback repeats its id\");\n    }\n}","reproduction":"Run the attached standalone test. Ask A, sign a valid 11-of-15 answer for A, and complete A successfully. Ask B. As Intake writer, call complete(B, status 0) with opaque args abi.encode(A, the prior attestation, bytes(\"invalid signature\")). Neither outer request is completed twice. Expected: the settled A record remains byte-for-byte unchanged, with the repeated callback rejected or ignored. Actual: A changes from Answered/verified=true to Unverified/verified=false and its stored record hash changes. The local proof models the relevant verified Intake v2 complete logic and fails on the unchanged registry. A separate local fork reproduction against the actual Ethereum Intake v2 and IMD at block 26213316 also failed the settled-record hash equality assertion; it is corroboration, while the attached proof remains entirely offline.","severity":"low","title":"A later Intake completion can overwrite an already settled registry record"},{"description":"The registry checks expiresAt but never checks issuedAt. The supplied canonical OracleAttestationConsumer rejects issuedAt > block.timestamp + 5 minutes. A correctly signed attestation beyond this tolerance is nevertheless recorded as Answered, verified, indexed by latestFor and exposed as current here. This is a protocol-validity mismatch requiring a future-dated signed attestation, for example after a signer clock error; an ordinary user cannot forge one. Reject it or record it as Unverified rather than presenting it as a current canonical answer.","line":205,"path":"src/AskSwarmRegistry.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\nimport {Test} from \"forge-std/Test.sol\";\nimport {AskSwarmRegistry} from \"src/AskSwarmRegistry.sol\";\nimport {IIntake, OracleAttestation} from \"src/IIntake.sol\";\n\ncontract ProofToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 amount) external { balanceOf[to] += amount; }\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount; return true;\n    }\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n// The relevant Intake v2 transport behavior: complete is writer-only, marks each OUTER\n// id completed once, and forwards opaque success args without validating their first word.\ncontract ProofIntake {\n    struct Request { address target; bytes4 selector; bool completed; }\n    ProofToken public immutable token;\n    address public immutable writer;\n    uint256 private nonce;\n    mapping(bytes32 => Request) public requests;\n    constructor(ProofToken t, address w) { token = t; writer = w; }\n    function priceOf(bytes32, address) external pure returns (uint256) { return 0.5 ether; }\n    function requestWithFailure(bytes32, bytes calldata, IIntake.Callback calldata cb,\n        bytes4, address, uint256 amount) external returns (bytes32 id) {\n        require(token.transferFrom(msg.sender, writer, amount));\n        id = keccak256(abi.encode(block.chainid, address(this), ++nonce));\n        requests[id] = Request(cb.target, cb.selector, false);\n    }\n    function complete(bytes32 outerId, bytes calldata args) external returns (bool called) {\n        require(msg.sender == writer, \"only writer\");\n        Request storage r = requests[outerId];\n        require(r.target != address(0) && !r.completed, \"not pending\");\n        r.completed = true;\n        (called,) = r.target.call{gas: 200_000}(abi.encodePacked(r.selector, args));\n    }\n}\n\ncontract FutureIssuedProof is Test {\n    uint256 constant KEY = 0xA11CE;\n    AskSwarmRegistry registry;\n    ProofToken token;\n    ProofIntake intake;\n    function setUp() public {\n        vm.chainId(1);\n        vm.warp(1_800_000_000);\n        token = new ProofToken();\n        intake = new ProofIntake(token, address(this));\n        registry = new AskSwarmRegistry(address(intake), address(token), vm.addr(KEY));\n        token.mint(address(this), 10 ether);\n        token.approve(address(registry), 10 ether);\n    }\n    function attestation(bytes32 id) internal view returns (OracleAttestation memory a) {\n        a = OracleAttestation({requestId: keccak256(abi.encode(\"oracle UUID\", id)), chainId: 1,\n            questionHash: keccak256(\"question\"), answerType: 0, answer: abi.encode(true), figure: 1,\n            fromBlock: 100, toBlock: 200, blockHash: keccak256(\"block\"), panelJobId: keccak256(\"job\"),\n            panelSize: 15, quorum: 11, agreed: 11, issuedAt: uint64(block.timestamp),\n            expiresAt: uint64(block.timestamp + 1 days)});\n    }\n    function sign(OracleAttestation memory a) internal view returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, registry.digest(a));\n        return abi.encodePacked(r, s, v);\n    }\n    function test_futureIssuedAttestationIsNotCurrent() public {\n        bytes32 id = registry.ask(\"{}\");\n        OracleAttestation memory a = attestation(id);\n        a.issuedAt = uint64(block.timestamp + 301); // beyond canonical 5-minute tolerance\n        bytes memory signature = sign(a);\n        intake.complete(id, abi.encode(id, a, signature));\n        (, AskSwarmRegistry.Answer memory result) = registry.get(id);\n        assertFalse(result.verified, \"attestation issued beyond clock tolerance must not verify\");\n        assertFalse(registry.isCurrent(a.questionHash));\n    }\n}","reproduction":"Run the attached standalone test at timestamp 1800000000 with a locally configured test signer. Ask once and sign an otherwise valid attestation with issuedAt=1800000301, expiresAt=1800086400, panelSize=15, quorum=11, agreed=11. Deliver through the Intake fixture under 200000 gas. Expected: verified=false and isCurrent=false (or callback rejection), because issuedAt exceeds the canonical 300-second clock tolerance by one second. Actual: verified=true, status Answered, and the question becomes current. The first expected-false assertion fails.","severity":"low","title":"Future-issued attestations outside the protocol clock tolerance are marked current"},{"description":"DOMAIN_SEPARATOR is computed once in the constructor and reused without checking the current block.chainid. If the deployed state survives a fork that changes chain id, signatures produced for the original chain remain valid in the copied registry, contrary to the current-chain EIP-712 domain. This is conditional on such a fork and authorized delivery; it is not a cross-contract replay against an independently deployed Base registry. Recompute the domain when the chain id differs from its deployment value. The source attestation chainId is intentionally distinct from the consumer domain chainId and must not be used as the substitute check.","line":146,"path":"src/AskSwarmRegistry.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\nimport {Test} from \"forge-std/Test.sol\";\nimport {AskSwarmRegistry} from \"src/AskSwarmRegistry.sol\";\nimport {IIntake, OracleAttestation} from \"src/IIntake.sol\";\n\ncontract ProofToken {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 amount) external { balanceOf[to] += amount; }\n    function approve(address spender, uint256 amount) external returns (bool) {\n        allowance[msg.sender][spender] = amount; return true;\n    }\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        allowance[from][msg.sender] -= amount;\n        balanceOf[from] -= amount;\n        balanceOf[to] += amount;\n        return true;\n    }\n}\n\n// The relevant Intake v2 transport behavior: complete is writer-only, marks each OUTER\n// id completed once, and forwards opaque success args without validating their first word.\ncontract ProofIntake {\n    struct Request { address target; bytes4 selector; bool completed; }\n    ProofToken public immutable token;\n    address public immutable writer;\n    uint256 private nonce;\n    mapping(bytes32 => Request) public requests;\n    constructor(ProofToken t, address w) { token = t; writer = w; }\n    function priceOf(bytes32, address) external pure returns (uint256) { return 0.5 ether; }\n    function requestWithFailure(bytes32, bytes calldata, IIntake.Callback calldata cb,\n        bytes4, address, uint256 amount) external returns (bytes32 id) {\n        require(token.transferFrom(msg.sender, writer, amount));\n        id = keccak256(abi.encode(block.chainid, address(this), ++nonce));\n        requests[id] = Request(cb.target, cb.selector, false);\n    }\n    function complete(bytes32 outerId, bytes calldata args) external returns (bool called) {\n        require(msg.sender == writer, \"only writer\");\n        Request storage r = requests[outerId];\n        require(r.target != address(0) && !r.completed, \"not pending\");\n        r.completed = true;\n        (called,) = r.target.call{gas: 200_000}(abi.encodePacked(r.selector, args));\n    }\n}\n\ncontract ChainDomainProof is Test {\n    uint256 constant KEY = 0xA11CE;\n    AskSwarmRegistry registry;\n    ProofToken token;\n    ProofIntake intake;\n    function setUp() public {\n        vm.chainId(1);\n        vm.warp(1_800_000_000);\n        token = new ProofToken();\n        intake = new ProofIntake(token, address(this));\n        registry = new AskSwarmRegistry(address(intake), address(token), vm.addr(KEY));\n        token.mint(address(this), 10 ether);\n        token.approve(address(registry), 10 ether);\n    }\n    function attestation(bytes32 id) internal view returns (OracleAttestation memory a) {\n        a = OracleAttestation({requestId: keccak256(abi.encode(\"oracle UUID\", id)), chainId: 1,\n            questionHash: keccak256(\"question\"), answerType: 0, answer: abi.encode(true), figure: 1,\n            fromBlock: 100, toBlock: 200, blockHash: keccak256(\"block\"), panelJobId: keccak256(\"job\"),\n            panelSize: 15, quorum: 11, agreed: 11, issuedAt: uint64(block.timestamp),\n            expiresAt: uint64(block.timestamp + 1 days)});\n    }\n    function sign(OracleAttestation memory a) internal view returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(KEY, registry.digest(a));\n        return abi.encodePacked(r, s, v);\n    }\n    function test_oldChainSignatureIsRejectedAfterChainIdChange() public {\n        bytes32 id = registry.ask(\"{}\");\n        OracleAttestation memory a = attestation(id);\n        bytes memory oldChainSignature = sign(a); // EIP-712 domain chainId = 1\n        vm.chainId(8453); // simulated chain-id-changing fork, preserving the deployed state\n        intake.complete(id, abi.encode(id, a, oldChainSignature));\n        (, AskSwarmRegistry.Answer memory result) = registry.get(id);\n        assertFalse(result.verified, \"signature from old domain chain must not verify on new chain\");\n        assertFalse(registry.isCurrent(a.questionHash));\n    }\n}","reproduction":"Run the attached standalone test. Deploy on vm.chainId(1), ask, and sign a valid pending attestation under that registry domain. Change only vm.chainId to 8453 while retaining the deployed state, simulating a chain-id-changing fork, then deliver the old signature. Expected: the old chain-1 domain signature is rejected or recorded Unverified on chain 8453. Actual: verified=true and the answer is indexed. The test fails at assertFalse(result.verified). The key difference from signing under a wrong chain for a newly deployed consumer is preservation of the original immutable domain.","severity":"low","title":"Cached EIP-712 domain permits old-chain signatures after a chain-id-changing fork"}],"hash":"78bce931021526182d4dc22fe641615bfc1eaf4ac3bf90a9e4572dcd8fe22a9f","nodeId":"d4bc66e7-85e7-464d-8737-1afba5804445","outcome":"completed","summary":"Added 40 tests and two invariants.\n\n- Build and seeded test suite passed: 64 checks.\n- Invariants passed 24,576 randomized calls.\n- All 19 mainnet fork tests passed.\n\nReported three low-severity findings with reproduced failing proofs in [.imd-findings.json](/home/imd/.identitymd/work/64fa31ee-3f0c-493f-b7ad-b2d93eb8b852/d4bc66e7-85e7-464d-8737-1afba5804445/.imd-findings.json): settled-record overwrite, future-issued attestations, and cached chain domains.","treeHash":"77080e6a18974b3a848d95879010ed9cf1d5cbe6","usage":{"cachedInputTokens":3405696,"inputTokens":150298,"model":"gpt-6-astra","outputTokens":37740,"runtime":"codex","turns":10,"wallClockMs":1144248}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"09078b7cdfb673fe","findings":[{"citation":"resolved","description":"The header invariant (line 21) says Intake enforces delivery 'only once per request'. The verified Intake v2 at 0xa43e6f75ee006411f79ac1c84120606c2330de82 enforces once per *Intake* request id, but its `complete` binds the args' first word to the request id only for status != 0 (lines 247-250 of the verified source); for status 0 the writer's args are forwarded as given. The registry keys everything on the request id it reads from those args, and `onOracleResult` only checks `k.asker != 0`, never `k.status == Pending`. So the id the registry writes is not the id Intake marks completed, and one registry ask can receive two result deliveries (one carried by another ask's Intake completion). Because `_answers[requestId]` is written (line 208) before the displacement read `cur = _answers[latestFor[a.questionHash]]` (line 225), a re-delivery under the indexed id compares the new answer against itself, so a 2-of-2 answer replaces a canonical 100-of-100 answer, or an unverified record (bad signature) becomes what `answerOf` and the filtered `answerOf(qh,minPanel,minAgreed)` return while `isCurrent` says false. The ask whose Intake id carried the stray args stays Pending forever. Trigger: only the plane's writer (msg.sender == INTAKE via `complete`), so a trusted role; severity low. It is a one-line guard that makes the stated invariant true regardless of writer behaviour: refuse when `k.status != Status.Pending` (also in `onFailure`), or at least read `cur` before writing `_answers[requestId]`.","line":183,"path":"src/AskSwarmRegistry.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {AskSwarmRegistry} from \"src/AskSwarmRegistry.sol\";\nimport {IIntake, OracleAttestation} from \"src/IIntake.sol\";\n\ncontract ProofERC20 {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function approve(address spender, uint256 amount) external returns (bool) { allowance[msg.sender][spender] = amount; return true; }\n    function mint(address to, uint256 a) external { balanceOf[to] += a; }\n    function transferFrom(address from, address to, uint256 amount) external returns (bool) {\n        uint256 a = allowance[from][msg.sender];\n        if (a != type(uint256).max) allowance[from][msg.sender] = a - amount;\n        balanceOf[from] -= amount; balanceOf[to] += amount; return true;\n    }\n}\n\n/// Mirrors the verified Intake v2 on mainnet (0xa43e6f75ee006411f79ac1c84120606c2330de82, `_request` and\n/// `complete`): writer-only, once per Intake request id, and the args' first word is bound to the request\n/// id only when status != 0. For status 0 the writer's args are passed to the callback as given.\ncontract ProofIntake {\n    struct Req { address payer; address target; bytes4 selector; bool completed; }\n    bytes32 public constant ACTION = bytes32(\"oracle.request@oracle-1\");\n    uint256 public constant PRICE = 0.5 ether;\n    address public writer; address public imd; address public owner;\n    mapping(bytes32 => Req) public requests; mapping(bytes32 => bytes4) public failureSelectorOf; uint256 nonce;\n    constructor(address w, address i) { writer = w; imd = i; owner = msg.sender; }\n    function priceOf(bytes32 action, address asset) public view returns (uint256) { return action == ACTION && asset == imd ? PRICE : 0; }\n    function requestWithFailure(bytes32 action, bytes calldata body, IIntake.Callback calldata cb, bytes4 onFailure, address asset, uint256 amount) external payable returns (bytes32 id) {\n        uint256 p = priceOf(action, asset);\n        require(p != 0 && amount >= p && body.length != 0, \"PriceNotMet\");\n        id = keccak256(abi.encode(block.chainid, address(this), ++nonce));\n        requests[id] = Req(msg.sender, cb.target, cb.selector, false);\n        failureSelectorOf[id] = onFailure;\n        require(ProofERC20(asset).transferFrom(msg.sender, owner, amount), \"pull\");\n    }\n    function complete(bytes32 id, uint8 status, bytes32, string calldata, bytes calldata args) external returns (bool delivered) {\n        require(msg.sender == writer, \"NotWriter\");\n        Req storage r = requests[id];\n        require(r.payer != address(0), \"UnknownRequest\");\n        require(!r.completed, \"AlreadyCompleted\");\n        r.completed = true;\n        bytes4 sel = r.selector;\n        if (status != 0) {\n            sel = failureSelectorOf[id];\n            if (sel != bytes4(0) && args.length != 0) {\n                require(args.length >= 64 && bytes32(args[0:32]) == id && uint256(bytes32(args[32:64])) == status, \"FailureArgsMismatch\");\n            } else sel = bytes4(0);\n        }\n        if ((status == 0 || sel != bytes4(0)) && r.target != address(0)) {\n            (delivered,) = r.target.call{gas: 200_000}(abi.encodePacked(sel, args));\n        }\n    }\n}\n\n/// Fails on the current code: a second status-0 delivery for an ask that is already Answered is accepted\n/// and overwrites the stored answer, so a 2-of-2 answer replaces the canonical 100-of-100 answer under the\n/// same request id without the displacement rule applying. Passes once onOracleResult refuses a request\n/// that is no longer Pending (or otherwise refuses a second write to the same request id).\ncontract OncePerAskProofTest is Test {\n    uint256 constant PK = 0xA11CE;\n    ProofERC20 imd; ProofIntake intake; AskSwarmRegistry reg;\n    address user = makeAddr(\"user\"); address writer = makeAddr(\"writer\");\n\n    function setUp() public {\n        vm.chainId(1); vm.roll(26_300_000); vm.warp(1_792_300_000);\n        imd = new ProofERC20(); intake = new ProofIntake(writer, address(imd));\n        reg = new AskSwarmRegistry(address(intake), address(imd), vm.addr(PK));\n        imd.mint(user, 100 ether);\n    }\n    function _ask() internal returns (bytes32 id) {\n        vm.startPrank(user); imd.approve(address(reg), type(uint256).max); id = reg.ask('{\"v\":1,\"question\":\"q\"}'); vm.stopPrank();\n    }\n    function _att(bytes32 id, bytes32 qh, bytes memory ans, uint16 panel, uint16 quorum, uint16 agreed) internal view returns (OracleAttestation memory a) {\n        a = OracleAttestation(id, 1, qh, 2, ans, 0, uint64(block.number - 10), uint64(block.number), blockhash(block.number - 1), bytes32(uint256(7)), panel, quorum, agreed, uint64(block.timestamp), uint64(block.timestamp + 1 days));\n    }\n    function _sign(OracleAttestation memory a) internal view returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(PK, reg.digest(a)); return abi.encodePacked(r, s, v);\n    }\n\n    function test_secondDeliveryForAnsweredAskMustNotOverwrite() public {\n        bytes32 r1 = _ask();\n        bytes32 r2 = _ask();\n        bytes32 q = keccak256(\"question\");\n\n        // Intake request r1 is completed with result args naming r2 (Intake binds args to the id only for failures).\n        OracleAttestation memory strong = _att(r2, q, \"CHF\", 100, 51, 100);\n        bytes memory sigS = _sign(strong);\n        vm.prank(writer);\n        intake.complete(r1, 0, 0, \"\", abi.encode(r2, strong, sigS));\n        (AskSwarmRegistry.Ask memory k2, AskSwarmRegistry.Answer memory a2) = reg.get(r2);\n        assertEq(uint8(k2.status), uint8(AskSwarmRegistry.Status.Answered));\n        assertEq(a2.panelSize, 100);\n        assertEq(reg.latestFor(q), r2);\n\n        // Intake request r2 is completed with a weaker signed answer for the same question, again under r2.\n        OracleAttestation memory weak = _att(r2, q, \"EUR\", 2, 2, 2);\n        bytes memory sigW = _sign(weak);\n        vm.prank(writer);\n        intake.complete(r2, 0, 0, \"\", abi.encode(r2, weak, sigW));\n\n        // Expected: r2 is already Answered, so the second delivery is refused and the 100-of-100 answer stays.\n        (, AskSwarmRegistry.Answer memory held) = reg.answerOf(q);\n        assertEq(reg.latestFor(q), r2);\n        assertEq(held.panelSize, 100, \"canonical 100-of-100 answer was overwritten by a 2-of-2 one under the same id\");\n        assertEq(held.answer, bytes(\"CHF\"));\n        assertEq(held.agreed, 100);\n    }\n}","reproduction":"State: two registry asks r1, r2 (both Pending), writer W. 1) W calls Intake.complete(r1, 0, 0, '', abi.encode(r2, A100, sig100)) where A100 is a signer-signed attestation {questionHash Q, panelSize 100, quorum 51, agreed 100}. Registry: asks[r2].status = Answered, _answers[r2] = A100, latestFor[Q] = r2; asks[r1] stays Pending (never completes again, Intake marks r1 completed). 2) W calls Intake.complete(r2, 0, 0, '', abi.encode(r2, A2, sig2)) with A2 = {questionHash Q, panelSize 2, quorum 2, agreed 2}, signed. Expected: second write for r2 refused (r2 is not Pending), answerOf(Q) still 100-of-100 'CHF'. Actual: _answers[r2] overwritten, answerOf(Q) returns panelSize 2 'EUR' with latestFor[Q] == r2. Variant: step 2 with an empty signature leaves latestFor[Q] == r2 with verified == false; answerOf(Q,15,11) still returns r2. Both reproduced in test/scratch/Leads.t.sol against a mock mirroring the verified Intake `complete`; the proof below fails on the current code and passes with `if (k.asker == address(0) || k.status != Status.Pending) revert UnknownAsk(requestId);`.","severity":"low","snippet":"        Ask storage k = asks[requestId];\n        if (k.asker == address(0)) revert UnknownAsk(requestId);","title":"onOracleResult has no once-per-ask guard: a second status-0 delivery under an already Answered request id overwrites the stored answer and the canonical latestFor entry without the displacement rule a"},{"citation":"resolved","description":"`ask(bytes body)` reads `INTAKE.priceOf(ACTION, IMD)` and pulls exactly that from msg.sender with no maximum the caller agreed to. Intake's price is owner-settable at any time (`setPrice`, verified source line 280) and the brief anticipates it moving. The suite's own `_ask` helper and the conformance test approve the registry for type(uint256).max, the pattern the bot's Pay-with-IMD page will most likely copy. Access x economics seam: the Intake owner's `setPrice` landing before a user's pending `ask` (or an allowance left standing from an earlier ask) makes the user pay the new price with no revert; a user who approved exactly 0.5 IMD is safe only by accident of the allowance. The price is also not emitted in `Asked`, so an asker cannot show what they paid from the registry's own record. Minimal fix that keeps the design: `ask(bytes body, uint256 maxPrice)` reverting when `p > maxPrice` (or an overload), and emitting `p` in `Asked`.","line":162,"path":"src/AskSwarmRegistry.sol","reproduction":"State: user approved the registry for type(uint256).max IMD (as test/AskSwarmRegistry.t.sol `_ask` does), Intake priceOf = 0.5e18. 1) Intake owner calls setPrice(ACTION, IMD, 50e18). 2) user calls reg.ask(body). Expected: the call reverts or pulls at most the 0.5 IMD the user saw. Actual: 50 IMD leave the user's wallet (test/scratch/Leads.t.sol test_askHasNoPriceCeiling: balance delta 50e18). On mainnet the same happens if the owner's setPrice lands in the block before the user's ask.","severity":"low","snippet":"        uint256 p = price();\n        if (p == 0) revert NotSold();\n        _call(IMD, abi.encodeWithSelector(0x23b872dd, msg.sender, address(this), p));","title":"ask has no price ceiling: a caller with a standing allowance pays whatever Intake's owner has set at inclusion time"},{"citation":"resolved","description":"Documented so the judge can weigh them; none is a permission bypass in the registry. (a) Intake.callbackGas is owner-settable (setCallbackGas, verified source line 303). onOracleResult measured under the real-shaped mock: 116,213 gas for a 32-byte answer with a cold question slot, 153,845 at the 4096-byte cap (test/scratch/GasCap.t.sol). If callbackGas is set below ~116k every delivery fails, every ask stays Pending and the 0.5 IMD is spent; between ~116k and ~154k long answers fail selectively. (b) Intake.setWriter changes who may call `complete`; the registry trusts whatever the writer pairs under a Pending id (finding 1 shows the extent). (c) SIGNER is immutable; if the plane rotates the attester, every later answer is stored Unverified and never indexed, with the price still spent (documented lifecycle). (d) Panel size and quorum are asker-chosen at a flat price, so a 2-of-2 majority answer is indexed when nothing stronger is held; readers must use answerOf(qh,minPanel,minAgreed), as the NatSpec says. (e) Tokens sent straight to the registry are stuck by design (no owner). None of these is fixable inside the agreed no-owner design; (a) and (c) argue for the Telegram bot checking `Intake.callbackGas()` and the attester before charging.","line":182,"path":"src/AskSwarmRegistry.sol","reproduction":"(a) Etch an Intake mock with callbackGas = 110_000 and complete any registry ask with a valid 32-byte attestation: the callback call returns false, asks[id].status stays Pending, no Answered event, price already forwarded to payTo. (c) Deploy with signer S1, deliver an attestation signed by S2: status Unverified, latestFor unchanged (covered by test_badSignatureStoredUnverifiedAndNotIndexed).","severity":"info","snippet":"        if (msg.sender != address(INTAKE)) revert NotIntake();","title":"Trust assumptions outside the registry: Intake owner (callbackGas, price, writer), the attester key, and the writer's pairing decide whether paid asks ever resolve"}],"hash":"804b1f0036b29360a73692ab668bae260f2abee909aa483ebc6003a985c7626d","nodeId":"14058e38-e851-4338-9a26-f5a3031bef80","outcome":"completed","summary":"Review complete. No tracked files changed; `.imd-findings.json` holds 3 findings and 8 coverage rows, with one proof test.\n\n**What I found**\n\nThe access model holds: both hooks are gated on the immutable Intake address, unknown ids revert, there is no owner, no fallback, no upgrade path, and the constructor hands out no roles. I fetched the verified Intake v2 and IMD sources from Blockscout to check the assumptions the registry rests on. Intake's `complete` is writer-only and once per Intake request. IMD is a plain OpenZeppelin OFT with no transfer hooks, so the Slither reentrancy leads do not reach anything. Gas at the 4096-byte answer cap is about 154k, under the 200k stipend.\n\nTwo low findings and one trust-assumption note:\n\n- **No once-per-ask guard in onOracleResult (low, with proof).** Intake binds the callback arguments' request id to its own request id only for failures, not for results. The registry checks only that the asker is nonzero, never that the ask is still Pending, and writes the stored answer before reading the currently indexed one. A second result delivery under an already Answered id therefore replaces a canonical 100-of-100 answer with a 2-of-2 one, or with an unverified record, and the displacement rule never fires. Only the writer can trigger it, so it is low, but the header comment claiming Intake enforces once-per-request is not true as written. The proof fails now and passes with a one-line Pending check.\n- **No price ceiling in ask (low).** The caller pays whatever Intake's owner has set at inclusion time. With the max allowance the test suite itself uses, a price change landing before the ask drains the new price with no revert. Reproduced with a mock at 50 IMD.\n- **Trust assumptions (info).** Intake's owner-settable callback gas can leave every paid ask Pending below roughly 116k, attester rotation makes every later answer Unverified, and panel size is asker-chosen at a flat price.\n\n**Coverage.** All three entry points are answered, plus rows for the constructor, the own-asks-only invariant, the latestFor invariant, the reads, and the static-analysis leads. Nothing in my area was left unreached. Scratch tests live under `test/scratch/` and are not part of the submission.","treeHash":null,"usage":{"cachedInputTokens":1461270,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":37322,"runtime":"claude","turns":34,"wallClockMs":567867}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"63c29c49a249ab7e","findings":[{"citation":"resolved","description":"The contract says (lines 26-28) that a weak panel cannot overwrite a strong one, and that replacing a strong answer costs a stronger panel. But it measures strength only as absolute panelSize and absolute agreed. The asker chooses panelSize and quorum in the body, and the price is a flat 0.5 IMD whatever the panel size, so a stronger panel costs nothing extra. The only check on the new answer is its own signed agreed >= quorum, which is also asker-chosen (quorum can be 1 or 2). questionHash covers the question, window and definitions, not the panel parameters. So for any question with an absolute window, anyone can ask the same question again with panelSize 100 and a low quorum. If the plurality of that panel lands on a different answer with at least as many absolute votes as the held answer, the new answer is indexed even though its agreement ratio is far lower. answerOf(questionHash) and isCurrent, which the bot's free tier reads before charging, then serve the weaker answer. Fix: compare strength by ratio as well (for example a.agreed * cur.panelSize >= cur.agreed * a.panelSize, together with a.quorum >= cur.quorum), or require a minimum quorum fraction such as quorum * 2 > panelSize before an answer is indexed.","line":211,"path":"src/AskSwarmRegistry.sol","reproduction":"State: question Q (absolute window) holds requestId 1 with panelSize 15, quorum 11, agreed 11, answer \"CHF\", unexpired (73% agreement). Anyone calls ask() with a body for the same Q and panelSize 100, quorum 2. The panel's plurality answer \"EUR\" gets 11 of 100 votes, so the signed attestation has panelSize 100, quorum 2, agreed 11. On delivery: ok = true (11 >= 2), held = true, 100 >= 15 and 11 >= 11, so latestFor[Q] becomes requestId 2. Expected: latestFor[Q] stays 1, because an 11% answer is weaker than a 73% one. Actual: latestFor[Q] == 2 and answerOf(Q) returns \"EUR\". Verified with a non-fork test (mock Intake calling onOracleResult under a 200k gas limit, test-key signer): assertEq(reg.latestFor(qh), strong) fails with 0x..02 != 0x..01.","severity":"medium","snippet":"            if (!held || (a.panelSize >= cur.panelSize && a.agreed >= cur.agreed)) latestFor[a.questionHash] = requestId;","title":"latestFor 'strength' ignores the agreement ratio and quorum, so a minority answer from a large, low-quorum panel displaces a held supermajority answer"},{"citation":"resolved","description":"The brief says onOracleResult 'requires agreed >= quorum and not expired', and the oracle-consumer protocol's _verifyAttestation refuses an expired attestation. Here ok checks only the signer and the quorum. Expiry is used only to decide indexing (line 208). As a result get(requestId) returns Answer.verified == true and Ask.status == Answered, and the Answered event carries verified=true, for an attestation that was already stale when it arrived. Any client that trusts get() or the event's verified flag (the Stored comment leaves expiry to the reader) treats a stale answer as valid. Fix: include block.timestamp <= a.expiresAt in ok, and record the delivery as Unverified otherwise. If the current behaviour is intended, correct the brief and LAUNCH.md.","line":190,"path":"src/AskSwarmRegistry.sol","reproduction":"Ask once, then have Intake deliver a correctly signed attestation with panelSize 15, quorum 11, agreed 11, expiresAt = block.timestamp - 1. Expected (per the brief): verified false and status not Answered. Actual: reg.get(id) returns verified == true and status == Answered (2), though latestFor is not updated. Verified with a non-fork mock-Intake test: assertFalse(got.verified) fails.","severity":"low","snippet":"        bool ok = rec != address(0) && rec == SIGNER && a.agreed >= a.quorum;","title":"An attestation already expired at delivery is stored verified=true with status Answered, contrary to the brief's 'requires ... not expired'"}],"hash":"a4289452ad4d3accbd4dde1cde692b73e1d599eca83d57a180701502dad52e80","nodeId":"d70d1adb-15db-4261-bdbe-7c0ba791fdd9","outcome":"completed","summary":"I found two problems in `AskSwarmRegistry`: one medium, one low. Neither loses funds, and nothing I checked blocks the launch. Both are written to `.imd-findings.json` with exact inputs, and each one fails a test I ran without a fork against a mock Intake.\n\n**Medium: a weaker answer can replace a stronger one in `latestFor`** (`src/AskSwarmRegistry.sol:211`). The contract decides which answer is stronger by panel size and number of agreeing votes only. It ignores what share of the panel agreed, and ignores quorum. Whoever asks picks the panel size and quorum, and the price is a flat 0.5 IMD whatever the size. So anyone can ask the same question again with a 100-member panel and quorum 2. If 11 of 100 agree on \"EUR\", that answer replaces a stored 11-of-15 \"CHF\" answer (11% agreement beats 73%). After that, `answerOf` and `isCurrent`, which the bot's free tier reads before charging, return \"EUR\". This breaks the contract's own promise (lines 26–28) that a weak panel cannot overwrite a strong one. Fix: also compare the agreement share and quorum, or require a minimum quorum share before an answer is stored as the current one.\n\n**Low: an answer that has already expired when it arrives is still marked verified** (`src/AskSwarmRegistry.sol:190`). The brief says the callback \"requires agreed >= quorum and not expired\". The code checks expiry only when deciding whether to make the answer current. `get(requestId)` and the `Answered` event still report it as verified with status Answered. Fix: add the expiry check to the verified condition. If the current behaviour is intended, correct the brief instead.\n\n**What was read:**\n- `src/AskSwarmRegistry.sol`, `src/IIntake.sol`, `test/AskSwarmRegistry.t.sol`, `script/Deploy.s.sol`, `foundry.toml`, `LAUNCH.md`, the protected launch checks, and the guides I was given.\n- The repository has no `contracts/` folder; the Foundry project is at the root.\n- I did not read the source of the live Intake v2 or the IMD token. I only called them through the mainnet fork, so how far Intake enforces one delivery per request is taken from the contract's own description and the fork tests, not from reading its code.\n\n**What I checked and found no problem with:**\n- **Existing tests:** all 13 fork tests pass.\n- **Gas:**\n  - The repo's own test measures the callback at about 145k gas for a 32-byte answer, not the ~105k the brief states.\n  - Delivered through the real Intake on a fork, answers of 32 to 4096 bytes all arrived and were stored within the 200k limit.\n- **Launch rules:**\n  - The compiled contract contains no DELEGATECALL, CALLCODE or SELFDESTRUCT.\n  - The constructor's approval call does not revert on an empty chain.\n- **Access and contract shape:**\n  - Only Intake can call the two callbacks.\n  - There is no owner and no setting changed after deployment.\n- **Signatures:**\n  - The signing domain and data layout match the protocol reference.\n  - Signatures are rejected when they are the wrong length or use the alternate high-s form.\n- **Token handling:** the contract keeps no IMD between calls.\n\nI changed no repository files. My temporary test files in `test/scratch/` have been deleted.","treeHash":null,"usage":{"cachedInputTokens":617479,"inputTokens":24,"model":"claude-opus-5-5","outputTokens":14432,"runtime":"claude","turns":12,"wallClockMs":180422}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"9b657b3675e34ec5","findings":[{"citation":"resolved","description":"onOracleResult accepts any requestId whose Ask exists; it does not require k.status == Status.Pending. The brief and the header comment say \"only once per request (Intake enforces both)\", but the deployed Intake v2 (0xa43e6f75ee006411f79ac1c84120606c2330de82, verified source) enforces once-only on ITS request id and, for status 0, forwards the writer's `args` to the callback without checking which request id those args name (it validates args[0:32] == requestId only when status != 0). The registry keys everything on the callback's first argument. So two Intake requests the registry made can both be completed with args naming the same registry id. The second write replaces _answers[id1] in place (three slots), flips asks[id1].status to Unverified, and leaves latestFor[questionHash] pointing at id1, so answerOf(questionHash) now returns an unverified record as the canonical answer and isCurrent() turns false while the real answer for id2 is never recorded. Trigger is the plane's writer (trusted) or a pairing bug on its side, so severity is low; the registry's stated invariant that latestFor only indexes verified majorities is otherwise not under its own control. Minimal fix that keeps the design: at the top of onOracleResult and onFailure, `if (k.status != Status.Pending) revert AlreadySettled(requestId);` (a revert inside Intake's try is recorded as delivered == false and changes nothing).","line":184,"path":"src/AskSwarmRegistry.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.26;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {AskSwarmRegistry} from \"src/AskSwarmRegistry.sol\";\nimport {OracleAttestation} from \"src/IIntake.sol\";\n\n/// Minimal ERC-20 for the IMD stand-in.\ncontract Token {\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n\n    function mint(address to, uint256 amt) external {\n        balanceOf[to] += amt;\n    }\n\n    function approve(address s, uint256 a) external returns (bool) {\n        allowance[msg.sender][s] = a;\n        return true;\n    }\n\n    function transferFrom(address f, address t, uint256 a) external returns (bool) {\n        uint256 al = allowance[f][msg.sender];\n        if (al != type(uint256).max) allowance[f][msg.sender] = al - a;\n        balanceOf[f] -= a;\n        balanceOf[t] += a;\n        return true;\n    }\n}\n\n/// Intake v2 as deployed at 0xa43e...de82: `complete` runs once per Intake request id and, on status 0,\n/// forwards the writer's `args` to the callback without checking which request id they name.\ncontract Intake {\n    struct Callback {\n        address target;\n        bytes4 selector;\n    }\n\n    struct Req {\n        address payer;\n        address target;\n        bytes4 sel;\n        bool completed;\n    }\n\n    bytes32 constant ACTION = bytes32(\"oracle.request@oracle-1\");\n    address public immutable writer;\n    address public immutable imd;\n    mapping(bytes32 => Req) public reqs;\n    uint256 nonce;\n\n    constructor(address w, address i) {\n        writer = w;\n        imd = i;\n    }\n\n    function priceOf(bytes32 a, address asset) public view returns (uint256) {\n        return a == ACTION && asset == imd ? 0.5 ether : 0;\n    }\n\n    function requestWithFailure(bytes32 a, bytes calldata, Callback calldata cb, bytes4, address asset, uint256 amount)\n        external\n        payable\n        returns (bytes32 id)\n    {\n        require(amount >= priceOf(a, asset), \"price\");\n        require(Token(asset).transferFrom(msg.sender, address(this), amount), \"pull\");\n        id = keccak256(abi.encode(block.chainid, address(this), ++nonce));\n        reqs[id] = Req(msg.sender, cb.target, cb.selector, false);\n    }\n\n    function complete(bytes32 id, uint8 status, bytes calldata args) external {\n        require(msg.sender == writer, \"NotWriter\");\n        Req storage r = reqs[id];\n        require(r.payer != address(0) && !r.completed, \"bad\");\n        r.completed = true;\n        if (status == 0) {\n            bytes memory data = bytes.concat(r.sel, args);\n            address t = r.target;\n            assembly {\n                pop(call(200000, t, 0, add(data, 0x20), mload(data), 0, 0))\n            }\n        }\n    }\n}\n\ncontract OnceOnlyProof is Test {\n    uint256 constant PK = 0xA11CE;\n    address user = makeAddr(\"user\");\n    address writer = makeAddr(\"writer\");\n    Token imd;\n    Intake intake;\n    AskSwarmRegistry reg;\n\n    function setUp() public {\n        vm.chainId(1);\n        vm.roll(26_300_000);\n        vm.warp(1_792_300_000);\n        imd = new Token();\n        intake = new Intake(writer, address(imd));\n        reg = new AskSwarmRegistry(address(intake), address(imd), vm.addr(PK));\n        imd.mint(user, 10 ether);\n    }\n\n    function _ask() internal returns (bytes32 id) {\n        vm.startPrank(user);\n        imd.approve(address(reg), type(uint256).max);\n        id = reg.ask(\"{}\");\n        vm.stopPrank();\n    }\n\n    function _att(bytes32 id, bytes memory ans, uint16 panel, uint16 quorum, uint16 agreed)\n        internal\n        view\n        returns (OracleAttestation memory a)\n    {\n        a = OracleAttestation({\n            requestId: id,\n            chainId: 1,\n            questionHash: bytes32(uint256(0xabcd)),\n            answerType: 0,\n            answer: ans,\n            figure: 0,\n            fromBlock: uint64(block.number - 10),\n            toBlock: uint64(block.number),\n            blockHash: blockhash(block.number - 1),\n            panelJobId: bytes32(uint256(7)),\n            panelSize: panel,\n            quorum: quorum,\n            agreed: agreed,\n            issuedAt: uint64(block.timestamp),\n            expiresAt: uint64(block.timestamp + 1 days)\n        });\n    }\n\n    function _sign(OracleAttestation memory a) internal view returns (bytes memory) {\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(PK, reg.digest(a));\n        return abi.encodePacked(r, s, v);\n    }\n\n    /// Two registry asks (two Intake request ids). The first is answered and indexed. The second Intake\n    /// request is completed with args that name the first registry id again. Expected: the settled id1\n    /// record is untouched (a settled ask takes no second delivery). Actual: id1's stored answer is\n    /// overwritten by an unsigned 1-of-2 attestation, status flips to Unverified, and latestFor still\n    /// points at id1, so answerOf hands out an unverified record as the canonical answer.\n    function test_settledAskTakesNoSecondDelivery() public {\n        bytes32 id1 = _ask();\n        bytes32 id2 = _ask();\n        OracleAttestation memory good = _att(id1, hex\"01\", 15, 11, 15);\n        bytes memory sigGood = _sign(good);\n        vm.prank(writer);\n        intake.complete(id1, 0, abi.encode(id1, good, sigGood));\n        assertEq(reg.latestFor(bytes32(uint256(0xabcd))), id1, \"indexed\");\n\n        OracleAttestation memory bad = _att(id1, hex\"00\", 2, 1, 1);\n        vm.prank(writer);\n        intake.complete(id2, 0, abi.encode(id1, bad, \"\"));\n\n        (AskSwarmRegistry.Ask memory k1, AskSwarmRegistry.Answer memory after1) = reg.get(id1);\n        assertEq(uint8(k1.status), uint8(AskSwarmRegistry.Status.Answered), \"settled status must not change\");\n        assertTrue(after1.verified, \"settled answer must not be overwritten\");\n        assertEq(after1.agreed, 15);\n        assertEq(reg.latestFor(bytes32(uint256(0xabcd))), id1);\n        assertTrue(reg.isCurrent(bytes32(uint256(0xabcd))), \"canonical answer must stay verified\");\n    }\n}","reproduction":"Mock Intake behaving like the deployed one (once per Intake id, args forwarded unchecked on status 0). 1) user calls ask twice -> id1, id2 (Pending). 2) writer: complete(id1, 0, abi.encode(id1, A, sig)) with A = 15/15 verified -> asks[id1].status == Answered, latestFor[q] == id1. 3) writer: complete(id2, 0, abi.encode(id1, B, \"\")) with B = 1-of-2, empty signature. Expected: id1 untouched (status Answered, verified, latestFor[q] == id1 current). Actual: asks[id1].status == Unverified (5), _answers[id1].verified == false, agreed == 1, latestFor[q] still id1, isCurrent(q) == false, answerOf(q).verified == false. forge test --match-path test/scratch/OnceOnlyProof.t.sol fails with 'settled status must not change: 5 != 2'.","severity":"low","snippet":"        if (k.asker == address(0)) revert UnknownAsk(requestId);","title":"A settled ask accepts a second delivery: onOracleResult never checks status == Pending, and the once-only guarantee it borrows from Intake does not cover the request id the callback is keyed on"},{"citation":"resolved","description":"ask reads INTAKE.priceOf(ACTION, IMD) and pulls exactly that from msg.sender. The README and brief say 0.5 IMD, but the price is a mutable value on Intake (setPrice, onlyOwner; owner 0x047F606fD5b2BaA5f5C6c4aB8958E45CB6B054B7 today), and ask takes no maxPrice. The intended callers (the Telegram bot wallet and the Pay-with-IMD page) approve once for many asks; the project's own suite approves type(uint256).max in _ask(). Any ask that is in the mempool, or any bot ask after a price change, is charged the new price silently: there is no event or revert that tells the payer the price moved. The trigger is Intake's owner (IMD, trusted), so this is a low economic-security item: a price-dependent pull with no slippage bound. Fix preserving the design: add `ask(bytes body, uint256 maxPrice)` that reverts when p > maxPrice, and have the bot pass the price it quoted; keep ask(bytes) for callers that accept the live price, or make it revert when p != 0.5e18 if the registry is meant to be pinned to that price.","line":162,"path":"src/AskSwarmRegistry.sol","reproduction":"Mock Intake with a settable price (as the deployed one has). user: IMD.approve(registry, type(uint256).max); registry.price() == 0.5e18. Intake owner: setPrice(ACTION, IMD, 7e18). user: registry.ask(body). Expected: the ask reverts or is bounded by what the caller agreed to. Actual: 7 IMD leave the user (balance 10e18 -> 3e18), the ask succeeds. test/scratch/PriceSlipProof.t.sol reproduces it (fails on current code with '3000000000000000000 != 10000000000000000000').","severity":"low","snippet":"        uint256 p = price();","title":"ask(bytes) pulls whatever Intake prices at execution time with no caller-side bound; a wallet with a standing allowance is charged the new price after an Intake setPrice"},{"citation":"resolved","description":"The majority gate and the displacement rule only protect an answer that is already held. For a question hash nothing holds yet, the smallest body Intake accepts (panelSize 2, quorum 2) yields an attestation with agreed 2 that is verified, a strict majority, and so indexed in latestFor with expiresAt up to 30 days out (validForSeconds max). The oracle-consumer reference says a small panel is cheap to fill with seats one holder controls and tells consumers to require panel minimums; the registry exposes answerOf(questionHash, minPanel, minAgreed) but the unfiltered answerOf, latestFor and isCurrent all report the 2-of-2 answer as the verified current answer. Cost to the asker: 0.5 IMD. Cost to displace it: another 0.5 IMD by anyone for a >= 2 panel with >= 2 agreed, so it is not a lock, but until that happens every reader of the unfiltered getters (the bot's free tier reads prior answers before charging) is served it. Low because the README documents that readers should check panelSize and agreed. If the project wants the unfiltered getters to mean something, index only answers with panelSize >= N (a constant, no owner needed) or have isCurrent/answerOf(bytes32) apply a documented floor.","line":224,"path":"src/AskSwarmRegistry.sol","reproduction":"ask(body with panelSize 2, quorum 2, validForSeconds 2592000); writer completes with a signed attestation panelSize 2, quorum 2, agreed 2, expiresAt now+30d. Then: latestFor(q) == id, answerOf(q).verified == true, answerOf(q).panelSize == 2, isCurrent(q) == true (test/scratch/Floor.t.sol logs exactly this). Expected per the oracle-consumer guidance: an answer below a panel floor is stored but not served as the canonical one.","severity":"low","snippet":"        if (ok && uint256(a.agreed) * 2 > a.panelSize) {","title":"No floor for the first indexed answer: a 2-of-2 panel bought for 0.5 IMD becomes the canonical, verified, current answer for a question until a larger panel is paid for"},{"citation":"resolved","description":"Per the oracle-consumer reference, questionHash covers the question, the resolved block window and the definitions; a relative window ({\"hours\": N}) resolves when the plane reads the ask, so its hash cannot be computed in advance and differs each time the same question is asked. The registry's economic defense (weak panels cannot displace strong ones) and its first-principles purpose (the bot reads answerOf before charging) both assume that re-asking the same question lands on the same hash. With the README's example body (window {\"hours\":24}) that only happens for two asks the plane resolves to the same block range. Not a contract defect (the contract never sees the question), but the author should know that today the strength ordering mostly never fires and that off-chain lookups must key by requestId or by a hash they compute from an absolute window. Documenting this, or recommending absolute windows in the bot, would close the gap.","line":113,"path":"src/AskSwarmRegistry.sol","reproduction":"Two asks with the test suite's BODY ({\"hours\":24}) sent an hour apart receive attestations whose fromBlock/toBlock differ (the published a9a6d603 attestation shows the resolved window 26205587..26212764 inside its questionHash input); their questionHash values differ, so latestFor has two entries and the second never compares against the first. No on-chain input makes the registry misbehave; the observation is about what the index can promise.","severity":"info","snippet":"    mapping(bytes32 questionHash => bytes32 requestId) public latestFor;","title":"questionHash binds the resolved block window, so for relative-window asks the memory lookup and the displacement rule only engage when the exact absolute window is reused"},{"citation":"resolved","description":"Measured with an exact-gas caller (test/scratch/GasProbe2.t.sol): minimum gas for onOracleResult to complete on a fresh question is 113,215 (32-byte answer), 116,200 (352 bytes), 131,469 (2048 bytes), 150,038 (4096 bytes, the MAX_ANSWER_BYTES edge); 4097+ bytes take the cheap Unverified path. The deployed Intake's stipend is `callbackGas` = 200,000 today but owner-settable (setCallbackGas). Below ~150k long legitimate answers, and below ~113k every answer, would be recorded by Intake as not delivered and never retried: the ask stays Pending with the price spent and the registry has no clear path. Likewise, SIGNER is immutable while the attester key is operational (Intake.signer() on mainnet is 0x169B08ea234d7819EeAbb246E34d8Cd81E32279F, the quote signer, not the attester, so nothing on chain exposes the attester to read); after a rotation ask keeps charging 0.5 IMD per call and every delivery lands as Unverified. The brief chose no owner and documents the lifecycle, so these are trust assumptions on IMD's Intake owner, not defects; recorded here so the judge has the numbers. The 50k margin under 200k holds.","line":205,"path":"src/AskSwarmRegistry.sol","reproduction":"Not a failing input on this code. Numbers above come from a binary search over the gas limit passed to onOracleResult from a contract frame, status 0, fresh question hash, cold slots.","severity":"info","snippet":"        bool ok = rec != address(0) && rec == SIGNER && a.agreed >= a.quorum && block.timestamp <= a.expiresAt;","title":"Trust assumptions on Intake's owner: callbackGas is owner-settable and the registry needs 113k-150k gas per delivery; a signer rotation keeps ask selling while every answer records Unverified"}],"hash":"eef59d584c9b16d7731f894f83f46fd3570e81b678a5f4e62eb302d36b8e3d52","nodeId":"c617c8d2-5cbd-441e-8100-5a93fa02774f","outcome":"completed","summary":"Review complete. The findings file is at the repository root and nothing outside `test/scratch/` and `.imd-findings.json` was touched.\n\n**Outcome.** No critical, high or medium defects in the economic, invariant or flow-gap area. The contract holds no value, pulls exactly Intake's price, and its callback fits the stipend. Three low findings and two informational notes are recorded, all with concrete reproductions.\n\n**Findings written**\n\n1. **Low, settled ask accepts a second delivery.** `onOracleResult` never checks the ask is still Pending. The once-only guarantee is borrowed from Intake, but the deployed Intake v2 validates the request id in the callback arguments only for failures. On a result it forwards whatever the writer encodes. A second Intake request completed with arguments naming an already-answered registry id overwrites the stored answer in place while `latestFor` keeps pointing at it. The index then serves an unverified record as canonical. Trigger is the trusted writer, hence low. A self-contained proof test is attached and fails on the current code.\n2. **Low, no price bound in `ask`.** The function pulls whatever Intake prices at execution. A wallet with a standing allowance (the bot pattern, and the suite's own helper) is charged the new amount after an Intake `setPrice` with no revert or signal. Reproduced with a settable-price mock.\n3. **Low, no floor for the first indexed answer.** A 2-of-2 panel bought for 0.5 IMD becomes the verified, current, canonical answer for a fresh question hash for up to 30 days until someone pays for a larger panel. Documented in the README as the reader's job, so low.\n4. **Info, question hash binds the resolved window.** Per the oracle reference, relative-window asks get a different hash each time, so the displacement rule and the free-tier lookup mostly never collide. A design note for the author, not a contract defect.\n5. **Info, Intake-owner trust assumptions.** Minimum delivery gas measured at 113k to 150k across answer lengths up to the 4096-byte cap, under the 200k stipend that Intake's owner can change. A signer rotation keeps `ask` selling while every answer records Unverified.\n\n**Leads closed.** Slither's reentrancy lines are benign: IMD is a plain OpenZeppelin OFT token with no hooks, and Intake makes no call back during a request. The ecrecover lead is covered by the high-s and zero-address checks. The earlier 248k gas reading was test-frame overhead. An exact-gas binary search showed a 50k margin at the cap.\n\n**Coverage.** All three entry points have rows, plus six invariant and lead rows. `ask` and `onOracleResult` point at findings 2 and 1. `onFailure` holds.","treeHash":null,"usage":{"cachedInputTokens":2295546,"inputTokens":642,"model":"claude-fable-5-1","outputTokens":49321,"runtime":"claude","turns":40,"wallClockMs":778030}}],"verification":[{"checks":[{"durationMs":10548,"exitCode":0,"name":"build","output":"Compiling 26 files with Solc 0.8.26\nSolc 0.8.26 finished in 10.44s\nCompiler run successful with warnings:\nWarning (2018): Function state mutability can be restricted to view\n   --> test/AskSwarmRegistry.t.sol:504:5:\n    |\n504 |     function _delivered() internal returns (bool d) {\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\n","passed":true},{"durationMs":136,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 4 tests for test/OracleConsumerConformance.t.sol:OracleConsumerConformanceTest\n[PASS] test_acceptsAFreshSignatureFromTheVectorKey() (gas: 413742)\n[PASS] test_acceptsTheProtocolSignatureAsDelivered() (gas: 426163)\n[PASS] test_callbackSelectorIsCanonical() (gas: 691)\n[PASS] test_digestMatchesTheProtocol() (gas: 10377)\nSuite result: ok. 4 passed; 0 failed; 0 skipped; finished in 8.44ms (8.23ms CPU time)\n\nRan 19 tests for test/AskSwarmRegistry.t.sol:AskSwarmRegistryTest\n[PASS] testFuzz_indexedAnswersAreMajorities(uint16,uint16,uint16,uint32,uint32) (runs: 256, μ: 428184, ~: 424317)\n[PASS] test_answerDeliveredWithinStipendAndVerified() (gas: 475844)\n[PASS] test_askPullsPriceAndRegisters() (gas: 292042)\n[PASS] test_badSignatureStoredUnverifiedAndNotIndexed() (gas: 435252)\n[PASS] test_callbackGasMargin() (gas: 725103)\nLogs:\n  onOracleResult gas, bytes32 answer: 145755\n  onOracleResult gas, 352-byte answer: 133380\n\n[PASS] test_constructorMakesNoCallsAndAskLeavesNoAllowance() (gas: 2129460)\n[PASS] test_digestMatchesPlaneSignature() (gas: 14952)\n[PASS] test_domainIsTheCallbackContract() (gas: 397163)\n[PASS] test_expiredAtDeliveryAndAnswerBoundaries() (gas: 2938624)\n[PASS] test_expiryBoundaryAtDelivery() (gas: 446482)\n[PASS] test_failureHookStatusesAndUnknownIds() (gas: 386251)\n[PASS] test_failureRecorded() (gas: 318965)\n[PASS] test_largeAnswerStillWithinStipend() (gas: 512078)\n[PASS] test_minorityAnswerNotIndexedEvenWhenNothingHeld() (gas: 775953)\n[PASS] test_minorityOfLargePanelCannotDisplaceSupermajority() (gas: 1149172)\n[PASS] test_onlyIntakeCanDeliver() (gas: 301552)\n[PASS] test_quorumAndExpiryGateVerified() (gas: 392358)\n[PASS] test_signatureEdges() (gas: 1275200)\n[PASS] test_weakPanelCannotOverwriteStrongAnswer() (gas: 1121899)\nSuite result: ok. 19 passed; 0 failed; 0 skipped; finished in 35.66ms (88.92ms CPU time)\n\nRan 2 test suites in 36.69ms (44.10ms CPU time): 23 tests passed, 0 failed, 0 skipped (23 total tests)\n","passed":true},{"durationMs":43,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"AskSwarmRegistry.ask(bytes)\",\"AskSwarmRegistry.onFailure(bytes32,uint8,bytes32,uint16,uint16,bytes)\",\"AskSwarmRegistry.onOracleResult(bytes32,(bytes32,uint256,bytes32,uint8,bytes,uint256,uint64,uint64,bytes32,bytes32,uint16,uint16,uint16,uint64,uint64),bytes)\"],\"files\":{\".gitignore\":4,\"ADAPTATION.md\":98,\"LAUNCH.md\":7,\"README.md\":104,\"foundry.toml\":11,\"launch.json\":14,\"script/Deploy.s.sol\":23,\"src/AskSwarmRegistry.sol\":383,\"src/IIntake.sol\":55,\"test/AskSwarmRegistry.t.sol\":534,\"test/OracleConsumerConformance.t.sol\":104,\"test/mocks/IntakeMocks.sol\":100},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"156dd9f78e3ac6d29da4398de5a1b60f66e14667f0cef2b2029de90970d1996d","verifiedTreeHash":"16be77b0f95ac2230bfe831bee7c75cfe6868bf9","verifierVersion":"0.1.0+c3fba9eb"},{"checks":[{"durationMs":18564,"exitCode":0,"name":"build","output":"Compiling 26 files with Solc 0.8.26\nSolc 0.8.26 finished in 18.42s\nCompiler run successful with warnings:\nWarning (2018): Function state mutability can be restricted to view\n   --> test/AskSwarmRegistry.t.sol:504:5:\n    |\n504 |     function _delivered() internal returns (bool d) {\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\n","passed":true},{"durationMs":188,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 4 tests for test/OracleConsumerConformance.t.sol:OracleConsumerConformanceTest\n[PASS] test_acceptsAFreshSignatureFromTheVectorKey() (gas: 413742)\n[PASS] test_acceptsTheProtocolSignatureAsDelivered() (gas: 426163)\n[PASS] test_callbackSelectorIsCanonical() (gas: 691)\n[PASS] test_digestMatchesTheProtocol() (gas: 10377)\nSuite result: ok. 4 passed; 0 failed; 0 skipped; finished in 4.15ms (2.28ms CPU time)\n\nRan 19 tests for test/AskSwarmRegistry.t.sol:AskSwarmRegistryTest\n[PASS] testFuzz_indexedAnswersAreMajorities(uint16,uint16,uint16,uint32,uint32) (runs: 256, μ: 428184, ~: 424317)\n[PASS] test_answerDeliveredWithinStipendAndVerified() (gas: 475844)\n[PASS] test_askPullsPriceAndRegisters() (gas: 292042)\n[PASS] test_badSignatureStoredUnverifiedAndNotIndexed() (gas: 435252)\n[PASS] test_callbackGasMargin() (gas: 725103)\nLogs:\n  onOracleResult gas, bytes32 answer: 145755\n  onOracleResult gas, 352-byte answer: 133380\n\n[PASS] test_constructorMakesNoCallsAndAskLeavesNoAllowance() (gas: 2129460)\n[PASS] test_digestMatchesPlaneSignature() (gas: 14952)\n[PASS] test_domainIsTheCallbackContract() (gas: 397163)\n[PASS] test_expiredAtDeliveryAndAnswerBoundaries() (gas: 2938624)\n[PASS] test_expiryBoundaryAtDelivery() (gas: 446482)\n[PASS] test_failureHookStatusesAndUnknownIds() (gas: 386251)\n[PASS] test_failureRecorded() (gas: 318965)\n[PASS] test_largeAnswerStillWithinStipend() (gas: 512078)\n[PASS] test_minorityAnswerNotIndexedEvenWhenNothingHeld() (gas: 775953)\n[PASS] test_minorityOfLargePanelCannotDisplaceSupermajority() (gas: 1149172)\n[PASS] test_onlyIntakeCanDeliver() (gas: 301552)\n[PASS] test_quorumAndExpiryGateVerified() (gas: 392358)\n[PASS] test_signatureEdges() (gas: 1275200)\n[PASS] test_weakPanelCannotOverwriteStrongAnswer() (gas: 1121899)\nSuite result: ok. 19 passed; 0 failed; 0 skipped; finished in 29.88ms (64.30ms CPU time)\n\nRan 2 test suites in 31.10ms (34.03ms CPU time): 23 tests passed, 0 failed, 0 skipped (23 total tests)\n","passed":true},{"durationMs":100,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"AskSwarmRegistry.ask(bytes)\",\"AskSwarmRegistry.onFailure(bytes32,uint8,bytes32,uint16,uint16,bytes)\",\"AskSwarmRegistry.onOracleResult(bytes32,(bytes32,uint256,bytes32,uint8,bytes,uint256,uint64,uint64,bytes32,bytes32,uint16,uint16,uint16,uint64,uint64),bytes)\"],\"files\":{\".gitignore\":4,\"ADAPTATION.md\":98,\"LAUNCH.md\":7,\"README.md\":104,\"foundry.toml\":11,\"script/Deploy.s.sol\":23,\"src/AskSwarmRegistry.sol\":383,\"src/IIntake.sol\":55,\"test/AskSwarmRegistry.t.sol\":534,\"test/OracleConsumerConformance.t.sol\":104,\"test/mocks/IntakeMocks.sol\":100},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true},{"durationMs":1764,"exitCode":0,"name":"slither","output":"[low/medium] reentrancy-benign at src/AskSwarmRegistry.sol:161: Reentrancy in AskSwarmRegistry.ask(bytes) (src/AskSwarmRegistry.sol#161-173):\n[low/medium] reentrancy-events at src/AskSwarmRegistry.sol:161: Reentrancy in AskSwarmRegistry.ask(bytes) (src/AskSwarmRegistry.sol#161-173):\n[low/medium] timestamp at src/AskSwarmRegistry.sol:181: AskSwarmRegistry.onOracleResult(bytes32,OracleAttestation,bytes) (src/AskSwarmRegistry.sol#181-243) uses timestamp for comparisons\n[low/medium] timestamp at src/AskSwarmRegistry.sol:284: AskSwarmRegistry.isCurrent(bytes32) (src/AskSwarmRegistry.sol#284-287) uses timestamp for comparisons\n[low/medium] timestamp at src/AskSwarmRegistry.sol:272: AskSwarmRegistry.answerOf(bytes32,uint16,uint16) (src/AskSwarmRegistry.sol#272-281) uses timestamp for comparisons","passed":true},{"durationMs":379,"exitCode":0,"name":"aderyn","output":"[high] reentrancy-state-change at src/AskSwarmRegistry.sol:167: Reentrancy: State change after external call\n[low] ecrecover at src/AskSwarmRegistry.sol:367: `ecrecover` Signature Malleability\n[low] literal-instead-of-constant at src/AskSwarmRegistry.sol:207: Literal Instead of Constant (7 places)\n[low] push-zero-opcode at src/AskSwarmRegistry.sol:2: PUSH0 Opcode (2 places)\n[low] unspecific-solidity-pragma at src/AskSwarmRegistry.sol:2: Unspecific Solidity Pragma (2 places)","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"3b092ade317094b0d2868f202ec5a22560a9fad746a4f7f72fe15af367253dfa","verifiedTreeHash":"965c055f3fd9e3f01ff9915ff16cb622449c9d8f","verifierVersion":"0.1.0+c3fba9eb"},{"checks":[{"durationMs":25752,"exitCode":0,"name":"build","output":"Compiling 30 files with Solc 0.8.26\nSolc 0.8.26 finished in 25.66s\nCompiler run successful with warnings:\nWarning (2018): Function state mutability can be restricted to view\n   --> test/AskSwarmRegistry.t.sol:504:5:\n    |\n504 |     function _delivered() internal returns (bool d) {\n    |     ^ (Relevant source part starts here and spans across multiple lines).\n\n","passed":true},{"durationMs":14618,"exitCode":0,"name":"test","output":"No files changed, compilation skipped\n\nRan 4 tests for test/OracleConsumerConformance.t.sol:OracleConsumerConformanceTest\n[PASS] test_acceptsAFreshSignatureFromTheVectorKey() (gas: 413742)\n[PASS] test_acceptsTheProtocolSignatureAsDelivered() (gas: 426163)\n[PASS] test_callbackSelectorIsCanonical() (gas: 691)\n[PASS] test_digestMatchesTheProtocol() (gas: 10377)\nSuite result: ok. 4 passed; 0 failed; 0 skipped; finished in 74.96ms (18.10ms CPU time)\n\nRan 19 tests for test/AskSwarmRegistry.t.sol:AskSwarmRegistryTest\n[PASS] testFuzz_indexedAnswersAreMajorities(uint16,uint16,uint16,uint32,uint32) (runs: 256, μ: 427582, ~: 424317)\n[PASS] test_answerDeliveredWithinStipendAndVerified() (gas: 475844)\n[PASS] test_askPullsPriceAndRegisters() (gas: 292042)\n[PASS] test_badSignatureStoredUnverifiedAndNotIndexed() (gas: 435252)\n[PASS] test_callbackGasMargin() (gas: 725103)\nLogs:\n  onOracleResult gas, bytes32 answer: 145755\n  onOracleResult gas, 352-byte answer: 133380\n\n[PASS] test_constructorMakesNoCallsAndAskLeavesNoAllowance() (gas: 2129460)\n[PASS] test_digestMatchesPlaneSignature() (gas: 14952)\n[PASS] test_domainIsTheCallbackContract() (gas: 397163)\n[PASS] test_expiredAtDeliveryAndAnswerBoundaries() (gas: 2938624)\n[PASS] test_expiryBoundaryAtDelivery() (gas: 446482)\n[PASS] test_failureHookStatusesAndUnknownIds() (gas: 386251)\n[PASS] test_failureRecorded() (gas: 318965)\n[PASS] test_largeAnswerStillWithinStipend() (gas: 512078)\n[PASS] test_minorityAnswerNotIndexedEvenWhenNothingHeld() (gas: 775953)\n[PASS] test_minorityOfLargePanelCannotDisplaceSupermajority() (gas: 1149172)\n[PASS] test_onlyIntakeCanDeliver() (gas: 301552)\n[PASS] test_quorumAndExpiryGateVerified() (gas: 392358)\n[PASS] test_signatureEdges() (gas: 1275200)\n[PASS] test_weakPanelCannotOverwriteStrongAnswer() (gas: 1121899)\nSuite result: ok. 19 passed; 0 failed; 0 skipped; finished in 184.37ms (192.20ms CPU time)\n\nRan 19 tests for test/AskSwarmRegistryPayments.t.sol:AskSwarmRegistryPaymentsTest\n[PASS] testFuzz_badTokenReturnRollsBackBothPaymentLegs(uint8,bool) (runs: 1000, μ: 213384, ~: 224242)\n[PASS] testFuzz_exactPriceIsForwardedAndBodyIsUnchanged(uint128,bytes) (runs: 1000, μ: 569602, ~: 576453)\n[PASS] test_askDoesNotSpendPreexistingDonation() (gas: 399167)\n[PASS] test_constructorRejectsEveryZeroDependency() (gas: 7544)\n[PASS] test_donationCannotSubsidizeAnUnfundedAsk() (gas: 154509)\n[PASS] test_fullCallerBalanceAndMaximumBody() (gas: 2954334)\n[PASS] test_insufficientBalanceRollsBackConsumedAllowance() (gas: 195211)\n[PASS] test_intakeBodyBoundsRollBackThePayment() (gas: 438943)\n[PASS] test_noApprovalAndOneWeiShortApprovalBothRevert() (gas: 327469)\n[PASS] test_noReturnTokenIsSupportedOnTransferAndApproval() (gas: 394360)\n[PASS] test_notSoldDoesNotChargeOrRegister() (gas: 145610)\n[PASS] test_oneWeiPrice() (gas: 384367)\n[PASS] test_plainEthAndPayableAskAreRejectedWithoutTakingFunds() (gas: 147694)\n[PASS] test_priceChangesAreReadOnEveryAsk() (gas: 561331)\n[PASS] test_priceIncreaseBeyondApprovalCannotChargeOldQuote() (gas: 189383)\n[PASS] test_priceLookupFailureDoesNotChargeOrRegister() (gas: 165473)\n[PASS] test_rejectionAfterIntakePullRollsBackRecipientPayment() (gas: 345300)\n[PASS] test_rejectionBeforeIntakePullRollsBackCallerPayment() (gas: 229858)\n[PASS] test_tokenRejectsOnlyIntakePullAndEntireAskRollsBack() (gas: 319320)\nSuite result: ok. 19 passed; 0 failed; 0 skipped; finished in 240.47ms (337.54ms CPU time)\n\nRan 21 tests for test/AskSwarmRegistryCallbacks.t.sol:AskSwarmRegistryCallbacksTest\n[PASS] testFuzz_longAnswerHashAndEventPreserveEveryByte(uint16,bytes32) (runs: 1000, μ: 824706, ~: 679710)\n[PASS] testFuzz_non65ByteSignaturesNeverVerify(uint16,bytes32) (runs: 1000, μ: 503644, ~: 491628)\n[PASS] testFuzz_shortAnswerRoundTripsIncludingZeros(bytes32,uint8) (runs: 1000, μ: 514641, ~: 520375)\n[PASS] test_bothFailureStatusesPreserveFeeAndEmitReasonAndCounts() (gas: 1042397)\n[PASS] test_bothPanelSizeAndAgreementMustBeAtLeastAsStrong() (gas: 1875969)\n[PASS] test_eachOfTheFifteenFieldsIsBoundByTheSignature() (gas: 5573202)\n[PASS] test_falseIsAVerifiedAnswerAndNotAFailure() (gas: 501442)\n[PASS] test_firstByteOverCapIsUnverifiedAndDoesNotDisplaceHeldAnswer() (gas: 800164)\n[PASS] test_intakeAndOracleRequestIdsMayDiffer() (gas: 523595)\n[PASS] test_invalidVAndZeroRecoveryAreRejected() (gas: 2233574)\n[PASS] test_maximumAcceptedAnswerFitsColdCallbackStipend() (gas: 1149494)\n[PASS] test_nonzeroAbiPaddingCannotLeakIntoShortAnswer() (gas: 506576)\n[PASS] test_registeredCallbackWithForeignRequestIdCannotCreateAnAsk() (gas: 93017)\n[PASS] test_signatureForAnotherConsumerIsNotReusable() (gas: 499245)\n[PASS] test_thirdPartyCannotCompleteOrCallEitherHook() (gas: 450516)\n[PASS] test_timeAndMinimumFiltersDoNotDeleteHistoricalRecord() (gas: 600971)\n[PASS] test_transportWillNotRetrySuccessOrFailure() (gas: 866615)\n[PASS] test_unknownReadsDoNotLookAnswered() (gas: 52628)\n[PASS] test_weakerMajorityCanReplaceOnlyAfterHeldAnswerExpires() (gas: 1209100)\n[PASS] test_wrongDomainChainNameAndVersionAreRejected() (gas: 1162239)\n[PASS] test_wrongSignerCannotReplaceHeldAnswer() (gas: 848884)\nSuite result: ok. 21 passed; 0 failed; 0 skipped; finished in 243.07ms (774.48ms CPU time)\n\nRan 1 test for test/AskSwarmRegistryInvariant.t.sol:AskSwarmRegistryInvariantTest\n[PASS]\nAskSwarmRegistryInvariantTest invariants:\n[PASS] invariant_paymentsAreConservedAndNoFeeIsRetained\n[PASS] invariant_requestsAndCanonicalAnswersStayConsistent\n AskSwarmRegistryInvariantTest invariants (runs: 256, calls: 24576, reverts: 0)\n\n╭-------------------------+-----------------------+-------+---------+----------╮\n| Contract                | Selector              | Calls | Reverts | Discards |\n+==============================================================================+\n| RegistrySequenceHandler | advanceTime           | 3078  | 0       | 0        |\n|-------------------------+-----------------------+-------+---------+----------|\n| RegistrySequenceHandler | answer                | 3020  | 0       | 0        |\n|-------------------------+-----------------------+-------+---------+----------|\n| RegistrySequenceHandler | ask                   | 3153  | 0       | 0        |\n|-------------------------+-----------------------+-------+---------+----------|\n| RegistrySequenceHandler | donate                | 3120  | 0       | 0        |\n|-------------------------+-----------------------+-------+---------+----------|\n| RegistrySequenceHandler | failingAsk            | 3015  | 0       | 0        |\n|-------------------------+-----------------------+-------+---------+----------|\n| RegistrySequenceHandler | refuse                | 3144  | 0       | 0        |\n|-------------------------+-----------------------+-------+---------+----------|\n| RegistrySequenceHandler | retryCompletedRequest | 3010  | 0       | 0        |\n|-------------------------+-----------------------+-------+---------+----------|\n| RegistrySequenceHandler | unauthorizedCallback  | 3036  | 0       | 0        |\n╰-------------------------+-----------------------+-------+---------+----------╯\n\nSuite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.47s (14.46s CPU time)\n\nRan 5 test suites in 14.47s (15.21s CPU time): 64 tests passed, 0 failed, 0 skipped (64 total tests)\n","passed":true},{"durationMs":61,"exitCode":0,"name":"source-index","output":"{\"v\":1,\"entryPoints\":[\"AskSwarmRegistry.ask(bytes)\",\"AskSwarmRegistry.onFailure(bytes32,uint8,bytes32,uint16,uint16,bytes)\",\"AskSwarmRegistry.onOracleResult(bytes32,(bytes32,uint256,bytes32,uint8,bytes,uint256,uint64,uint64,bytes32,bytes32,uint16,uint16,uint16,uint64,uint64),bytes)\"],\"files\":{\".gitignore\":4,\"ADAPTATION.md\":98,\"LAUNCH.md\":7,\"README.md\":104,\"foundry.toml\":11,\"script/Deploy.s.sol\":23,\"src/AskSwarmRegistry.sol\":383,\"src/IIntake.sol\":55,\"test/AskSwarmRegistry.t.sol\":534,\"test/AskSwarmRegistryCallbacks.t.sol\":419,\"test/AskSwarmRegistryInvariant.t.sol\":356,\"test/AskSwarmRegistryPayments.t.sol\":261,\"test/OracleConsumerConformance.t.sol\":104,\"test/TESTING.md\":48,\"test/helpers/RegistryTestSupport.sol\":283,\"test/mocks/IntakeMocks.sol\":100},\"excluded\":[\"lib/\",\"node_modules/\"],\"truncated\":false}","passed":true}],"detail":"all checks passed","evaluation":"checks","profile":"foundry","status":"accepted","submissionHash":"78bce931021526182d4dc22fe641615bfc1eaf4ac3bf90a9e4572dcd8fe22a9f","verifiedTreeHash":"77080e6a18974b3a848d95879010ed9cf1d5cbe6","verifierVersion":"0.1.0+c3fba9eb"}]}