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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to `citrate-labs-sdk` are documented here. This project adhe

## [Unreleased]

## [0.8.0] - 2026-11-08 — contract verification on CitrateScan

Minor release: new API only, nothing removed or changed in an incompatible way. Publish once the
CitrateScan verify API (v2) serves `verifysourcecode` / `checkverifystatus` at
`DEFAULT_EXPLORER_URL`.

### Added

- `citrate_sdk.verify`: `verify_contract` and `deploy_and_verify` verify Solidity source on
Expand All @@ -22,6 +28,13 @@ All notable changes to `citrate-labs-sdk` are documented here. This project adhe
resubmissions wait at least `rate_limit_backoff`; transport-error messages never include the API
key or the request's query string.

### Fixed (review follow-up on #21)

- `wait_for_verification`: the transient backoff exponent is clamped (`transient_backoff`) and
`poll_interval` is at least `MIN_POLL_INTERVAL` (5 ms), so `poll_interval=0` no longer retries in
a tight loop and `poll_interval=0.0` with `transient_backoff_max=0.0` can no longer raise
`OverflowError` after 1024 consecutive transient errors.

## [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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ Keywords:
- `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.
within `timeout`. A 4xx other than 429, or a `Fail` verdict, ends it. `poll_interval` is at
least `MIN_POLL_INTERVAL` (5 ms), so 0 does not retry in a tight loop.
- `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)`
Expand Down
2 changes: 1 addition & 1 deletion citrate_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
wait_for_verification,
)

__version__ = "0.7.0"
__version__ = "0.8.0"
__author__ = "Citrate Team"

__all__ = [
Expand Down
26 changes: 24 additions & 2 deletions citrate_sdk/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@
from .errors import CitrateError

DEFAULT_EXPLORER_URL = "https://explorer.citrate.ai/api"

#: Shortest wait in seconds between two status polls, whatever ``poll_interval`` says (0 would
#: retry in a tight loop).
MIN_POLL_INTERVAL = 0.005

#: The doubling stops here; 2**30 times any interval already exceeds every sensible cap.
_MAX_BACKOFF_EXPONENT = 30
#: Explorer limits (CitrateScan docs, "Limits").
MAX_SOURCE_BYTES = 2 * 1024 * 1024
MAX_SOURCE_FILES = 64
Expand Down Expand Up @@ -528,6 +535,19 @@ def check_verify_status(
return VerifyStatus("failed", text)


def transient_backoff(poll_interval: float, transient_errors: int, transient_backoff_max: float) -> float:
"""The wait in seconds after the ``transient_errors``-th consecutive transient error.

``poll_interval`` (at least :data:`MIN_POLL_INTERVAL`) doubled per error, capped at
``transient_backoff_max`` (or the interval, if larger). The exponent is clamped, so the result
is finite for any error count and never raises ``OverflowError``.
"""
base = max(float(poll_interval), MIN_POLL_INTERVAL)
exponent = min(max(transient_errors - 1, 0), _MAX_BACKOFF_EXPONENT)
delay: float = base * 2.0**exponent
return min(delay, max(float(transient_backoff_max), base))


def wait_for_verification(
guid: str,
*,
Expand All @@ -544,8 +564,10 @@ def wait_for_verification(

A transient :class:`ExplorerResponseError` (transport failure, HTTP 5xx or 429) does not end the
wait: the poll is retried after a backoff that doubles from ``poll_interval`` up to
``transient_backoff_max`` seconds, within ``timeout``. Any other error ends it.
``transient_backoff_max`` seconds (see :func:`transient_backoff`), within ``timeout``. Any other
error ends it. ``poll_interval`` is at least :data:`MIN_POLL_INTERVAL`.
"""
poll_interval = max(float(poll_interval), MIN_POLL_INTERVAL)
deadline = time.monotonic() + timeout
transient_errors = 0
while True:
Expand All @@ -563,7 +585,7 @@ def wait_for_verification(
if not e.transient:
raise
transient_errors += 1
delay = min(poll_interval * 2 ** (transient_errors - 1), max(transient_backoff_max, poll_interval))
delay = transient_backoff(poll_interval, transient_errors, transient_backoff_max)
last = str(e)
else:
if s.state == "verified":
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "citrate-labs-sdk"
version = "0.7.0"
version = "0.8.0"
description = "Python SDK for the Citrate distributed AI network (chain 40204). The canonical TypeScript SDK is @citratelabs/sdk on npm; this Python client is opt-in and may lag it."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
51 changes: 51 additions & 0 deletions tests/test_verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import json
import math
import socket
import threading
import time
Expand All @@ -20,13 +21,15 @@
from urllib.parse import parse_qsl, urlsplit

import pytest
import requests
from eth_abi import encode

import citrate_sdk
from citrate_sdk import CitrateClient
from citrate_sdk.errors import CitrateError
from citrate_sdk.verify import (
DEFAULT_EXPLORER_URL,
MIN_POLL_INTERVAL,
BuildInfoError,
ExplorerResponseError,
VerificationFailedError,
Expand All @@ -39,6 +42,7 @@
load_build_info,
parse_build_info,
prepare_standard_json,
transient_backoff,
verify_contract,
wait_for_verification,
)
Expand Down Expand Up @@ -422,6 +426,53 @@ def test_backoff_between_transient_errors_is_bounded(recorder: Any) -> None:
assert want - 0.003 <= got < want + 0.2


class _AlwaysResetSession(requests.Session):
"""Test-only session: every GET fails like a connection reset, and is counted."""

def __init__(self) -> None:
super().__init__()
self.calls = 0

def get(self, url: Any, **kwargs: Any) -> Any:
self.calls += 1
raise requests.ConnectionError("connection reset by peer")


def test_zero_poll_interval_and_backoff_max_still_back_off() -> None:
# Both 0.0: the doubling stayed at 0 (a tight retry loop) and 0.0 * 2**1024 raised
# OverflowError after 1024 consecutive transient errors instead of timing out.
session = _AlwaysResetSession()
t0 = time.monotonic()
with pytest.raises(VerificationTimeoutError) as e:
wait_for_verification(GUID, explorer_url="https://explorer.example/api", session=session,
poll_interval=0.0, transient_backoff_max=0.0, timeout=0.3)
assert "ConnectionError" in e.value.last_status
# MIN_POLL_INTERVAL (5 ms) between attempts: about 60 calls in 0.3 s, never thousands.
assert 2 <= session.calls <= 0.3 / MIN_POLL_INTERVAL + 5
assert time.monotonic() - t0 < 2.0


def test_zero_poll_interval_backoff_doubles_to_the_cap() -> None:
session = _AlwaysResetSession()
with pytest.raises(VerificationTimeoutError):
wait_for_verification(GUID, explorer_url="https://explorer.example/api", session=session,
poll_interval=0, transient_backoff_max=1.0, timeout=0.3)
# 5, 10, 20, 40, 80, 160 ms fits 0.3 s in about 7 calls.
assert 2 <= session.calls < 12


def test_transient_backoff_is_finite_and_capped() -> None:
assert transient_backoff(0.02, 1, 0.05) == 0.02
assert transient_backoff(0.02, 2, 0.05) == 0.04
assert transient_backoff(0.02, 3, 0.05) == 0.05
for n in (1, 30, 1024, 1025, 5000, 10**18):
for interval in (0.0, 0.001, 3.0):
for cap in (0.0, 30.0):
d = transient_backoff(interval, n, cap)
assert math.isfinite(d)
assert MIN_POLL_INTERVAL <= d <= max(cap, interval, MIN_POLL_INTERVAL)


def test_transport_failures_5xx_and_429_are_transient(recorder: Any) -> None:
cases: list[tuple[tuple[int, str], int | None, bool]] = [
((503, "down"), 503, True),
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading