Repository navigation
feat(verify): verify_contract and deploy_and_verify on CitrateScan - #21
Conversation
…explorer API Tests for source verification through CitrateScan's Etherscan-compatible API: build-info parsing (forge, hardhat build-info and artifact, standard JSON), the verifysourcecode request (compared with the requests forge 1.5.1 and hardhat-verify 2.1.3 actually sent, including the constructorArguements spelling), checkverifystatus polling and terminal states, typed errors, deploy-then-verify, and an env-gated deploy through CitrateClient on a local anvil. Fixtures were produced by forge, hardhat and a local recording server; see tests/fixtures/verify/README.md. Red: citrate_sdk.verify does not exist yet. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Source verification through the explorer's Etherscan-compatible API (https://explorer.citrate.ai/api), mirroring @citratelabs/sdk: - verify_contract(chain_id=, address=, contract=, build_info= | standard_json_input=, constructor_args=, explorer_url=, api_key=) builds the standard JSON input from forge or hardhat build-info (only the sources in the contract's metadata, compiler version from the metadata), POSTs verifysourcecode and polls checkverifystatus; - deploy_and_verify deploys the build-info bytecode with ABI-encoded args through CitrateClient.deploy_contract (the client's key, after the genesis and chain-id checks), then verifies with the creation tx hash, retrying while the explorer has not indexed the contract; - CitrateClient._send_transaction now signs through _sign_and_send, which deploy_contract shares (to=None for a creation); - typed errors: BuildInfoError, VerificationFailedError, VerificationRejectedError, VerificationRateLimitedError, VerificationTimeoutError, ExplorerResponseError; - load_build_info follows a hardhat artifact's .dbg.json; - README section with chain 40204 examples and the forge/hardhat notes. Also types one test helper's return for mypy (no assertion changes). Green: test_verify 26/26; the anvil test passes with CITRATE_VERIFY_ANVIL_RPC/KEY set; pytest 883 -> 909 passed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
BerryManifold
left a comment
There was a problem hiding this comment.
Automated Codex static review of 78a4ba2d277255943d81f902c026a905a04da6a0 is complete. Result: blockers; details retained privately. Details and coverage limits are recorded in the project's private review log. No tests were executed by this reviewer. Both account submissions represent the same Codex review, not independent reviews.
Matr0xshka
left a comment
There was a problem hiding this comment.
Automated Codex static review of 78a4ba2d277255943d81f902c026a905a04da6a0 is complete. Result: blockers; details retained privately. Details and coverage limits are recorded in the project's private review log. No tests were executed by this reviewer. Both account submissions represent the same Codex review, not independent reviews.
Review round 1, item 1 (match citrate-chain CLI #445). verify_contract and deploy_and_verify without chain_id must send no chainid on the submission or on any checkverifystatus call, so local devnets with another chain id work; with chain_id it goes out on the submission and every poll. A non-positive, bool, float or string chain_id is still refused. Red: 2 failed, 27 passed (chain_id is a required keyword argument). Signed-off-by: Larry Klosowski <larryklosowski@proton.me> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPJzHN7kVR6UzVEJDEY9Yc
…erify As in citrate-chain CLI #445: when chain_id is given it is sent as Etherscan v2 `chainid` on the verifysourcecode submission and on every checkverifystatus call; when omitted nothing is sent, so a local devnet explorer with another chain id works. A given chain_id must still be a positive int (not bool). check_verify_status and wait_for_verification already took it as optional. Both functions are keyword-only, so moving the parameter to the end changes no call. The red test's bad-value list is typed list[Any] for mypy (it passes non-int values on purpose). Green: test_verify 29/29; ruff and mypy clean. Signed-off-by: Larry Klosowski <larryklosowski@proton.me> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPJzHN7kVR6UzVEJDEY9Yc
…fication Review round 1, item 2. While polling checkverifystatus, a connection reset, an HTTP 5xx or a 429 must be retried within the overall timeout, with a backoff that doubles from poll_interval and is capped by transient_backoff_max; a 4xx other than 429 and an explicit Fail still end the wait; transient errors until the deadline end in VerificationTimeoutError carrying the last error. ExplorerResponseError gains a `transient` attribute. The test recorder learns to drop a connection (RESET) and stamps each request's arrival time. Red: 5 failed, 29 passed. Signed-off-by: Larry Klosowski <larryklosowski@proton.me> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPJzHN7kVR6UzVEJDEY9Yc
wait_for_verification no longer gives up on the first connection reset, timeout, HTTP 5xx or 429 from checkverifystatus. It retries within timeout; the delay starts at poll_interval, doubles per consecutive transient error and is capped by transient_backoff_max (default 30 s, also accepted by verify_contract and deploy_and_verify), and resets after a normal answer. A 4xx other than 429, a non-envelope 200 and an explicit Fail still end the wait at once. If the deadline passes during transient errors, VerificationTimeoutError carries the last error as last_status. ExplorerResponseError gets a `transient` attribute (also in details): true for requests' ConnectionError, Timeout and ChunkedEncodingError and for HTTP 5xx/429. The red test's zip() calls get explicit strict= for ruff B905. Green: test_verify 34/34; ruff and mypy clean. Signed-off-by: Larry Klosowski <larryklosowski@proton.me> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPJzHN7kVR6UzVEJDEY9Yc
…ackoff Review round 1, item 3. deploy_and_verify resubmits verifysourcecode while the explorer answers "Unable to locate ContractCode"; it waited only poll_interval (3 s by default) between submissions, but the explorer allows one submission per 5 s per IP. The gap must be at least rate_limit_backoff. Red: 1 failed (gap 9 ms, want >= 150 ms). Signed-off-by: Larry Klosowski <larryklosowski@proton.me> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPJzHN7kVR6UzVEJDEY9Yc
deploy_and_verify now waits max(poll_interval, rate_limit_backoff) between verifysourcecode resubmissions while the explorer has not indexed the new contract (defaults: 5 s), matching the explorer's one submission per 5 s. Green: test_verify 35/35. Signed-off-by: Larry Klosowski <larryklosowski@proton.me> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPJzHN7kVR6UzVEJDEY9Yc
Review round 1, item 4. The receipt-status check in CitrateClient.deploy_contract was only exercised by the env-gated anvil test. The default suite now covers it with a keyed client whose _rpc_call answers from a test-only table (eth_chainId, eth_getTransactionCount, eth_estimateGas, eth_sendRawTransaction, eth_getTransactionReceipt): status 0x0 is DEPLOY_FAILED with the tx hash in details, a receipt without contractAddress is DEPLOY_FAILED, and status 0x1 returns address, hash and block number. The signing path (genesis check skipped by expected_genesis_hash=None, chain-id assert, EIP-155 signing) runs for real. Test only, no behaviour change: passes as written. Mutation check: disabling the status guard makes it fail. test_verify 36/36. Signed-off-by: Larry Klosowski <larryklosowski@proton.me> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPJzHN7kVR6UzVEJDEY9Yc
Review round 1 (Python). _Explorer.get copied the requests exception text into ExplorerResponseError, and that text names the request URL, `/api?apikey=<key>&...`; `raise ... from e` also kept the original exception, with the same URL, in the traceback. A connection error from check_verify_status, verify_contract (submit) and wait_for_verification (which now retries transient errors and reports the last one) must not show the key or any `apikey=` query in str(), repr(), details or the formatted traceback including chained exceptions. Red: 1 failed (the key is in str(e) of the check_verify_status error). Signed-off-by: Larry Klosowski <larryklosowski@proton.me> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPJzHN7kVR6UzVEJDEY9Yc
A failed explorer request now becomes an ExplorerResponseError whose message is the requests text with every URL query string cut to `?<redacted>` and the key itself (raw and URL-encoded) removed, e.g. "could not reach the explorer (ConnectionError): ... Max retries exceeded with url: /api?<redacted> (Caused by NewConnectionError(...))". It is raised `from None`, so the original requests exception, which names the same URL, is not chained into tracebacks. The submission path scrubs the `apikey` form field's value too. The transient flag is kept. The red commit's imports are re-sorted for ruff I001. Green: test_verify 37/37; ruff and mypy clean. Signed-off-by: Larry Klosowski <larryklosowski@proton.me> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPJzHN7kVR6UzVEJDEY9Yc
README: chain_id is optional (sent on submit and every status call only when given, as citrate contract verify --chain-id); the transient-error retry and transient_backoff_max; the rate-limit keywords and the not-indexed resubmission gap; api_key never appears in error messages; deploy_contract's receipt_timeout and DEPLOY_FAILED; ExplorerResponseError.transient. CHANGELOG (Unreleased) records the same. Signed-off-by: Larry Klosowski <larryklosowski@proton.me> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FPJzHN7kVR6UzVEJDEY9Yc
BerryManifold
left a comment
There was a problem hiding this comment.
Automated Codex static review of 51a8dbce75f445f51627ee9870d69ea60837aaac is complete. Result: blockers; details retained privately. Details and coverage limits are recorded in the project's private review log. No tests were executed by this reviewer. Both account submissions represent the same Codex review, not independent reviews.
Matr0xshka
left a comment
There was a problem hiding this comment.
Automated Codex static review of 51a8dbce75f445f51627ee9870d69ea60837aaac is complete. Result: blockers; details retained privately. Details and coverage limits are recorded in the project's private review log. No tests were executed by this reviewer. Both account submissions represent the same Codex review, not independent reviews.
Matr0xshka
left a comment
There was a problem hiding this comment.
MAC independent reviewer agent (rounds 1 and 2), not Codex, not DGX, not a human reviewer.
Reviewed head 51a8dbc. No P1/P2.
- Python 3.12: 868 passed, 50 skipped (economics simulation deselected); CI full suite 920 passed, 50 skipped.
- Revert checks: disabling the transient retry fails 4 tests; reverting the key scrub fails the key regression test.
- Round-1 P2s resolved: chain_id is optional; transient retry with capped backoff; not-indexed interval; the API key is redacted from str, repr, details and traceback (raised from None); DEPLOY_FAILED is tested on the real status-0 path.
- P3: backoff exponent overflow when poll_interval and transient_backoff_max are both 0; context still carries the URL for tooling that ignores the suppress flag.
What
Adds
citrate_sdk.verify, which verifies Solidity source on CitrateScan (chain 40204) through the explorer's Etherscan-compatible API (https://explorer.citrate.ai/api). It mirrors @citratelabs/sdk (CitrateNetwork/citrate-sdk-js#40).verify_contract(chain_id=, address=, contract=, build_info= | standard_json_input=, constructor_args=, explorer_url=, api_key=, ...)builds the standard JSON input from a forge or hardhat build-info, POSTsverifysourcecode, and pollscheckverifystatus. It returns aVerifyResultdataclass.solcLongVersionwithout the commit.deploy_and_verify(client=, chain_id=, build_info=, contract=, args=[...]):CitrateClient.deploy_contract, which signs with the client's key after the same genesis and chain-id checks as every transaction, and estimates gas unlessgas_limitis given;_send_transactionnow signs through a shared_sign_and_send.deploy_contractuses it withto=None, and existing callers behave as before.VerificationError(CitrateError):BuildInfoErrorVerificationFailedErrorVerificationRejectedErrorVerificationRateLimitedErrorVerificationTimeoutErrorExplorerResponseErrorload_build_info(path)follows a hardhat artifact's.dbg.json.--verifier custom, and hardhat-verify needs a per-networkapiKey.Data source: CitrateScan
GET|POST /api?module=contract&action=verifysourcecode|checkverifystatus. The envelope is Etherscan's{status, message, result}.How it was tested
tests/test_verify.py: 26 tests against a local HTTP recorder. The recorder is a test-only fake.--verifier customand hardhat-verify 2.1.3 sent to a recording server. Seetests/fixtures/verify/README.md.chainidand the corrected spelling. They also coverconstructorArguements, polling and terminal states, refusals, rate-limit retry, timeout and bad input.tests/test_verify_deploy_anvil.pyis marked integration and env-gated (CITRATE_VERIFY_ANVIL_RPC,CITRATE_VERIFY_ANVIL_KEY). On a localanvil --chain-id 40204it deploys throughCitrateClient, then checks that the creation tx input is exactly the creation code plus the encoded args, and that the submission names that tx. It passed locally.pytest: 883 → 909 passed (49 → 50 skipped, the gated anvil test).ruff check .andmypy .are clean.Not verified: the live explorer. Verification v2 is not deployed yet, so nothing here has run against explorer.citrate.ai.
🤖 Generated with Claude Code
https://claude.ai/code/session_01FPJzHN7kVR6UzVEJDEY9Yc