Skip to content

Commit d52de39

Browse files
Origin-based endpoints: configure just https://api.provable.com
Users now pass the bare API origin; the SDK detects the hosted Provable API by host (api.provable.com) and derives every service path itself — reads at /v2, delegated proving at /prove, hosted scanner at /scanner, JWT auth at /jwts — all per-network. Any OTHER host (devnode, local, custom node) is treated as a literal read base (no /v2) with NO hosted prover/scanner wired up, so nothing breaks and there's no footgun. A legacy ".../v2" value is still accepted (detected as Provable and rebuilt identically) — fully backward compatible. - _client_common: DEFAULT_HOST -> bare origin; add PROVABLE_API_HOSTS + is_provable_host(). - network_client / async: _resolve_urls() maps host -> (read_host, origin, prover_default, scanner_default); prover/scanner default only on the hosted API, else None. Expose origin/prover_uri/scanner_uri. submit_proving_request returns a clear error when no prover is configured off the hosted API. - facade: HTTPProvider url is now the origin; scanner_base() returns None off the hosted API and the facade raises an actionable error rather than pointing at a bogus /scanner. - Roundtrip live test: filter scan on min_microcredits (avoid spending a zero-balance record -> invalid SNARK inputs) and shrink the mint to 10_000. - e2e endpoint defaults -> bare origin. Devnode untouched (non-Provable -> literal base). Live DPS + RSS tests pass on testnet + mainnet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 88faf16 commit d52de39

10 files changed

Lines changed: 301 additions & 71 deletions

sdk/python/aleo/_client_common.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,20 @@ def __init__(self, message: str, status: int | None = None) -> None:
3535

3636

3737
FIVE_MINUTES_MS: int = 5 * 60 * 1000
38-
DEFAULT_HOST: str = "https://api.provable.com/v2"
38+
DEFAULT_HOST: str = "https://api.provable.com"
3939
DEFAULT_NETWORK: str = "mainnet"
40+
41+
# The hosted Provable API splits its services across path prefixes off a single
42+
# origin (reads at /v2, delegated proving at /prove, hosted scanner at /scanner,
43+
# JWT auth at /jwts). We detect it by host so that EVERY other endpoint (devnode,
44+
# local, or any custom node) is treated as a literal read base — no /v2 magic,
45+
# and no prover/scanner wired up (those services only exist on the hosted API).
46+
PROVABLE_API_HOSTS: frozenset[str] = frozenset({"api.provable.com"})
47+
48+
49+
def is_provable_host(url: str) -> bool:
50+
"""True if *url* points at the hosted Provable API (api.provable.com)."""
51+
return (urlparse(url).hostname or "").lower() in PROVABLE_API_HOSTS
4052
SDK_HEADERS: set[str] = {"x-aleo-sdk-version", "x-aleo-environment", "x-aleo-method"}
4153

4254

sdk/python/aleo/async_network_client.py

Lines changed: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
DEFAULT_NETWORK,
2424
AleoNetworkError,
2525
AleoProvingError,
26+
is_provable_host,
2627
jwt_expired,
2728
jwt_origin,
2829
make_default_headers,
@@ -83,19 +84,26 @@ def __init__(
8384
"Install with: pip install aleo[async]"
8485
) from None
8586

86-
self._base_url: str = host
8787
self._network: str = network
88-
self._host: str = f"{host}/{network}"
88+
# See AleoNetworkClient._resolve_urls: hosted Provable API → /v2 reads +
89+
# /prove + /scanner off the origin; any other host → literal read base
90+
# with no hosted prover/scanner.
91+
read_host, origin, prover_default, scanner_default = self._resolve_urls(host)
92+
self._origin: str = origin
93+
self._base_url: str = origin # compat alias (now the origin)
94+
self._host: str = read_host
8995
self._has_custom_transport: bool = transport is not None
9096
self._transport: Any = transport
9197
self._account: Any = None
9298
self._verbose_errors: bool = True
9399
self.api_key: str | None = api_key
94100
self.consumer_id: str | None = consumer_id
95101
self.jwt_data: dict[str, Any] | None = jwt_data
96-
self._prover_uri: str | None = f"{prover_uri}/{network}" if prover_uri else None
102+
self._prover_uri: str | None = (
103+
f"{prover_uri}/{network}" if prover_uri else prover_default
104+
)
97105
self._record_scanner_uri: str | None = (
98-
f"{record_scanner_uri}/{network}" if record_scanner_uri else None
106+
f"{record_scanner_uri}/{network}" if record_scanner_uri else scanner_default
99107
)
100108

