Skip to content

feat(verify): verify_contract and deploy_and_verify on CitrateScan - #21

Merged
SaulBuilds merged 12 commits into
mainfrom
feat/verify
Nov 8, 2026
Merged

SaulBuilds merged 12 commits into
mainfrom
feat/verify

Conversation

@SaulBuilds

Copy link
Copy Markdown
Contributor

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, POSTs verifysourcecode, and polls checkverifystatus. It returns a VerifyResult dataclass.
    • It sends only the sources in the contract's metadata, as forge and hardhat-verify do.
    • The compiler version is read from the metadata, because forge 1.5.1 writes solcLongVersion without the commit.
  • deploy_and_verify(client=, chain_id=, build_info=, contract=, args=[...]):
    • deploys through the new CitrateClient.deploy_contract, which signs with the client's key after the same genesis and chain-id checks as every transaction, and estimates gas unless gas_limit is given;
    • then verifies with the creation tx hash, retrying while the explorer has not indexed the contract.
  • _send_transaction now signs through a shared _sign_and_send. deploy_contract uses it with to=None, and existing callers behave as before.
  • Typed errors, all VerificationError(CitrateError):
    • BuildInfoError
    • VerificationFailedError
    • VerificationRejectedError
    • VerificationRateLimitedError
    • VerificationTimeoutError
    • ExplorerResponseError
  • load_build_info(path) follows a hardhat artifact's .dbg.json.
  • A README section with chain 40204 examples. It includes two notes from the explorer docs: forge needs --verifier custom, and hardhat-verify needs a per-network apiKey.

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.
    • The fixtures come from real tools: forge 1.5.1 and hardhat 2.29.1 build-infos, plus the requests forge --verifier custom and hardhat-verify 2.1.3 sent to a recording server. See tests/fixtures/verify/README.md.
    • The tests check that our form equals forge's request, and hardhat-verify's request plus chainid and the corrected spelling. They also cover constructorArguements, polling and terminal states, refusals, rate-limit retry, timeout and bad input.
  • tests/test_verify_deploy_anvil.py is marked integration and env-gated (CITRATE_VERIFY_ANVIL_RPC, CITRATE_VERIFY_ANVIL_KEY). On a local anvil --chain-id 40204 it deploys through CitrateClient, 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.
  • Red: c476233 (module missing). Green: 78a4ba2. pytest: 883 → 909 passed (49 → 50 skipped, the gated anvil test). ruff check . and mypy . 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

SaulBuilds and others added 2 commits November 7, 2026 17:12
…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>
@SaulBuilds
SaulBuilds requested a review from a team as a code owner November 8, 2026 01:30

@BerryManifold BerryManifold left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Matr0xshka left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

SaulBuilds and others added 10 commits November 8, 2026 10:45
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 BerryManifold left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Matr0xshka left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Matr0xshka left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@SaulBuilds
SaulBuilds merged commit a6063d3 into main Nov 8, 2026
10 checks passed
@SaulBuilds
SaulBuilds deleted the feat/verify branch November 8, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants