Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@
All notable changes to `citrate-labs-sdk` are documented here. This project adheres to
[Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added

- `citrate_sdk.verify`: `verify_contract` and `deploy_and_verify` verify Solidity source on
CitrateScan through its Etherscan-compatible API (`verifysourcecode`, `checkverifystatus`) from
forge or hardhat build-info, a hardhat artifact, or a standard JSON input, with polling and typed
errors (`VerificationError` and subclasses). Also `load_build_info`, `parse_build_info`,
`prepare_standard_json`, `check_verify_status`, `wait_for_verification`.
- `CitrateClient.deploy_contract(data, value=0, gas_limit=None, receipt_timeout=120)`: sign and
send a contract-creation transaction (same genesis and chain-id checks) and wait for the receipt
(`DEPLOY_FAILED` on a status-0 receipt).
- Verification details: `chain_id` is optional (sent as `chainid` on the submission and every
status call only when given, as `citrate contract verify --chain-id`); transient explorer errors
(transport, timeout, HTTP 5xx and 429) are retried while waiting, with a backoff capped by
`transient_backoff_max`, and flagged by `ExplorerResponseError.transient`; not-indexed
resubmissions wait at least `rate_limit_backoff`; transport-error messages never include the API
key or the request's query string.

## [0.7.0] - 2026-10-27 — r1006 address book and a genesis guard

Held for the r1006 public cutover: publish only after rpc.citrate.ai serves r1006.
Expand Down
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,87 @@ Point the SDK at a local Citrate stack on one machine instead of the public test
For the full multi-repo bring-up (chain → identity → bundler → gateway → SDKs), see the
LOCAL_STACK guide at <https://docs.citrate.ai>.

## Verify contracts on CitrateScan
`citrate_sdk.verify` submits Solidity source to CitrateScan, the explorer for chain 40204, through
its Etherscan-compatible API (`https://explorer.citrate.ai/api`). It POSTs `verifysourcecode`,
polls `checkverifystatus`, and returns when the explorer has recompiled the source and matched it
to the code on chain. Input is a forge or hardhat build-info, a hardhat contract artifact, or a
solc standard JSON input. Only the sources the contract's metadata lists are sent, as forge and
hardhat-verify do.

```python
import os

from citrate_sdk import CitrateClient, deploy_and_verify, load_build_info, verify_contract

# forge: `forge build --build-info` writes out/build-info/<id>.json with the compiler input.
build_info, _ = load_build_info("out/build-info/<id>.json")
result = verify_contract(
chain_id=40204, # optional: sent as Etherscan v2 `chainid`; omit it for a local devnet explorer
address="0xYourContractAddress",
build_info=build_info,
contract="src/MyContract.sol:MyContract", # or "MyContract" when the name is unique
constructor_args="0x...", # optional: the explorer reads them from the creation transaction
)
print(result.status) # "verified" or "already_verified"

# hardhat: pass the contract artifact; load_build_info follows its .dbg.json.
bi, contract = load_build_info("artifacts/contracts/MyContract.sol/MyContract.json")
verify_contract(chain_id=40204, address="0xYourContractAddress", build_info=bi, contract=contract)

# Deploy from the build-info with the client's key (same genesis/chain-id checks as every
# transaction), then verify with the creation transaction hash.
client = CitrateClient(rpc_url="https://rpc.citrate.ai", private_key=os.environ["CITRATE_PRIVATE_KEY"])
deployed = deploy_and_verify(
client=client, chain_id=40204, build_info=build_info,
contract="src/MyContract.sol:MyContract", args=[7, "hello"],
)
print(deployed.address, deployed.tx_hash, deployed.verification.status)
```

Keywords:
- `chain_id`: optional. When given, it is sent as Etherscan v2 `chainid` on the submission and on
every status call. When omitted, no `chainid` is sent, so an explorer for a local devnet with
another chain id works. This matches `citrate contract verify --chain-id`.
- `explorer_url` (default `https://explorer.citrate.ai/api`), `api_key` (sent as `apikey`; not
required, and never included in error messages), `compiler_version` (otherwise read from the
contract metadata), `creation_tx_hash`.
- `wait=False`: return the guid without polling.
- `poll_interval` (3 s) and `timeout` (300 s) for the status polling. A transient error while
polling (connection reset, request timeout, HTTP 5xx or 429) does not end the wait: the poll is
retried with a backoff that doubles from `poll_interval` up to `transient_backoff_max` (30 s),
within `timeout`. A 4xx other than 429, or a `Fail` verdict, ends it.
- `rate_limit_retries` (3) and `rate_limit_backoff` (5 s) for `Max rate limit reached`. The
explorer takes one submission per 5 s per IP, so while it has not indexed a just-deployed
contract, `deploy_and_verify` resubmits at most every `max(poll_interval, rate_limit_backoff)`
seconds, for up to `index_timeout` (120 s).

`check_verify_status(guid)` and `wait_for_verification(guid)` poll a job you submitted earlier;
pass the same `chain_id` (or none) as the submission. `CitrateClient.deploy_contract` waits up to
`receipt_timeout` (120 s) for the receipt and raises `CitrateError` with code `DEPLOY_FAILED`
when the receipt has status 0.

Errors subclass `VerificationError` (a `CitrateError`):
- `BuildInfoError`: bad input, nothing sent. A plain `forge build` without `--build-info` gets
this error, with the fix in the message.
- `VerificationFailedError`: `Fail - Unable to verify`.
- `VerificationRejectedError`: the explorer's refusal string.
- `VerificationRateLimitedError`: still rate limited after retries.
- `VerificationTimeoutError`: no verdict yet. The job may still finish.
- `ExplorerResponseError`: the explorer could not be reached or gave an unexpected answer.
`transient` is true for a transport failure, a timeout, HTTP 5xx and 429.

The explorer accepts at most 64 files and 2 MiB, inline content only.

forge and hardhat can verify against the same API directly. Two details matter on chain 40204:
- **forge:** use `--verifier custom --verifier-url https://explorer.citrate.ai/api --verifier-api-key citrate`.
With forge 1.5.1, `--verifier etherscan` stops before sending anything ("No known Etherscan API
URL for chain `40204`"). `--verifier-api-key` must be present, and any value works.
- **hardhat-verify:** give `etherscan.apiKey` as a per-network object (`{ citrate: "citrate" }`)
with a `customChains` entry for chainId 40204 (`apiURL: "https://explorer.citrate.ai/api"`).
With hardhat-verify 2.1.3 a single string `apiKey` switches it to Etherscan's v2 mode, which
ignores `customChains` and sends the request to api.etherscan.io.

## Configuration
| Env var | Default | Purpose |
|---------|---------|---------|
Expand Down
29 changes: 29 additions & 0 deletions citrate_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,22 @@
StakingInfo,
)

# Contract source verification on CitrateScan (Etherscan-compatible explorer API)
from .verify import (
BuildInfoError,
ExplorerResponseError,
VerificationError,
VerificationFailedError,
VerificationRateLimitedError,
VerificationRejectedError,
VerificationTimeoutError,
check_verify_status,
deploy_and_verify,
load_build_info,
verify_contract,
wait_for_verification,
)

__version__ = "0.7.0"
__author__ = "Citrate Team"

Expand Down Expand Up @@ -81,6 +97,19 @@
"MemoryClient",
"ByomMemoryClient",
"MemoryError",
# Contract verification
"verify_contract",
"deploy_and_verify",
"load_build_info",
"check_verify_status",
"wait_for_verification",
"VerificationError",
"BuildInfoError",
"VerificationFailedError",
"VerificationRejectedError",
"VerificationRateLimitedError",
"VerificationTimeoutError",
"ExplorerResponseError",
# Data types
"LearningPool",
"CycleStatus",
Expand Down
59 changes: 56 additions & 3 deletions citrate_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,16 @@ def _send_transaction(
JSON string, which is sent byte-for-byte (see ``deploy_model``).
"""
encoded = data if isinstance(data, str) else json.dumps(data)
return self._sign_and_send(to_address, "0x" + encoded.encode().hex(), value, gas_limit)

def _sign_and_send(
self, to_address: str | None, data_hex: str, value: int, gas_limit: int
) -> str:
"""Sign ``data_hex`` (0x-prefixed calldata or creation code) and broadcast it.

``to_address=None`` is a contract creation. Data source: ``eth_getTransactionCount``,
``eth_chainId`` and ``eth_sendRawTransaction`` on ``rpc_url``.
"""
if not self.key_manager:
raise CitrateError("Private key required for transactions")

Expand All @@ -515,23 +525,66 @@ def _send_transaction(
# Build transaction. RM-G.4: bind chainId so the signature is
# EIP-155 (domain-separated) and cannot be replayed on another
# Citrate network — the pre-fix tx omitted chainId (pre-EIP-155).
tx = {
tx: dict[str, Any] = {
"from": from_address,
"to": to_address,
"value": hex(value),
"gas": hex(gas_limit),
"gasPrice": hex(20_000_000_000), # 20 gwei
"nonce": hex(nonce),
"data": "0x" + encoded.encode().hex(),
"data": data_hex,
"chainId": self._eip155_chain_id(),
}
if to_address is not None:
tx["to"] = to_address

# Sign transaction
signed_tx = self.key_manager.sign_transaction(tx)

# Send raw transaction
return cast(str, self._rpc_call("eth_sendRawTransaction", [signed_tx]))

def deploy_contract(
self, data: str, value: int = 0, gas_limit: int | None = None, receipt_timeout: int = 120
) -> dict[str, Any]:
"""Deploy an EVM contract and wait for it to be mined.

``data`` is the 0x-prefixed creation code with the ABI-encoded constructor arguments
appended. The transaction is signed with this client's key after the genesis and
chain-id checks every transaction gets. ``gas_limit`` defaults to ``eth_estimateGas``
plus 20%. Returns ``{"address", "tx_hash", "block_number"}``.
Data source: ``eth_estimateGas``, ``eth_sendRawTransaction`` and
``eth_getTransactionReceipt`` on ``rpc_url``. Used by
:func:`citrate_sdk.verify.deploy_and_verify`.
"""
if not isinstance(data, str) or not data.startswith("0x") or len(data) <= 2:
raise CitrateError("deploy_contract: data must be non-empty 0x-prefixed hex")
try:
bytes.fromhex(data[2:])
except ValueError as e:
raise CitrateError("deploy_contract: data must be non-empty 0x-prefixed hex") from e
if not self.key_manager:
raise CitrateError("Private key required for contract deployment")
if gas_limit is None:
estimate = self._rpc_call(
"eth_estimateGas",
[{"from": self.key_manager.get_address(), "data": data, "value": hex(value)}],
)
gas_limit = int(estimate, 16) * 6 // 5
tx_hash = self._sign_and_send(None, data, value, gas_limit)
receipt = self._wait_for_receipt(tx_hash, timeout=receipt_timeout)
status = receipt.get("status")
if status is not None and int(str(status), 16) != 1:
raise CitrateError(f"Contract creation failed (tx {tx_hash})", "DEPLOY_FAILED", {"tx_hash": tx_hash})
address = receipt.get("contractAddress")
if not address:
raise CitrateError(f"Receipt has no contractAddress (tx {tx_hash})", "DEPLOY_FAILED", {"tx_hash": tx_hash})
block = receipt.get("blockNumber")
return {
"address": address,
"tx_hash": tx_hash,
"block_number": int(str(block), 16) if block is not None else None,
}

def _wait_for_receipt(self, tx_hash: str, timeout: int = 60) -> dict[str, Any]:
"""Wait for transaction receipt"""
start_time = time.time()
Expand Down
Loading
Loading