101109
if self._has_custom_transport:
@@ -112,6 +120,28 @@ def __init__(
112120
else:
113121
self._client = httpx.AsyncClient()
114122

123+
def _resolve_urls(
124+
self, host: str
125+
) -> tuple[str, str, str | None, str | None]:
126+
"""Resolve ``(read_host, origin, prover_default, scanner_default)`` for *host*.
127+
128+
Hosted Provable API → reads under ``/v2`` with the delegated prover
129+
(``/prove``) and hosted scanner (``/scanner``) off the same origin; any
130+
other host → literal read base with no hosted prover/scanner. (``/jwts``
131+
and ``/consumers`` always live at the bare origin — handled in
132+
:meth:`_refresh_jwt`, not here.) Mirrors ``AleoNetworkClient._resolve_urls``.
133+
"""
134+
origin = jwt_origin(host)
135+
network = self._network
136+
if is_provable_host(host):
137+
return (
138+
f"{origin}/v2/{network}",
139+
origin,
140+
f"{origin}/prove/{network}",
141+
f"{origin}/scanner/{network}",
142+
)
143+
return (f"{host.rstrip('/')}/{network}", origin, None, None)
144+
115145
# ── Network module selection ──────────────────────────────────────────
116146

117147
def _net(self) -> Any:
@@ -135,8 +165,12 @@ def _net(self) -> Any:
135165
# ── Mutators ──────────────────────────────────────────────────────────
136166

137167
def set_host(self, host: str) -> None:
138-
self._base_url = host
139-
self._host = f"{host}/{self._network}"
168+
read_host, origin, prover_default, scanner_default = self._resolve_urls(host)
169+
self._origin = origin
170+
self._base_url = origin
171+
self._host = read_host
172+
self._prover_uri = prover_default
173+
self._record_scanner_uri = scanner_default
140174

141175
def set_prover_uri(self, prover_uri: str) -> None:
142176
self._prover_uri = f"{prover_uri}/{self._network}"
@@ -150,6 +184,21 @@ def set_account(self, account: Any) -> None:
150184
def get_account(self) -> Any:
151185
return self._account
152186

187+
@property
188+
def origin(self) -> str:
189+
"""The API origin (``scheme://host``) all services derive from."""
190+
return self._origin
191+
192+
@property
193+
def prover_uri(self) -> str | None:
194+
"""DPS prover base (``{origin}/prove/{network}`` on the hosted API), or None."""
195+
return self._prover_uri
196+
197+
@property
198+
def scanner_uri(self) -> str | None:
199+
"""Hosted-scanner base (``{origin}/scanner/{network}`` on the hosted API), or None."""
200+
return self._record_scanner_uri
201+
153202
def set_header(self, name: str, value: str) -> None:
154203
self.headers[name] = value
155204

@@ -221,8 +270,7 @@ async def _do() -> Any:
221270
# ── JWT refresh ───────────────────────────────────────────────────────
222271

223272
async def _refresh_jwt(self, api_key: str, consumer_id: str) -> dict[str, Any]:
224-
origin = jwt_origin(self._base_url)
225-
url = f"{origin}/jwts/{consumer_id}"
273+
url = f"{self._origin}/jwts/{consumer_id}"
226274
hdrs = {
227275
**self._request_headers("refreshJwt"),
228276
"X-Provable-API-Key": api_key,
@@ -480,15 +528,22 @@ async def submit_proving_request_safe(
480528
consumer_id: str | None = None,
481529
jwt_data: dict[str, Any] | None = None,
482530
) -> dict[str, Any]:
483-
# DPS is a Provable service at the API origin under the ``/prove``
484-
# prefix, per network (e.g. https://api.provable.com/prove/testnet),
485-
# sibling to /scanner — not the read node's /v2/{network} base. Handshake
486-
# hits {base}/pubkey and {base}/prove/authorization. Explicit override wins.
487-
prover_uri = (
488-
url
489-
or self._prover_uri
490-
or f"{jwt_origin(self._base_url)}/prove/{self._network}"
491-
)
531+
# Prover base: {origin}/prove/{network} on the hosted API (set at
532+
# construction). Off the hosted API there is no prover unless configured.
533+
prover_uri = url or self._prover_uri
534+
if not prover_uri:
535+
return {
536+
"ok": False,
537+
"status": None,
538+
"error": {
539+
"message": (
540+
"No delegated prover configured for host "
541+
f"{self._origin!r}. Delegated proving is available on the "
542+
"Provable API (api.provable.com); for another endpoint pass "
543+
"HTTPProvider(prover_uri=...)."
544+
)
545+
},
546+
}
492547
# Build auth headers, optionally forcing a fresh JWT mint. The prover and
493548
# the hosted scanner share ONE consumer, and the auth server keeps a
494549
# single active JWT per consumer — so a scanner JWT mint invalidates the

sdk/python/aleo/facade/async_client.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,12 +231,21 @@ def _build_scanner(self) -> Any:
231231
from ..async_record_scanner import AsyncRecordScanner
232232
from .provider import scanner_base
233233

234+
provider = self._client.provider
235+
base = scanner_base(provider)
236+
if base is None:
237+
raise RuntimeError(
238+
"The hosted record scanner is only available on the Provable API "
239+
f"(api.provable.com); this client points at {provider.url!r}. "
240+
"Assign your own scanner (aleo.records.scanner = AsyncRecordScanner(...)) "
241+
"or a custom aleo.record_provider to scan against this endpoint."
242+
)
234243
return AsyncRecordScanner(
235-
scanner_base(self._client.provider),
236-
network=self._client.provider.network,
237-
api_key=self._client.provider.api_key,
238-
consumer_id=getattr(self._client.provider, "consumer_id", None),
239-
transport=getattr(self._client.provider, "_transport", None),
244+
base,
245+
network=provider.network,
246+
api_key=provider.api_key,
247+
consumer_id=getattr(provider, "consumer_id", None),
248+
transport=getattr(provider, "_transport", None),
240249
)
241250

242251
@property

sdk/python/aleo/facade/provider.py

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,29 @@
1010
from urllib.parse import urlparse
1111

1212
from ..network_client import AleoNetworkClient
13-
from .._client_common import DEFAULT_HOST, DEFAULT_NETWORK
13+
from .._client_common import DEFAULT_HOST, DEFAULT_NETWORK, is_provable_host
1414

1515
# AsyncAleoNetworkClient imported lazily to avoid pulling httpx at import time.
1616

1717
_VALID_NETWORKS = frozenset({"mainnet", "testnet"})
1818

1919

20-
def scanner_base(provider: "HTTPProvider") -> str:
21-
"""Derive the hosted record-scanner base from a provider's URL.
20+
def scanner_base(provider: "HTTPProvider") -> str | None:
21+
"""Derive the hosted record-scanner base from a provider's URL, or ``None``.
2222
23-
The scanner is a Provable *service* at the API origin under the ``/scanner``
24-
prefix (sibling to ``/prove`` and ``/jwts``), so — like the prover — it is
25-
derived from the endpoint origin, NOT the read node's ``/v2/{network}`` base.
26-
The :class:`~aleo.record_scanner.RecordScanner` appends ``/{network}``, so we
27-
return ``{scheme}://{host}/scanner`` (no network suffix). Users only ever
28-
configure the one endpoint URL; reads, proving, and scanning all derive from
29-
its origin.
23+
The hosted scanner is a Provable *service* at the API origin under the
24+
``/scanner`` prefix (sibling to ``/prove`` and ``/jwts``), derived from the
25+
origin — NOT the read node's ``/v2/{network}`` base. The
26+
:class:`~aleo.record_scanner.RecordScanner` appends ``/{network}``, so we
27+
return ``{scheme}://{host}/scanner`` (no network suffix).
28+
29+
Returns ``None`` off the hosted Provable API: the hosted scanner does not
30+
exist on devnode / local / custom nodes, so there is nothing to point at.
31+
Callers that need scanning against such an endpoint assign their own
32+
:class:`~aleo.record_scanner.RecordScanner` or ``RecordProvider``.
3033
"""
34+
if not is_provable_host(provider.url):
35+
return None
3136
parsed = urlparse(provider.url)
3237
return f"{parsed.scheme}://{parsed.netloc}/scanner"
3338

@@ -38,7 +43,12 @@ class HTTPProvider:
3843
Parameters
3944
----------
4045
url:
41-
Versioned API root, e.g. ``"https://api.provable.com/v2"``.
46+
API origin, e.g. ``"https://api.provable.com"`` (the default). For the
47+
hosted Provable API the SDK adds the service prefixes itself — reads at
48+
``/v2``, delegated proving at ``/prove``, hosted scanner at ``/scanner``,
49+
JWT auth at ``/jwts`` — so you never spell them out. Any other host
50+
(devnode, a local or custom node) is used as a literal read base, with no
51+
hosted prover/scanner wired up. A legacy ``".../v2"`` value still works.
4252
network:
4353
Network name — ``"mainnet"`` (default) or ``"testnet"``.
4454
api_key:

sdk/python/aleo/facade/records.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,16 @@ def _build_scanner(self) -> Any:
8080
from .provider import scanner_base
8181

8282
provider = self._client._provider
83+
base = scanner_base(provider)
84+
if base is None:
85+
raise RuntimeError(
86+
"The hosted record scanner is only available on the Provable API "
87+
f"(api.provable.com); this client points at {provider.url!r}. "
88+
"Assign your own scanner (aleo.records.scanner = RecordScanner(...)) "
89+
"or a custom aleo.record_provider to scan against this endpoint."
90+
)
8391
return RecordScanner(
84-
scanner_base(provider),
92+
base,
8593
network=provider.network,
8694
api_key=provider.api_key,
8795
consumer_id=getattr(provider, "consumer_id", None),

0 commit comments

Comments
 (0)