{"assessments":[],"deployments":[],"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":"8b53e808-0b20-4f7f-a342-bccb6bd261ae","kind":"audit","nodes":[{"acceptedSubmissionHash":"6e1a0eb92b90802c28a6b843d3581a7296932ee15e4ec77c46782628af4753d4","dependsOn":[],"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":"5d12d652a10b89d495646dcc35983b20f162cf24013d182da7da76f131f59193","dependsOn":[],"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":"0fbbf3735af4d15bf8d4a0cdc9171dee6e8d460e5a5da2961a6287b0b8cc20bf","dependsOn":["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":"e5a839089e5b4ea80faf4e8c785ac5c3fd367a3994e8e8505b341b7f7452bf73","dependsOn":[],"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":"044b8e5b78c9120eb80658a6cc4439db41685658211746fe14da95cca84f3cfd","dependsOn":[],"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"}],"objective":"Review exact changed runtime 12da8a671d2b5c33dc34d3f4a634412f6da5d7c2 against 12ddbe0b12380f1260f227318b06a0d03948d386. The preceding review found unbounded identical-input Etherscan retries, stale failure diagnostics after a new accepted GUID, repeated Sourcify uploads on an already-verified 409, and pass responses retained as errors. This candidate fixes those findings. Etherscan has a durable three-submission cap including failed/uncertain POSTs, reserved before sending with row-revision CAS; legacy active attempts conservatively count as one. Original failed GUIDs remain. At exhaustion state stays pending and etherscanState is operator_action_required; read-only exact source checks can still complete an independently verified job. No counter reset or new payment/transaction is possible here. Sourcify 409 suppresses repeated upload only; exact creation/runtime match and Etherscan listing remain mandatory. Twelve focused cases cover both chains, concurrency, cap across HTTP/refused/terminal failures, later exact completion, stale errors, 409, and saved GUID recovery. Version and generated docs are synchronized. Scope is noncustodial readonly Ethereum/Sepolia, finite unchanged sponsor/gas limits. Evaluate the diff and immediate callers for concrete regressions. The prior full runtime gate and real chain acceptance remain applicable to unchanged code. No custody, token launch or public inference.\n The last specialist also reproduced pre-existing queue starvation. The same candidate now advances the existing per-kind scan cursor after a successful claim, wrapping once with no more than1,000 rows per claim. A waiting early-ID job cannot monopolize the lane; leases and original effect keys are unchanged. 59 focused checks pass and one optional external-Postgres case is skipped. These cover jobs, source verification, approvals, public review/admission, services and intake. Source0.12.7 was never deployed; migration of the legacy verifier sees at most one original submission per row.\n","parentJobId":null,"planHash":"8b10f3472e1fdad01f34f81c186fbf521e1785a4e7680e26b029aaaa58eca224","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"8b53e808-0b20-4f7f-a342-bccb6bd261ae","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51566","feedbackHash":"9ee2ce23060cc8544f50805284f036b4839522cbb0a7d5c7081e0a912ff52e9a","nodeKey":"audit_economics","submissionHash":"6e1a0eb92b90802c28a6b843d3581a7296932ee15e4ec77c46782628af4753d4","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52182","feedbackHash":"8d6c107a262719ef3d37cbc33383e8393da3c2f8c9f81480023d895ac7c2331a","nodeKey":"audit_flow","submissionHash":"5d12d652a10b89d495646dcc35983b20f162cf24013d182da7da76f131f59193","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51130","feedbackHash":"b79624e83e25fe8e872ff738f0cc7f5cb4a30ad43a570b79f771dfbdc506bbac","nodeKey":"audit_judge","submissionHash":"0fbbf3735af4d15bf8d4a0cdc9171dee6e8d460e5a5da2961a6287b0b8cc20bf","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52323","feedbackHash":"852e693b568898b4f5733f16454559a4a1fb050f2a7515453b624029c60ce032","nodeKey":"audit_math","submissionHash":"e5a839089e5b4ea80faf4e8c785ac5c3fd367a3994e8e8505b341b7f7452bf73","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51168","feedbackHash":"1aebee3311b297b217d097c5da47df77f5cb609f63bfc788bf96c415bbcb9e0b","nodeKey":"audit_permissions","submissionHash":"044b8e5b78c9120eb80658a6cc4439db41685658211746fe14da95cca84f3cfd","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"7247231cc602d33484bd22246011be06f5a8fd92867920f88f7dfa1ec2d55e55","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6208734cdf5317a1","findings":[{"citation":"resolved","description":"The reservation at line 62 is charged for every verifysourcecode round-trip, and line 72 then stores any status '0' reply as etherscanError without distinguishing a reply that evaluated the input (\"Unable to verify\") from one that never admitted it to the verification queue (\"Invalid API Key\", \"Max rate limit reached\", \"Missing Or invalid Action name\"). Counting an HTTP 5xx is justified because the submission may have been accepted; a status '0' body is Etherscan's explicit statement that no GUID was issued and nothing was queued, so charging it buys no protection against duplicate identical submissions. Because docs/testnet.md states no counter reset exists and etherscanState becomes operator_action_required, three operator-side faults (a rotated or revoked ETHERSCAN_API_KEY, or one shared key being rate limited by the deployment, execution, wallet and vault lanes together) permanently exhaust explorer verification for that address within three minutes, and repairing the key does not restore it. The read-only path can still complete the job only if someone verifies the contract on Etherscan by hand. This is an asymmetry between whose fault it is (operator configuration) and whose budget is burned (the project's single, unrecoverable explorer submission allowance). Minimal fix: do not increment etherscanSubmissionCount when the reply is a status '0' body whose result does not match the input-evaluation patterns already used at line 82 (e.g. /invalid api key|rate limit|invalid action/i), while keeping the 60-second cooldown and the error diagnostic; alternatively treat a getsourcecode reply with status '0' as 'explorer unavailable' and skip the submission branch for that step, since a submission after a rate-limited read is also what produces the \"Contract source code already verified\" refusal.","line":62,"path":"adapters/source-verification.mjs","reproduction":"Scratch node test run against this tree (store = openStore(':memory:'), the test fixture `input` from test/source-verification.test.mjs). Fetcher: Sourcify POST -> {verificationId:'source'}, Sourcify GET -> exact/exact; every Etherscan call while keyValid=false -> Response.json({status:'0',message:'NOTOK',result:'Invalid API Key'}). Call verifier.step(input) three times advancing now() by 90000 ms each. Actual: etherscanSubmissionCount=3, etherscanState='operator_action_required', etherscanError='Invalid API Key'. Then set keyValid=true (getsourcecode -> SourceCode '' and verifysourcecode -> {status:'1',result:'guid-1'}) and step three more times: posts to verifysourcecode remain 0 and etherscanState stays 'operator_action_required'. Expected: a reply that issued no GUID should not count toward the cap (or the operator should have a documented recovery), so the first step after the key repair submits once and etherscanGuid becomes 'guid-1'. The same sequence with result 'Max rate limit reached' behaves identically.","severity":"low","snippet":"        value.etherscanSubmissionCount++; value.etherscanAttemptAt = now();","title":"Etherscan replies that provably rejected the request (invalid key, rate limit) consume the durable, non-resettable three-submission cap"},{"citation":"resolved","description":"Worker A reserves a submission (count=1, save at line 63, row revision r+1) and then waits up to 25 s on the Etherscan POST. If a second step for the same address runs meanwhile (the job lease is 120 s while a step can spend 5 x 25 s on provider calls plus RPC time, so an executor instance re-claims after lease expiry; two overlapping cron ticks do the same), worker B reads the row at r+1, finds attemptAt within the 60 s cooldown, performs only read-only checks and commits its final save at line 100 (revision r+2). When A's POST returns status '1' with 'guid-1', A's save at line 73 fails the CAS at line 36 with verification_step_stale and the accepted GUID is never persisted: no etherscanGuid, no chain-verification-attempts row, count stays 1. Sixty seconds later the next step submits the identical input again (count 2) while guid-1 is still live on Etherscan, which is exactly the duplicate the cap was introduced to prevent, and if guid-1 later fails there is no retained evidence for it, contrary to the stated 'original failed GUIDs remain'. Under the old code this race only cost a retry; under the finite non-resettable cap it consumes one of three allowances per occurrence. Minimal fix: after a POST that returned a GUID, re-read the current row inside the save transaction and merge the GUID into it when the only divergence is B's read-only fields (etherscanSubmissionCount and etherscanAttemptAt unchanged), or have B skip its final save when it observes an in-flight reservation (attemptAt within the cooldown and no GUID) so the submitter's save stays current.","line":36,"path":"adapters/source-verification.mjs","reproduction":"Scratch node test on this tree with openStore(':memory:') and the fixture input from test/source-verification.test.mjs. Fetcher: Sourcify POST -> {verificationId:'source'}, Sourcify GET -> exact/exact, getsourcecode -> {status:'1',result:[{SourceCode:'',ContractName:'Example'}]}, verifysourcecode -> increments posts and, for the first call only, awaits a gate promise before returning {status:'1',result:'guid-1'}; checkverifystatus -> {status:'0',result:'Pending in queue'}. Start a = verifier.step(input) without awaiting; wait until the first POST is parked on the gate; run b = await verifier.step(input) (succeeds: etherscanSubmissionCount=1, no etherscanGuid); release the gate. Actual: a rejects with code 'verification_step_stale'; stored row has etherscanSubmissionCount=1 and no etherscanGuid; store.list('chain-verification-attempts') is empty. Advance now() by 61000 ms and step again: posts=2, etherscanSubmissionCount=2, etherscanGuid='guid-2'. Expected: after the race the row holds etherscanGuid='guid-1' (or an attempt record for it) and the next step polls checkverifystatus for guid-1 with posts still 1.","severity":"low","snippet":"          requireThat((current?.revision ?? 0) === (row?.revision ?? 0), 'verification_step_stale');","title":"A concurrent step during a slow verifysourcecode POST discards the accepted GUID, keeps the charged reservation and triggers a duplicate identical submission"}],"hash":"044b8e5b78c9120eb80658a6cc4439db41685658211746fe14da95cca84f3cfd","nodeId":"94c53163-2f6a-476b-b171-33f1934b5a37","outcome":"completed","summary":"The review is complete. Both findings are written to `.imd-findings.json` and anchored to verified lines, and the working tree holds no other changes.\n\n**Outcome.** The diff between the two commits is sound in its main claims. The Etherscan cap is reserved before the send under a revision check, failed GUIDs are retained as attempt rows, the 409 memo suppresses only re-upload, stale errors clear on a new GUID or a pending or pass status, and the jobs cursor now rotates fairly. I found two defects, both low severity, both reproduced with scratch tests run against the tree.\n\n**Finding 1: operator-side refusals burn the unrecoverable cap** at `adapters/source-verification.mjs:62`. A status zero body such as \"Invalid API Key\" or \"Max rate limit reached\" is Etherscan saying no GUID was issued, yet it still counts as one of the three durable submissions. Three such replies put the row in operator_action_required within three minutes, and repairing the key never restores it. The scratch run showed zero real submissions ever reached Etherscan after the key was fixed.\n\n**Finding 2: a race drops an accepted GUID** at `adapters/source-verification.mjs:36`. If a second step for the same address runs while the first is parked on a slow POST, the second step's final save makes the first step's post-submission save stale. The accepted GUID is discarded, no attempt row is written, the reservation stays charged, and the next step resubmits the identical input. This is reachable because the 120-second lease is shorter than the worst-case provider time inside one step.\n\n**What I checked and found clean.**\n- Sourcify's upstream source confirms a 409 means only \"already verified and not improvable\", while duplicate in-flight requests are a 429, so the memo cannot mask a transient failure.\n- The cursor rework passed randomized sweeps over 1,000 to 3,001 rows with a stale legacy cursor, and a four-job rotation case.\n- The full suite in a scratch copy with dependencies installed passed 433 tests with 10 skipped. Doc regeneration produced no drift from the committed docs, and the version is consistent across package, lockfile, interface and OpenAPI.\n- Every caller treats the new stale error as a waiting retry, not a terminal failure.\n\nNot covered: no live chain or explorer calls were made, and the Postgres store path was reasoned from source only.","treeHash":null,"usage":{"cachedInputTokens":2123934,"inputTokens":738,"model":"claude-fable-5-1","outputTokens":38852,"runtime":"claude","turns":41,"wallClockMs":606209}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"232b62e021f6f394","findings":[{"citation":"resolved","description":"Merged from audit_permissions #1, audit_economics #1, audit_math #1 and audit_flow #1 (same mechanism, same fix). The slot reserved at line 62 is never released, and line 72 records every status '0' reply the same way, whether Etherscan evaluated the source ('Unable to verify') or explicitly refused to admit the request ('Unable to locate ContractCode at 0x..' while the indexer has not seen the deployment, 'Max rate limit reached', 'Max daily rate limit reached', 'Invalid API Key'). Charging HTTP/transport failures is justified because the submission may have landed; a status '0' body is Etherscan's statement that no GUID was issued and nothing was queued, so charging it buys no protection against duplicate identical submissions. The immediate callers make this routine: chain-executor.mjs:135 and :175 (and wallet-executor.mjs:48, vault-executor.mjs:49) call verification.step as soon as the receipt has two confirmations, and src/chain-deployments/index.mjs:169-170 re-steps a 'verifying' job every 30 s, so with the 60 s cooldown the three POSTs land at t=0, t=90 s and t=180 s after confirmation. If Etherscan is still refusing at t=180 s (indexer lag, a shared-key rate limit across the deployment/execution/wallet/vault lanes, or a misrotated ETHERSCAN_API_KEY), etherscanSubmissionCount is 3, etherscanRetryExhausted is true, etherscanState is 'operator_action_required', and line 59 blocks every future submission of this immutable input: there is no reset and verification_input_changed pins the row to the address. The module stays 'verifying' (chain-executor.mjs:136), prepareRegistration requires module.state === 'verified' (index.mjs:89), the module id digest({projectId, caseId, kind}) (index.mjs:67) prevents redeploying the same reviewed case, and a project-register row stuck in 'verifying' blocks any other registration for that project via 'registration_in_progress' (index.mjs:103, :128). Only a manual Etherscan verification by the operator lets the read-only path complete the job. The previous runtime (12ddbe0, and deployed 0.12.x) simply retried these refusals after the cooldown until the explorer accepted, so this is a regression introduced by the candidate. Foundry's own verifier treats 'Unable to locate ContractCode at' as 'could not detect deployment' and retries it rather than counting it. Note the stranded row keeps being stepped every 30 s with one Sourcify GET and one getsourcecode call (pre-existing cadence), so exhaustion also costs quota without a backoff. Minimal fix that keeps the three-submission design: after the POST, when submission.status === '0' and the result matches a known non-admission (/unable to locate contractcode|rate limit|daily limit|invalid api key/i), decrement etherscanSubmissionCount (keep etherscanAttemptAt so the 60 s cooldown still applies) before the save at line 73; keep the charge for HTTP/transport failures, accepted GUIDs and genuine 'Unable to verify' refusals. Alternatively gate the POST until getsourcecode/eth_getCode shows the address is indexed, and/or make the cooldown exponential so three submissions span an hour rather than three minutes.","line":72,"path":"adapters/source-verification.mjs","reproduction":"Scratch Node test (test/scratch/judge.test.mjs, case A) on this tree with store = openStore(':memory:') and the fixture input from test/source-verification.test.mjs. Fetcher: Sourcify POST -> {verificationId:'source'}; Sourcify GET -> exact_match/exact_match; getsourcecode -> {status:'1',result:[{SourceCode:'',ContractName:'Example'}]}; verifysourcecode -> HTTP 200 {status:'0',message:'NOTOK',result:'Unable to locate ContractCode at 0x1111...1111'} (or 'Max rate limit reached', or 'Invalid API Key' with getsourcecode also returning that status '0' body) while ready=false, then {status:'1',result:'guid-N'} once ready=true; checkverifystatus -> 'Pending in queue'. Drive verifier.step(input) eight times with now() advancing 30 000 ms per call (the worker cadence), then set ready=true and step 20 more times 90 s apart. Expected: once the explorer admits the request the unchanged input is submitted once and etherscanGuid is set. Actual, all three refusal strings: POSTs at offsets 0 s, 90 s, 180 s; after step 8 etherscanSubmissionCount=3, etherscanState='operator_action_required', state='pending', etherscanError=<refusal>; after the explorer is ready posts stays 3, accepted=0, etherscanGuid undefined, etherscanState stays 'operator_action_required' on every later step. The specialist proof at .imd/reads/proofs/Proof_750a37901a22.t.sol is a Node test with the same fetcher (indexed at +181 s); run as test/scratch/proof-750a.test.mjs it fails on this code with 'the explorer accepted a submission once it indexed the contract' (accepted=0). Against the 12ddbe0 adapter the same fetcher yields a fourth POST and a GUID once indexed.","severity":"medium","snippet":"        else value.etherscanError = String(submission.result).slice(0, 500);","title":"Explicit Etherscan non-acceptance replies (not-yet-indexed, rate limit, invalid key) consume the durable three-submission cap, so a three-minute explorer hiccup after confirmation permanently strands "},{"citation":"resolved","description":"Merged from audit_permissions #2, audit_math #2 and audit_flow #2 (same mechanism, same fix). The reservation is a two-phase write: save() at line 63 bumps the row to revision r+1, the external POST runs for up to 25 s, and the result is persisted by a second save() at line 73 that line 36 only allows if the revision is still r+1. Every step ends with an unconditional save() at line 100, including a purely read-only step. So a second step for the same row that starts after the reservation and finishes before the POST returns (it sees etherscanAttemptAt within the 60 s cooldown and makes no POST) moves the row to r+2, and the first step's post-POST save throws verification_step_stale. The accepted GUID is never persisted: the row keeps etherscanSubmissionCount=1 with no etherscanGuid, no etherscanError and no chain-verification-attempts record. After the cooldown the next step reserves slot 2 and POSTs the identical source again while guid-1 is still live on Etherscan, which is exactly the duplicate the cap exists to prevent; if guid-1 later fails there is no retained evidence for it, contrary to 'original failed GUIDs remain'. Under 12ddbe0 this race only cost a retry (the losing save overwrote with the current revision); under the finite non-resettable cap each occurrence consumes one of three slots and pushes the row toward the operator_action_required state of the previous finding. Reachability: a step can spend five explorer calls of up to 25 s each plus RPC time, exceeding the 120 s lease in src/chain-deployments/index.mjs:149 (vaults/wallets likewise), after which the next per-minute cron tick (worker/chain-tick.mjs, schedule '* * * * *') on another instance re-claims the job and steps the same row; the repository's concurrency test starts all five steps simultaneously so losers fail at their first save and this interleaving is never exercised. Minimal fix: when the save after a successful POST would be stale, re-read the current row inside the transaction and merge the GUID/error into it when etherscanSubmissionCount and etherscanAttemptAt are unchanged (the reservation already guarantees no competing POST used this slot); or record the accepted GUID in a chain-verification-attempts row keyed by digest({id, guid}) before the CAS save; or have a step that observes an in-flight reservation (attemptAt within the cooldown, no GUID, no error) skip its final save.","line":36,"path":"adapters/source-verification.mjs","reproduction":"Scratch Node test (test/scratch/judge.test.mjs, case B) on this tree with openStore(':memory:') and the fixture input. Fetcher: Sourcify POST -> {verificationId:'source'}; Sourcify GET -> exact/exact; getsourcecode -> SourceCode ''; verifysourcecode -> increments posts and, for the first call only, resolves a 'postStarted' promise then awaits a gate before returning {status:'1',result:'guid-1'}; checkverifystatus -> 'Fail - Unable to verify'. Start slow = verifier.step(input) without awaiting; await postStarted; quick = await verifier.step(input) (returns etherscanSubmissionCount=1, etherscanGuid undefined); release the gate. Expected: slow persists etherscanGuid='guid-1' and the later poll records guid-1's failure in chain-verification-attempts with posts still 1. Actual: slow rejects with code 'verification_step_stale'; the stored chain-verifications row has etherscanSubmissionCount=1, etherscanGuid undefined, etherscanError undefined; chain-verification-attempts is empty. Advance now() by 90 000 ms and step again: posts=2, etherscanSubmissionCount=2, and chain-verification-attempts contains only ['guid-2']; guid-1 is never retained anywhere.","severity":"low","snippet":"          requireThat((current?.revision ?? 0) === (row?.revision ?? 0), 'verification_step_stale');","title":"A step that completes while another step's verifysourcecode POST is in flight makes the accepted GUID fail the revision CAS, so the GUID is dropped, the reserved slot is burned and the same input is r"},{"citation":"resolved","description":"Merged from audit_economics #3, audit_math #3 and audit_flow #3. The task premise 'migration of the legacy verifier sees at most one original submission per row' holds only for rows that obtained a GUID (the pre-12ddbe0 code at bce9c41 never deleted etherscanGuid). In bce9c41 the submission guard was `!etherscanVerified && !etherscanGuid && (attemptAt older than 60 s)`, and a status '0' reply only refreshed etherscanAttemptAt and etherscanError, so a legacy row whose POSTs were refused N times (rate limit, daily limit, 'Unable to verify', not-yet-indexed) was re-POSTed after every 60 s cooldown for as long as the job was retried and holds N real submissions with no GUID and no attempts rows. Line 32 migrates such a row as count 1 and admits two more identical submissions. Impact is bounded (at most two extra POSTs per legacy row, no payment or transaction), so informational; the stated migration invariant is inexact rather than unsafe. Fix: document that pre-upgrade refusals are not counted, or treat a legacy row with etherscanAttemptAt and etherscanError but no GUID as already at the cap if a conservative bound is wanted.","line":32,"path":"adapters/source-verification.mjs","reproduction":"Scratch Node test (test/scratch/judge.test.mjs, case C). Seed chain-verifications with the row the bce9c41 adapter leaves after repeated refusals: {id: digest({chainId:11155111,address}), address, chainId:11155111, inputHash (same digest formula as the adapter), state:'pending', sourcifyId:'x', etherscanAttemptAt:1700000000000, etherscanError:'Max rate limit reached'} with no etherscanGuid and no etherscanSubmissionCount. Step the current adapter with now()=1700000061000 and verifysourcecode still returning {status:'0',result:'Max rate limit reached'}. Expected under the stated premise that the cap bounds total identical submissions: a row that already exhausted the budget pre-upgrade makes no further POST. Actual: the first step migrates the count to 1, reserves and POSTs (count 2, posts 1); 90 s later a second POST is made (count 3, posts 2) before etherscanState becomes 'operator_action_required'. N legacy refusals plus two new POSTs in total.","severity":"info","snippet":"      value.etherscanSubmissionCount ??= value.etherscanGuid || value.etherscanAttemptAt ? 1 : 0;","title":"Legacy-row migration counts an unbounded history of refused Etherscan POSTs as one submission; the deployed verifier re-POSTed every 60 s whenever no GUID was returned"}],"hash":"0fbbf3735af4d15bf8d4a0cdc9171dee6e8d460e5a5da2961a6287b0b8cc20bf","nodeId":"30e9d1c3-99a7-4a0e-a95d-229b161921fb","outcome":"completed","summary":"Findings file written and validated. The three merged findings are at `.imd-findings.json`, each reproduced against this tree with a scratch Node test.\n\n**Outcome.** The eleven specialist findings collapse into three distinct mechanisms, all reproduced. One was dropped as pre-existing behavior in unchanged callers.\n\n- **Medium, `adapters/source-verification.mjs:72`.** Explicit Etherscan non-acceptance replies (\"Unable to locate ContractCode\" during indexer lag, \"Max rate limit reached\", \"Invalid API Key\") are charged against the permanent three-submission cap. With the worker re-stepping every 30 seconds and a 60-second cooldown, the three POSTs land at 0, 90 and 180 seconds after confirmation. If the explorer still refuses at 180 seconds the row becomes operator_action_required with zero accepted submissions and never submits again, even after the explorer would accept. The module cannot be redeployed for the same case and a stuck registration blocks the project. This is a regression from 12ddbe0, which retried these refusals. Merged from four specialists. The attached specialist proof is a Node test, not Foundry, so it is referenced rather than attached. It fails on this code as claimed.\n- **Low, `adapters/source-verification.mjs:36`.** A step that completes while another step's POST is in flight bumps the row revision, so the accepted GUID fails the CAS and is discarded. The slot stays spent, no attempt record exists, and the next step resubmits the identical input. Reachable when a step outruns the 120-second lease and the per-minute cron re-claims. Merged from three specialists.\n- **Info, `adapters/source-verification.mjs:32`.** Legacy rows with refused history and no GUID migrate as one submission, though the deployed verifier re-POSTed every 60 seconds without a GUID. Bounded to two extra POSTs per row. Merged from three specialists.\n\n**Dropped.** The claim that exhausted rows are polled every 30 seconds forever without backoff is true but describes unchanged caller cadence, and continued read-only polling is the stated design so manual verification can complete the job. I noted the quota cost inside the medium finding instead.\n\n**Checks run.** The verifier and jobs suites pass on this tree, as does the new cursor fairness test. The full suite could not run because third-party dependencies are not vendored in the tree. My own review of the jobs cursor change found no regression. Scratch tests were removed and the tree is clean.","treeHash":null,"usage":{"cachedInputTokens":1028733,"inputTokens":386,"model":"claude-fable-5-1","outputTokens":21524,"runtime":"claude","turns":25,"wallClockMs":323652}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d11ea2b5e05fa7a8","findings":[{"citation":"resolved","description":"The cap is reserved before the POST and never released, which is correct for uncertain outcomes (HTTP 5xx, timeout) because the explorer may have accepted the source. But an HTTP 200 reply with status '0' and a result such as 'Invalid API Key', 'Max rate limit reached' or 'Daily limit of 100 source code submissions reached' is a definitive statement that the source was never evaluated, and line 72 only records it as etherscanError while the slot stays consumed. The deployment worker retries a 'verifying' job every 30 s and the cooldown admits a POST every 60 s, so three consecutive refusals happen within about three minutes. After that etherscanRetryExhausted is true, line 59 blocks every further submission, and the design (docs/testnet.md, task statement) provides no counter reset. Because getsourcecode also fails under an invalid key, the same window hits every verification row stepped during it, so a brief operator key rotation mistake or a shared-key throttle converts all active module-deploy / project-register / vault / wallet verifications into permanent operator_action_required rows that must be verified by hand on Etherscan. Expected: a reply that proves no submission occurred (or at minimum the documented transient Etherscan refusals) should not spend a permanent slot; alternatively the budget needs an operator-visible, auditable reset. Fix: after the POST, when submission.status === '0' and the result matches a known non-submission (/invalid api key|rate limit|daily limit/i), decrement etherscanSubmissionCount (keeping etherscanAttemptAt for the cooldown) before the save at line 73; keep the reservation for HTTP/transport failures and content refusals.","line":62,"path":"adapters/source-verification.mjs","reproduction":"State: new row for address 0x1111...1111 on Sepolia, Sourcify POST 202 and GET exact/exact, worker key invalid (or throttled). Etherscan fetcher returns HTTP 200 {\"status\":\"0\",\"message\":\"NOTOK\",\"result\":\"Invalid API Key\"} for every action. Call step(input) seven times 30 s apart (t=100000 ms, +30 s each). Actual: after the 5th call etherscanSubmissionCount === 3, etherscanState === 'operator_action_required', etherscanError === 'Invalid API Key'. Then fix the key (getsourcecode returns status 1 with empty SourceCode, verifysourcecode would return a GUID) and call step ten more times 90 s apart: actual verifysourcecode POST count stays 0, etherscanState stays 'operator_action_required', state stays 'pending' forever. Expected: at least one real submission once the explorer accepts calls again, since no source was ever submitted. Demonstrated by test/scratch/cap-repro.test.mjs 'three minutes of an invalid/throttled key permanently exhausts the cap' (passes on this code, i.e. the behaviour is as described).","severity":"medium","snippet":"        value.etherscanSubmissionCount++; value.etherscanAttemptAt = now();","title":"Definitive non-submissions (invalid key, rate limit, daily limit) consume the permanent 3-slot Etherscan cap; a 3-minute key or throttle outage strands every in-flight verification in operator_action_"},{"citation":"resolved","description":"The step makes up to five 25 s explorer calls (Sourcify POST, Sourcify GET, getsourcecode, verifysourcecode, checkverifystatus), which can exceed the 120 s lease used by chain-deployments.runOne, so a second worker can re-claim the job and step the same chain-verifications row while the first worker's verifysourcecode POST is still in flight. The first worker reserved the slot (revision r+1) and then gets a GUID back, but the second worker's read-only step has already written revision r+2 (cooldown blocks its own submission), so the first worker's save at line 73 throws verification_step_stale and the accepted GUID is never persisted. The count stays correct (the submission really happened), but the GUID is lost: if that submission later fails at Etherscan no row is written to chain-verification-attempts, contradicting 'Original failed GUIDs remain', and if it is still queued the next step resubmits after the cooldown, spending a second slot on a duplicate of a live submission. The old code (12ddbe0) overwrote with the current revision and kept the GUID. Fix: on verification_step_stale after a successful POST, re-read the row and merge etherscanGuid into the current value under its revision (the reservation already guards the count), or persist the GUID through a dedicated attempt row keyed by digest({id, guid}) before the CAS save.","line":36,"path":"adapters/source-verification.mjs","reproduction":"State: same row stepped by two callers. Fetcher: Sourcify POST 202 / GET exact-exact; getsourcecode status 1 empty SourceCode; verifysourcecode returns {status:'1',result:'guid-N'} but the first call blocks until released; checkverifystatus returns 'Fail - Unable to verify'. Start step A; once its verifysourcecode call is in flight, run step B to completion (B returns etherscanSubmissionCount 1, no etherscanGuid). Release A's POST. Actual: A rejects with code verification_step_stale; stored row has no etherscanGuid; chain-verification-attempts is empty. Advance 61 s and step again: a second POST happens (posts === 2, count 2) and the attempts history contains only 'guid-2'; 'guid-1' is never retained. Expected: guid-1 persisted (or recorded as an attempt) so no duplicate submission and no missing history. Demonstrated by test/scratch/cap-repro.test.mjs second case.","severity":"low","snippet":"          requireThat((current?.revision ?? 0) === (row?.revision ?? 0), 'verification_step_stale');","title":"Revision CAS added to save() discards an accepted Etherscan GUID when a second step of the same row interleaves between the reservation save and the post-submission save; the GUID is then missing from"},{"citation":"resolved","description":"The task assumes the legacy verifier produced at most one submission per row. That holds only when a GUID was obtained (the pre-12ddbe0 code at bce9c41 never deleted a GUID). When the legacy POST came back with status '0' (no GUID: 'Unable to verify', 'Max rate limit reached', 'Daily limit ... reached'), bce9c41 set only etherscanAttemptAt and re-POSTed after every 60 s cooldown for as long as the job was retried, so a legacy row can carry dozens of real submissions with no GUID and no attempts rows. The migration assigns such a row etherscanSubmissionCount 1 and admits two more submissions, so the 'durable three-submission cap' is not durable across the upgrade for exactly the rows the cap was introduced for. Impact is bounded (two extra POSTs per legacy row, then capped). Fix: treat a legacy row with etherscanAttemptAt and etherscanError but no GUID as count 3 (or 2) if a conservative bound is wanted, or document that pre-upgrade refusals are not counted.","line":32,"path":"adapters/source-verification.mjs","reproduction":"State: chain-verifications row written by the deployed verifier: {state:'pending', sourcifyId:'x', etherscanAttemptAt: T, etherscanError:'Max rate limit reached'} after N>3 refused POSTs (no etherscanGuid, no etherscanSubmissionCount, no attempts rows). Run the new step with an Etherscan fetcher that refuses again. Actual: line 32 sets etherscanSubmissionCount = 1, line 59 admits a POST now and another 60 s later (count reaches 3 only after two further real submissions). Expected per the stated design: a row that already exhausted the cap pre-upgrade makes no further submissions.","severity":"low","snippet":"      value.etherscanSubmissionCount ??= value.etherscanGuid || value.etherscanAttemptAt ? 1 : 0;","title":"Legacy-row migration counts an unbounded history of refused POSTs as one submission; the deployed verifier re-POSTed every 60 s when no GUID was returned"}],"hash":"5d12d652a10b89d495646dcc35983b20f162cf24013d182da7da76f131f59193","nodeId":"9188860e-0b97-4045-959c-5b47aa8c2dd2","outcome":"completed","summary":"The review is complete and the findings file is written and validated.\n\n**Scope reviewed:** the diff from commit 12ddbe0 to 12da8a6 (the Etherscan submission cap with revision CAS, Sourcify 409 handling, stale-error clearing, and the per-claim job-scan cursor), plus the immediate callers in chain-deployments, chain-executor, vault and wallet executors, and both store implementations. The 12 verification cases and 6 job cases pass locally. Regenerated docs match the committed ones. My own randomized scratch tests confirm the new cursor always reaches a claimable job within ceil(N/1000)+1 scans and rotates waiting jobs round-robin across a 2,200-row history, so the starvation fix holds.\n\n**Findings written to `.imd-findings.json`:**\n\n- **Medium, source-verification.mjs:62.** A definitive non-submission reply from Etherscan (HTTP 200 with \"Invalid API Key\", \"Max rate limit reached\", \"Daily limit reached\") still spends one of the three permanent slots. With the worker's 30 s retry and 60 s cooldown, three minutes of a bad key or throttle exhausts every in-flight verification, and the design offers no reset. Reproduced: after the key is repaired, zero further POSTs ever happen and the row stays `operator_action_required`.\n- **Low, source-verification.mjs:36.** The new revision CAS discards an accepted GUID when a second step of the same row interleaves between the reservation save and the post-POST save, which is reachable because five 25 s explorer calls can outlast the 120 s lease. The count stays right, but the GUID is never persisted or recorded in the attempts history, and a duplicate submission follows after the cooldown. Reproduced.\n- **Low, source-verification.mjs:32.** The legacy migration counts a row as one submission, but the deployed verifier re-POSTed every 60 s whenever no GUID came back, so a legacy row with repeated refusals can carry many real submissions and still gets two more.\n\n**Leads checked and rejected:** any-409 suppression of Sourcify uploads (Sourcify's `duplicate_verification_request` is a 429, and `already_verified` only fires on an exact match, so nothing is lost); JavaScript versus Postgres id ordering (ids are `sha256:` plus lowercase hex, so they agree); cursor CAS contention (transactions are serialized in both stores); the dead `etherscanRetryExhausted` guard at line 75 (harmless).\n\nTwo scratch files remain under `test/scratch/` as the reproductions; nothing else in the tree was changed.","treeHash":null,"usage":{"cachedInputTokens":1499275,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":43465,"runtime":"claude","turns":42,"wallClockMs":742899}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d0653dc91b6e2259","findings":[{"citation":"resolved","description":"The new cap counts every verifysourcecode POST, including ones Etherscan answers with HTTP 200 / status '0', i.e. replies in which the explorer explicitly states it did NOT accept a submission. Two such replies are routine for a freshly confirmed deployment: 'Unable to locate ContractCode at 0x..' (the explorer's indexer has not seen the contract yet; forge's own verifier special-cases and retries exactly this string) and 'Max rate limit reached' / 'Max daily rate limit reached' (shared per API key). The immediate callers re-step a 'verifying' job every 30 s (src/chain-deployments/index.mjs:169-170, vault/wallet executors likewise) and the adapter's cooldown is 60 s, so the three reserved submissions land at t=0, t=90 s and t=180 s after the transaction was confirmed (2 blocks on Sepolia). If the explorer is still refusing at t=180 s the row becomes etherscanRetryExhausted=true / etherscanState='operator_action_required' with zero accepted submissions and no code path can ever submit again (verification_input_changed pins the row to this address; there is no reset). Economic consequence on the unchanged callers: chain-executor returns state 'verifying' forever, chain-modules stays 'verifying', prepareRegistration requires module.state === 'verified' (src/chain-deployments/index.mjs:89) and the module id is digest({projectId, caseId, kind}) (line 67), so the same reviewed case cannot be redeployed; reserveProject and the sponsor's deploy gas are already spent. A project-register row stuck in 'verifying' additionally blocks every further registration for that project via 'registration_in_progress' (lines 103 and 128) and never writes the chain-binding. Before this candidate (12ddbe0 and the deployed 0.12.x) the same refusals simply retried after the 60 s cooldown until the explorer accepted, so this is a regression introduced by counting refused POSTs. The docs/testnet.md text 'at most three times, with a minimum 60-second interval' describes a 3-minute window, which is shorter than ordinary Sepolia/mainnet explorer indexing delay and far shorter than a daily-quota lockout. Suggested minimal fix that keeps the three-submission design: do not debit the counter for a status '0' POST whose result matches a known non-acceptance (/unable to locate contractcode|rate limit|daily limit/i) and/or make the cooldown exponential (60 s, 10 min, 60 min) so the three submissions span an hour rather than three minutes; keep counting HTTP/transport failures, which are genuinely uncertain.","line":59,"path":"adapters/source-verification.mjs","proof":"import test from 'node:test';\nimport assert from 'node:assert/strict';\nimport { openStore } from '../../src/storage/index.mjs';\nimport { createSourceVerification } from '../../adapters/source-verification.mjs';\nconst input = { address: '0x' + '11'.repeat(20), creationTransaction: '0x' + '22'.repeat(32), constructorArguments: '0x1234',\n  artifact: { entry: 'src/Example.sol', contract: 'Example', profile: { compiler: '0.8.27+commit.40a35a09.Emscripten.clang' }, standardInput: { language: 'Solidity', sources: {} } } };\ntest('explorer indexing lag of 181s must not permanently exhaust the cap', async t => {\n  const store = openStore(':memory:'); t.after(() => store.close());\n  let time = 100000; const indexedAt = time + 181000; let posts = 0, accepted = 0;\n  const verifier = createSourceVerification({ store, etherscanKey: 'k', now: () => time, fetcher: async (url, options = {}) => {\n    const parsed = new URL(url);\n    if (parsed.hostname === 'sourcify.dev') return Response.json(options.method === 'POST' ? { verificationId: 'source' } : { runtimeMatch: 'exact_match', creationMatch: 'exact_match' });\n    switch (parsed.searchParams.get('action')) {\n      case 'getsourcecode': return Response.json({ status: '1', result: [{ SourceCode: '', ContractName: 'Example' }] });\n      case 'verifysourcecode': posts++;\n        if (time < indexedAt) return Response.json({ status: '0', result: 'Unable to locate ContractCode at ' + input.address });\n        accepted++; return Response.json({ status: '1', result: 'guid-' + posts });\n      default: return Response.json({ status: '0', result: 'Pending in queue' });\n    }\n  } });\n  let result;\n  for (let i = 0; i < 40; i++) { result = await verifier.step(input); time += 30000; }\n  assert.ok(accepted >= 1, 'the explorer accepted a submission once it indexed the contract');\n  assert.notEqual(result.etherscanState, 'operator_action_required');\n});","reproduction":"State: new chain-verifications row (fresh deployment). Fetcher: Sourcify POST -> {verificationId}, Sourcify GET -> exact_match/exact_match, getsourcecode -> status '1' with SourceCode '' (not yet verified), verifysourcecode -> {status:'0', result:'Unable to locate ContractCode at 0x11..11'} while now() < confirmedAt+181000, afterwards {status:'1', result:'guid-N'} (explorer indexed). Drive verifier.step(input) every 30 000 ms as the worker does. Expected: once the explorer indexes the contract the unchanged input is submitted and accepted. Actual (run with test/scratch/repro.test.mjs): posts=3 at t=0/90/180 s, acceptedPosts=0, etherscanSubmissionCount=3, etherscanState='operator_action_required', state='pending'; ten further steps after the explorer is ready never POST again. Same outcome with every verifysourcecode reply = {status:'0', result:'Max rate limit reached'}: exhausted after 300 s of wall time (posts=3, etherscanError='Max rate limit reached'). The 181 s lag case passes the cap in the current code and would not be exhausted if non-acceptance replies were not debited.","severity":"medium","snippet":"      if (!value.etherscanVerified && !value.etherscanGuid && value.etherscanSubmissionCount < 3 &&\n          (!value.etherscanAttemptAt || value.etherscanAttemptAt < now() - 60000)) {\n        // Reserve before the external call, including uncertain/failed replies.\n        value.etherscanSubmissionCount++; value.etherscanAttemptAt = now();\n        await save();","title":"Explicitly refused Etherscan POSTs (indexer lag, rate limit) consume the durable 3-submission cap, so a ~3 minute explorer hiccup after deployment permanently strands the module/registration"},{"citation":"resolved","description":"The candidate introduces a terminal-until-operator state (etherscanState='operator_action_required') but the immediate callers do not distinguish it from an ordinary pending verification: chain-executor maps proof.state !== 'verified' to 'verifying' (adapters/chain-executor.mjs:136,177) and runOne schedules the next step 30 s later unconditionally. Each step performs one Sourcify GET and one Etherscan getsourcecode call (plus the chain reads in transact/getCode), i.e. 2 880 explorer calls per day per stranded row with no backoff and no cap on attempts. A single Etherscan key is shared by every verification on the worker; the free plan allows 100 000 calls/day. Roughly 35 stranded rows (or far fewer plus normal traffic) exceed the daily quota, after which getsourcecode and verifysourcecode answer status '0' 'Max daily rate limit reached' for every other contract; because such replies debit the 3-submission cap (previous finding), every new deployment confirmed during the lockout becomes operator_action_required within 3 minutes, which adds more 30 s pollers. Unprivileged amplifier: any project that can enqueue module-deploy/vault-deploy jobs grows the stranded set. Fix: when proof.etherscanState === 'operator_action_required' (or attempts exceed a bound) back off to e.g. 1 h, or move the row to a 'blocked' result that an operator action re-queues, instead of waiting 30 s.","line":169,"path":"src/chain-deployments/index.mjs","reproduction":"State: one chain-verifications row with etherscanSubmissionCount=3, no etherscanGuid, etherscanVerified=false (reachable via the previous reproduction), its module-deploy job in state waiting. Drive deployments.runOne(executor) with now() advancing: each call claims the job, executor.step -> verification.step performs 1 Sourcify GET + 1 Etherscan getsourcecode, and finish() writes state 'waiting' with retryAt = now()+30000 again. Expected: a row that the adapter itself reports as needing operator action is polled with a long backoff or parked. Actual: 2 explorer requests every 30 s indefinitely (2 880/day/row), attempts increments without bound, and the row never leaves 'verifying'.","severity":"low","snippet":"        return await jobs.finish(claim, { state: done ? 'complete' : blocked ? 'blocked' : failed ? 'failed' : 'waiting',\n          retryAt: done || blocked || failed ? null : now() + 30000, result }, async tx => {","title":"An exhausted (operator_action_required) verification is re-stepped every 30 s forever with two explorer calls per step, burning the shared Etherscan quota and feeding the cap exhaustion above"},{"citation":"resolved","description":"The migration assumes 'at most one original submission per row'. That holds for accepted submissions (the pre-12ddbe0 code never deleted etherscanGuid), but the deployed code (git show 12ddbe0^:adapters/source-verification.mjs) resubmitted whenever a POST returned status '0' (no GUID stored, only etherscanAttemptAt refreshed) after the 60 s cooldown, so a legacy row whose POSTs were refused N times holds etherscanAttemptAt and etherscanError with no GUID and N>1 real submissions. Such rows are migrated as count 1 and get two more submissions. The impact is bounded (at most two extra identical-input POSTs per legacy row, no payment or transaction), so this is informational, but the stated migration invariant is not exact.","line":32,"path":"adapters/source-verification.mjs","reproduction":"State: row written by the previous runtime {etherscanAttemptAt: T, etherscanError: 'Unable to locate ContractCode at 0x..', no etherscanGuid, no etherscanSubmissionCount} after three refused POSTs. Call step(input) with now() > T+60000 and the explorer still refusing. Expected per the stated invariant: the row is treated as already having consumed its budget proportionally. Actual: etherscanSubmissionCount becomes 1 then 2 after this step; two further POSTs are sent before exhaustion.","severity":"info","snippet":"      value.etherscanSubmissionCount ??= value.etherscanGuid || value.etherscanAttemptAt ? 1 : 0;","title":"Legacy-row migration undercounts: the deployed verifier re-POSTed after every refused (status '0') reply, so a legacy row may carry several real submissions but is migrated as one"}],"hash":"6e1a0eb92b90802c28a6b843d3581a7296932ee15e4ec77c46782628af4753d4","nodeId":"bac927c3-e1c1-4184-867d-5969f54f5e6a","outcome":"completed","summary":"Review complete. The findings file is written and validated, and the tree is unchanged apart from my scratch directory.\n\n**Outcome.** The candidate fixes the four prior findings as described, and the claim-cursor change holds up: a randomized property test over 2,600 rows with mixed terminal and live jobs claimed every available job in exactly one poll each, with no starvation at any cursor position. The economic regression is in how the new submission cap is debited.\n\n**Findings written to `.imd-findings.json`:**\n\n- **Medium, source-verification.mjs line 59.** Etherscan replies that explicitly refuse a POST (HTTP 200, status '0') are debited against the durable three-submission cap. With the callers' 30 s re-step and the 60 s cooldown, the three submissions land at 0, 90 and 180 seconds after confirmation. An explorer indexer lag or rate-limit window longer than three minutes exhausts the row with zero accepted submissions, and nothing can submit again. The module then stays 'verifying' forever, `prepareRegistration` refuses it, the deterministic module id blocks redeploying the same reviewed case, and sponsor gas is already spent. The deployed runtime retried these refusals until acceptance, so this is a regression. A node proof test that fails on the current code is attached.\n- **Low, chain-deployments/index.mjs line 169.** An exhausted verification is re-stepped every 30 s indefinitely with two explorer calls per step. Enough stranded rows exceed the shared daily Etherscan quota, after which every new verification is refused and exhausts via the finding above, a self-reinforcing cascade.\n- **Info, source-verification.mjs line 32.** The legacy migration counts a row as one submission, but the deployed verifier re-POSTed after every refused reply, so some legacy rows carry more than one real submission.\n\n**Verification.** The two changed test files pass. With dependencies installed temporarily, the full suite ran with 433 passed, 10 skipped, 0 failed. Sourcify's published spec confirms 409 means only `already_verified`, so that part of the change is sound. Dependencies and the proof copy were removed afterward.","treeHash":null,"usage":{"cachedInputTokens":1613022,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":40699,"runtime":"claude","turns":44,"wallClockMs":676043}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"3a720671678111bc","findings":[{"citation":"resolved","description":"Boundary x invariant seam. The cap reserves a slot for every POST and treats every non-'1' status uniformly as a used submission (line 72 stores the error, nothing distinguishes a terminal refusal from a transient one). Etherscan answers verifysourcecode with status '0' and result 'Unable to locate ContractCode at 0x...' whenever the address has not been indexed yet. The executors call verification.step immediately after the receipt is confirmed (adapters/chain-executor.mjs:135 after 2 blocks on Sepolia, wallet-executor.mjs:48, vault-executor.mjs:49) and re-run the job every 30 s while the result is 'verifying' (src/chain-deployments/index.mjs:169-170, src/wallets/index.mjs:155-156, src/vaults/index.mjs:90-91). With the 60 s cooldown the POST fires at t0, t0+90 s and t0+180 s; if Etherscan has not indexed the contract within 3 minutes of confirmation (a common lag during explorer backlog), etherscanSubmissionCount reaches 3, etherscanRetryExhausted becomes true and etherscanState becomes operator_action_required. No later step ever submits again even though the explorer would now accept the same immutable input, and the design states no counter reset exists, so the job sits in 'waiting' every 30 s until an operator verifies by hand on Etherscan. The pre-change adapter (12ddbe0) retried after the cooldown and recovered once the address was indexed; this candidate turns a routine transient into a durable dead end. Foundry's own verifier (crates/verify/src/etherscan/mod.rs:195) recognises exactly this reply as 'Could not detect deployment' and retries it instead of counting it as a failure. 'Max rate limit reached' on the POST is the same class. Fix: do not consume a reserved slot (decrement/undo the reservation, or skip the POST) when the refusal matches /unable to locate contractcode|not a smart.contract|rate limit/i, or gate the POST until the explorer reports code at the address (e.g. module=proxy&action=eth_getCode), keeping the cap for genuine 'Unable to verify' refusals, accepted GUIDs and HTTP failures.","line":62,"path":"adapters/source-verification.mjs","reproduction":"State: fresh chain-verifications row, Sourcify exact on both matches, Etherscan getsourcecode returns SourceCode '' (not verified), verifysourcecode returns {status:'0',message:'NOTOK',result:'Unable to locate ContractCode at 0x1111...1111'} until a flag `indexed` flips, then {status:'1',result:'guid-N'}. Drive step(input) every 30 s of simulated time for 4 minutes, then set indexed=true and step 4 more times.\nExpected (and observed with the 12ddbe0 adapter on the identical fetcher): posts=3 during the lag, then a 4th POST is made once indexed and etherscanGuid='guid-4'.\nActual (this candidate): posts=3, etherscanSubmissionCount=3, etherscanState='operator_action_required', etherscanError='Unable to locate ContractCode at 0x...'; after indexing posts stays 3, etherscanGuid undefined, state stays 'pending'/'operator_action_required' forever.\nNode test used (test/scratch, passes against this tree):\n```js\nimport test from 'node:test'; import assert from 'node:assert/strict';\nimport { openStore } from '../../src/storage/index.mjs';\nimport { createSourceVerification } from '../../adapters/source-verification.mjs';\nconst input = { address: '0x' + '11'.repeat(20), creationTransaction: '0x' + '22'.repeat(32), constructorArguments: '0x1234',\n  artifact: { entry: 'src/Example.sol', contract: 'Example', profile: { compiler: '0.8.27+commit.40a35a09.Emscripten.clang' }, standardInput: { language: 'Solidity', sources: {} } } };\ntest('indexing lag exhausts the cap', async t => {\n  const store = openStore(':memory:'); t.after(() => store.close());\n  let time = 100000, posts = 0, indexed = false;\n  const verifier = createSourceVerification({ store, etherscanKey: 'k', now: () => time, fetcher: async (url, options = {}) => {\n    const parsed = new URL(url);\n    if (parsed.hostname === 'sourcify.dev') return Response.json(options.method === 'POST' ? { verificationId: 'source' } : { runtimeMatch: 'exact_match', creationMatch: 'exact_match' });\n    switch (parsed.searchParams.get('action')) {\n      case 'getsourcecode': return Response.json({ status: '1', result: [{ SourceCode: '', ContractName: 'Example' }] });\n      case 'verifysourcecode': posts++; return indexed ? Response.json({ status: '1', result: 'guid-' + posts })\n        : Response.json({ status: '0', message: 'NOTOK', result: 'Unable to locate ContractCode at ' + input.address });\n      default: return Response.json({ status: '0', result: 'Pending in queue' });\n    } } });\n  let r; for (let i = 0; i < 8; i++) { r = await verifier.step(input); time += 30000; }\n  assert.equal(posts, 3); assert.equal(r.etherscanState, 'operator_action_required');\n  indexed = true; for (let i = 0; i < 4; i++) { r = await verifier.step(input); time += 30000; }\n  assert.equal(posts, 3); assert.equal(r.etherscanGuid, undefined); // expected: posts 4, guid 'guid-4'\n});\n```","severity":"medium","snippet":"        value.etherscanSubmissionCount++; value.etherscanAttemptAt = now();","title":"Explorer 'Unable to locate ContractCode' (not-yet-indexed) refusals permanently consume the three-submission cap; a deployment verified before Etherscan indexes it reaches operator_action_required wit"},{"citation":"resolved","description":"Boundary x invariant seam in the two-phase reservation. The slot is reserved with one CAS write (line 63), the external POST runs, then the result is persisted with a second CAS write (line 73) that requires the row revision to still equal the reservation's revision (line 36). Every step, including a purely read-only one, performs an unconditional final save at line 100, so any step that starts after the reservation and finishes before the POST returns bumps the revision. The second save then throws verification_step_stale and the accepted GUID is lost: the row keeps etherscanSubmissionCount=1 with no etherscanGuid, no etherscanError and no chain-verification-attempts record. After the cooldown the next step reserves slot 2 and POSTs the identical source again, so one of the three durable submissions is wasted and the design's 'original GUIDs remain' evidence is missing for the first submission. The authors' concurrency test starts all five steps simultaneously, so losers fail at their first save before the winner's POST completes and this interleaving is never exercised. The POST window is up to 25 s (AbortSignal.timeout) and overlapping steps are reachable: a second worker instance claiming the job after the 120 s lease expires mid-step (the step chain can spend 5 x 25 s on explorer timeouts), or integration/mainnet-release.mjs run by an operator while the chain worker ticks. Combined with the previous finding this pushes rows toward operator_action_required. Fix: when the post-POST save conflicts, re-read the current row and merge rather than fail: if current.etherscanSubmissionCount === value.etherscanSubmissionCount and !current.etherscanGuid, write current with the GUID/error applied (the reservation already guarantees no competing POST used this slot); alternatively make read-only steps skip the final save when nothing changed.","line":72,"path":"adapters/source-verification.mjs","reproduction":"State: fresh row, Sourcify exact, getsourcecode not verified, verifysourcecode returns {status:'1',result:'guid-1'} but only after an external gate is released. Start step A; wait until A's POST has been issued (count=1, attemptAt=now saved at revision 2). Start and finish step B with the same input while A's POST is pending: B sees the cooldown, makes no POST, and its final save moves the row to revision 3. Release the gate.\nExpected: A persists etherscanGuid='guid-1' (Etherscan accepted it and will process it); later polls track guid-1 and a failure leaves a chain-verification-attempts record.\nActual: A rejects with verification_step_stale; persisted row is {etherscanSubmissionCount:1} with no etherscanGuid and no etherscanError. After +90 s the next step reserves slot 2 and POSTs again (posts=2, etherscanGuid='guid-2'); chain-verification-attempts stays empty, so guid-1 is never recorded anywhere.\nNode test used (test/scratch, passes against this tree, i.e. demonstrates the loss):\n```js\nimport test from 'node:test'; import assert from 'node:assert/strict';\nimport { openStore } from '../../src/storage/index.mjs';\nimport { createSourceVerification } from '../../adapters/source-verification.mjs';\nconst input = { address: '0x' + '11'.repeat(20), creationTransaction: '0x' + '22'.repeat(32), constructorArguments: '0x1234',\n  artifact: { entry: 'src/Example.sol', contract: 'Example', profile: { compiler: '0.8.27+commit.40a35a09.Emscripten.clang' }, standardInput: { language: 'Solidity', sources: {} } } };\ntest('accepted GUID lost to an interleaved read-only step', async t => {\n  const store = openStore(':memory:'); t.after(() => store.close());\n  let time = 100000, posts = 0, releasePost, openGate;\n  const postStarted = new Promise(r => { releasePost = r; }), gate = new Promise(r => { openGate = r; });\n  const fetcher = async (url, options = {}) => {\n    const parsed = new URL(url);\n    if (parsed.hostname === 'sourcify.dev') return Response.json(options.method === 'POST' ? { verificationId: 'source' } : { runtimeMatch: 'exact_match', creationMatch: 'exact_match' });\n    switch (parsed.searchParams.get('action')) {\n      case 'getsourcecode': return Response.json({ status: '1', result: [{ SourceCode: '', ContractName: 'Example' }] });\n      case 'verifysourcecode': posts++; releasePost(); await gate; return Response.json({ status: '1', result: 'guid-' + posts });\n      default: return Response.json({ status: '0', result: 'Pending in queue' });\n    } };\n  const v = createSourceVerification({ store, etherscanKey: 'k', now: () => time, fetcher });\n  const slow = v.step(input); await postStarted;\n  const quick = await v.step(input); assert.equal(quick.etherscanSubmissionCount, 1); assert.equal(quick.etherscanGuid, undefined);\n  openGate(); await assert.rejects(slow, { code: 'verification_step_stale' });\n  const row = (await store.list('chain-verifications'))[0].value;\n  assert.equal(posts, 1); assert.equal(row.etherscanGuid, undefined); // expected: 'guid-1'\n  time += 90000; const next = await v.step(input);\n  assert.equal(posts, 2); assert.equal(next.etherscanSubmissionCount, 2); // expected: posts 1, count 1\n  assert.equal((await store.list('chain-verification-attempts')).length, 0);\n});\n```","severity":"medium","snippet":"        else value.etherscanError = String(submission.result).slice(0, 500);\n        await save();","title":"Accepted Etherscan GUID is discarded when any other step saves the row during the POST window: the reserved slot is burned, the GUID is never recorded, and the same input is resubmitted"},{"citation":"resolved","description":"The pre-0.12.7 adapter (48c611a) only stopped resubmitting once a GUID was stored; a refused POST (status '0', for example 'Unable to verify') set etherscanAttemptAt and was re-sent every 60 s indefinitely. A surviving legacy row with etherscanAttemptAt set and no etherscanGuid therefore may stand for an unbounded number of identical submissions, not one, so the stated premise 'migration sees at most one original submission per row' holds only for rows that received a GUID. The migrated count still bounds future submissions to two more, so this is informational; note it in the migration text or count such rows as 3 (already exhausted) if the intent is to never add to an already-refused legacy history.","line":32,"path":"adapters/source-verification.mjs","reproduction":"State: row {state:'pending', etherscanAttemptAt: 1700000000000, etherscanError:'Unable to verify'} written by the 48c611a adapter after N>=3 refused POSTs. Step with the current adapter: etherscanSubmissionCount becomes 1, and two further POSTs of the same input are made after cooldowns (N+2 total). Expected under the stated premise: the row is treated as having used one of three slots because it made one submission; actual history is N submissions.","severity":"info","snippet":"      value.etherscanSubmissionCount ??= value.etherscanGuid || value.etherscanAttemptAt ? 1 : 0;","title":"Legacy rows with etherscanAttemptAt but no GUID may already represent many refused submissions, yet migrate as one"}],"hash":"e5a839089e5b4ea80faf4e8c785ac5c3fd367a3994e8e8505b341b7f7452bf73","nodeId":"d6204c9b-b637-42a7-b168-af1d3295932e","outcome":"completed","summary":"The findings file is in place and excluded from git by the repo's own exclude rule, the scratch tests are removed, and the tree is clean. The review is complete.\n\n## Review outcome\n\nThe findings are written to `.imd-findings.json`. Two medium findings and one informational note, all in the Etherscan submission-cap logic of the verifier adapter. The jobs cursor change and the version/docs synchronisation came through clean.\n\n**Medium: not-yet-indexed explorer refusals exhaust the durable cap.** The reservation at `adapters/source-verification.mjs:62` counts every non-success POST identically. Etherscan replies \"Unable to locate ContractCode at 0x...\" until it has indexed a deployment, and the executors call the verifier right after 2-block confirmation with a 30-second job retry and 60-second cooldown. If indexing lags more than 3 minutes, the row hits `operator_action_required` and never submits again, even once the explorer would accept the identical input. I reproduced this against the pinned adapter and against the pre-change adapter with the same mock: the old code recovered with a fourth submission, the new code is stuck. Foundry's own verifier treats this exact reply as a retryable \"could not detect deployment\".\n\n**Medium: an accepted GUID is lost when another step saves during the POST window.** The slot is reserved in one CAS write, then the GUID is persisted in a second CAS write at `adapters/source-verification.mjs:73`. Every step ends with an unconditional save, so a purely read-only step that interleaves with the up-to-25-second POST bumps the revision and the GUID save throws `verification_step_stale`. The row then holds a consumed slot with no GUID, no error and no attempt record, and the next step resubmits the same source. The authors' concurrency test starts all steps at once so this interleaving never occurs. Overlap is reachable via lease expiry mid-step with a second worker instance, or an operator running the mainnet release script alongside the worker.\n\n**Info: legacy migration premise.** A pre-0.12.7 row with `etherscanAttemptAt` and no GUID could represent many refused resubmissions, since that adapter only stopped on a stored GUID. It migrates as one. The bound still holds, so this is a note on the stated premise, not a defect.\n\n**What checked out clean.**\n- The per-kind cursor advance and single wrap: I model-checked six table geometries with 1,000 to 3,000 rows and random cursor positions. Every eligible job was found within ceil(N/1000)+1 claims, and 1,200 eligible jobs were each claimed once before any repeat. The `<=` wrap filter, the `1000 - records.length` limit and the `nextCursor` after a wrap are all consistent.\n- Sourcify 409 handling: the live spec says 409 is \"already verified\" and \"already being verified\" is 429, so the suppression is correct.\n- Version 0.12.8 is consistent across package files, the API contract and docs, and regenerating the docs produces no diff.\n- The 18 focused jobs and verification tests pass locally.\n\nEach reproduction in the findings file is a self-contained Node test that runs against this tree. No Foundry proofs were attached, since no Solidity code changed in this diff.","treeHash":null,"usage":{"cachedInputTokens":2100997,"inputTokens":610,"model":"claude-fable-5-1","outputTokens":50656,"runtime":"claude","turns":43,"wallClockMs":835011}}],"verification":[]}