diff --git a/.agents/voice.md b/.agents/voice.md index a64c1258..75b1e900 100644 --- a/.agents/voice.md +++ b/.agents/voice.md @@ -55,6 +55,41 @@ Why it fails: filler ("easily", "powerfully", "seamless"); restates types; says "a result" instead of what you get and what you do with it; no side-effect or error information. +### Phrasings to avoid + +Beyond the filler above, these are banned outright. Each one reads like guidance +while leaving the caller with nothing to act on — replace it with the concrete +instruction, or delete it. + +- **"reach for"** — never use it. Say what to call and when. +- **"defensively"**, **"as appropriate"**, **"where necessary"** — name the + actual condition instead. + +```python +# Bad — sounds like advice, gives none +"""… either may be ``None``, so reach for them defensively.""" + +# Good — states the check and what it protects +"""… treat both as optional — guard with ``if entry.token0_info`` before +reading a field rather than assuming a default.""" +``` + +### Plain verbs + +Say what the code does, not what it is like. A service or object does not +"speak", "know", "want", or "see" — it returns, accepts, requires, stores, +decrypts. Figurative verbs read as style and cost the reader a translation step. + +```python +# Bad — figurative +"""The API speaks decimal amounts; the contract speaks base units.""" +"""Every pool the indexer knows.""" + +# Good — plain +"""The API returns decimal amounts; the contract takes base units.""" +"""Every pool the DEX lists.""" +``` + ## Naming in prose and examples - Use the Pythonic surface: properties (`key.address`, not `key.address()`), @@ -71,4 +106,5 @@ error information. This is a privacy chain. Do not document or add affordances that link signatures to signer addresses (no `recover`-style verb). When a feature shares secret material with a service (e.g. delegated record scanning shares the view -key), state that tradeoff plainly and point to the self-hosted alternative. +key), state that tradeoff plainly. Where an alternative exists, name the +supported extension point (e.g. assigning a custom ``RecordProvider``). diff --git a/.claude/skills/shield-swap/SKILL.md b/.claude/skills/shield-swap/SKILL.md index 46baaaae..9643bd48 100644 --- a/.claude/skills/shield-swap/SKILL.md +++ b/.claude/skills/shield-swap/SKILL.md @@ -5,7 +5,7 @@ description: Use when the user wants to trade, LP, or build on the shield_swap A Read `shield-swap-sdk/AGENTS.md` (generated from the SDK — always current) and follow its Tier 1 lifecycle and conversation pattern. Write Python -against the SDK; don't re-implement flows the verbs already provide, and +against the SDK; don't re-implement flows the methods already provide, and don't read SDK source unless AGENTS.md genuinely lacks the answer. Preconditions are enforced in code — on error, read the exception message; -it names the verb that fixes it. +it names the method that fixes it. diff --git a/README.md b/README.md index c4647d57..c0b5a71e 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ credits.functions.transfer_private(record, str(recipient.address), 1) \ .delegate(account) ``` -`aleo.record_provider` is swappable: set it to your own object implementing the `RecordProvider` protocol (`get_unspent_credits_record` / `find`) — e.g. a self-hosted scanner — and the whole facade (including private-fee auto-sourcing) uses it, with no view-key sharing. +`aleo.record_provider` is swappable: set it to your own object implementing the `RecordProvider` protocol (`get_unspent_credits_record` / `find`), and the whole facade (including private-fee auto-sourcing) uses it, with no view-key sharing. ## Async (`AsyncAleo`) diff --git a/sdk/Readme.md b/sdk/Readme.md index 3fb9b327..bc5145d5 100644 --- a/sdk/Readme.md +++ b/sdk/Readme.md @@ -162,7 +162,7 @@ credits.functions.transfer_private(record, str(recipient.address), 1) \ .delegate(account) ``` -`aleo.record_provider` is swappable: set it to your own object implementing the `RecordProvider` protocol (`get_unspent_credits_record` / `find`) — e.g. a self-hosted scanner — and the whole facade (including private-fee auto-sourcing) uses it, with no view-key sharing. +`aleo.record_provider` is swappable: set it to your own object implementing the `RecordProvider` protocol (`get_unspent_credits_record` / `find`), and the whole facade (including private-fee auto-sourcing) uses it, with no view-key sharing. ## Async (`AsyncAleo`) diff --git a/sdk/python/aleo/_client_common.py b/sdk/python/aleo/_client_common.py index f46142a8..8c570135 100644 --- a/sdk/python/aleo/_client_common.py +++ b/sdk/python/aleo/_client_common.py @@ -53,6 +53,15 @@ def is_provable_host(url: str) -> bool: def package_version() -> str: + """Return the installed SDK version, for the telemetry headers. + + Checks the current distribution name first, then the pre-0.2 name, so older + installs still report their real version. + + Returns: + The version string, or ``"0.0.0"`` when the package metadata cannot be + read (a source tree that was never installed). + """ from importlib.metadata import version # "aleo" is the pre-0.2 distribution name; keep it as a fallback so @@ -78,6 +87,13 @@ def user_agent() -> str: def make_default_headers() -> dict[str, str]: + """Build the SDK's baseline telemetry headers for a new client. + + Returns: + The version and environment headers every default-transport request + carries. Callers who pass their own ``headers`` replace these outright, + and a custom transport strips them — see :func:`method_headers`. + """ return { "X-Aleo-SDK-Version": package_version(), "X-Aleo-environment": "python", @@ -94,6 +110,19 @@ def method_headers( method: str, has_custom_transport: bool, ) -> dict[str, str]: + """Build the headers for one request, honouring custom-transport mode. + + Args: + headers: The client's configured headers. + method: SDK method name, sent as ``X-ALEO-METHOD`` so calls can be + attributed server-side. + has_custom_transport: True when the caller owns the HTTP layer, in which + case every SDK-internal header — including the method name and + ``User-Agent`` — is withheld so the caller controls the wire format. + + Returns: + The headers to send. + """ if has_custom_transport: return user_headers(headers) return {**headers, "X-ALEO-METHOD": method, "User-Agent": user_agent()} @@ -106,6 +135,11 @@ def jwt_origin(host: str) -> str: def now_ms() -> int: + """Return the current wall-clock time in epoch milliseconds. + + Matches the unit JWT expiries are stored in, so the two compare directly. + Wall-clock, not monotonic — do not use it to measure elapsed time. + """ return int(time.time() * 1000) @@ -118,6 +152,16 @@ def jwt_expired(jwt_data: dict[str, Any]) -> bool: def validate_block_range(start: int, end: int) -> None: + """Reject a block range the node would refuse, before sending it. + + Args: + start: First height of the range. + end: Last height of the range. + + Raises: + ValueError: If *start* is negative, exceeds *end*, or the span is wider + than the 50-block per-request cap. + """ if start < 0: raise ValueError("start must be >= 0") if start > end: diff --git a/sdk/python/aleo/_facade_common.py b/sdk/python/aleo/_facade_common.py index 164ffc9f..3af59cc1 100644 --- a/sdk/python/aleo/_facade_common.py +++ b/sdk/python/aleo/_facade_common.py @@ -5,10 +5,15 @@ """ from __future__ import annotations -from typing import Any, Protocol, runtime_checkable +from decimal import Decimal, ROUND_DOWN +from typing import Any, Protocol, Union, runtime_checkable _MICROCREDITS_PER_CREDIT: int = 1_000_000 +#: What a credits amount may be written as. ``str`` and ``Decimal`` are exact; +#: ``float`` is not (see :func:`credits_to_microcredits`). +CreditsAmount = Union[str, int, Decimal, float] + @runtime_checkable class RecordProvider(Protocol): @@ -17,9 +22,8 @@ class RecordProvider(Protocol): The default implementation is :class:`~aleo.facade.records.RecordsModule` (``aleo.records``), which wraps a delegated :class:`~aleo.record_scanner.RecordScanner`. Any object that satisfies this Protocol can be assigned to - ``aleo.record_provider`` — e.g. a self-hosted scanner wrapper — so callers - who do not want to share their view key with a hosted scanning service can - plug in their own source of records. + ``aleo.record_provider``, so callers who do not want to share their view key + with a hosted scanning service can plug in their own source of records. Implementations are consumed by :meth:`~aleo.facade.call.BoundCall.build_transaction` (and the rest of the verb ladder) to auto-source a credits record for a @@ -46,8 +50,44 @@ def find(self, **filters: Any) -> list[Any]: ... -def credits_to_microcredits(credits: float | int) -> int: - """Convert a credits amount (float or int) to integer microcredits. +def _to_decimal(credits: CreditsAmount) -> Decimal: + """Interpret a credits amount exactly. + + A ``float`` has already lost the value the caller wrote — ``1.005`` is really + ``1.00499999999999989...`` — so it is routed through ``str()``, which yields + the shortest representation that round-trips, i.e. the literal as typed. + ``str``, ``int``, and ``Decimal`` are exact already. + """ + if isinstance(credits, Decimal): + return credits + if isinstance(credits, int): + return Decimal(credits) + return Decimal(str(credits)) + + +def credits_to_microcredits( + credits: CreditsAmount, *, allow_rounding: bool = False +) -> int: + """Convert a credits amount to integer microcredits, exactly. + + Computed in decimal, never binary floating point: ``1.005`` credits is + 1_005_000 microcredits, where a float multiply would truncate to 1_004_999 + and silently lose value. Pass *credits* as ``str`` or ``Decimal`` for full + exactness; a ``float`` is interpreted as the decimal literal it prints as. + + Args: + credits: Credits amount — ``"1.005"``, ``Decimal("1.005")``, ``1``, or + ``1.005``. + allow_rounding: Permit input finer than one microcredit, truncating + toward zero. Off by default so lost precision is an error rather + than a silent underpayment. + + Returns: + The amount in microcredits. + + Raises: + ValueError: If *credits* carries sub-microcredit precision and + *allow_rounding* is False, or is not a usable number. Examples -------- @@ -55,18 +95,46 @@ def credits_to_microcredits(credits: float | int) -> int: 1000000 >>> credits_to_microcredits(1.5) 1500000 + >>> credits_to_microcredits("1.005") + 1005000 """ - return int(credits * _MICROCREDITS_PER_CREDIT) - - -def microcredits_to_credits(microcredits: int) -> float: - """Convert an integer microcredits amount to a credits float. + try: + scaled = _to_decimal(credits) * _MICROCREDITS_PER_CREDIT + except (ArithmeticError, ValueError, TypeError) as exc: + raise ValueError(f"{credits!r} is not a valid credits amount") from exc + if not scaled.is_finite(): + raise ValueError(f"{credits!r} is not a finite credits amount") + rounded = scaled.to_integral_value(rounding=ROUND_DOWN) + if scaled != rounded and not allow_rounding: + raise ValueError( + f"{credits!r} credits is {scaled} microcredits — finer than the " + "chain's smallest unit. Round it yourself, or pass " + "allow_rounding=True to truncate toward zero." + ) + return int(rounded) + + +def microcredits_to_credits(microcredits: int) -> Decimal: + """Convert integer microcredits to credits, exactly. + + Returns a :class:`~decimal.Decimal` rather than a float: microcredits are a + ``u64``, and past 2**53 a float cannot even hold the integer, so a float + round-trip is not the identity. Compares equal to the obvious float + (``microcredits_to_credits(1_500_000) == 1.5``), but mixing it into float + arithmetic raises — convert deliberately with ``float(...)`` if you want + that, accepting the precision loss. + + Args: + microcredits: Amount in microcredits. + + Returns: + The amount in credits, exact at any ``u64`` magnitude. Examples -------- >>> microcredits_to_credits(1_000_000) - 1.0 + Decimal('1.000000') >>> microcredits_to_credits(1_500_000) - 1.5 + Decimal('1.500000') """ - return microcredits / _MICROCREDITS_PER_CREDIT + return Decimal(int(microcredits)).scaleb(-6) diff --git a/sdk/python/aleo/_scanner_common.py b/sdk/python/aleo/_scanner_common.py index 55f64b7c..73498aad 100644 --- a/sdk/python/aleo/_scanner_common.py +++ b/sdk/python/aleo/_scanner_common.py @@ -53,6 +53,13 @@ def __init__( # --------------------------------------------------------------------------- class RecordsResponseFilter(TypedDict, total=False): + """Selects which fields the scanner returns for each encrypted record. + + Set a key to True to include that field. Every key is optional; omitting all + of them lets the service pick its defaults. Narrowing the projection is the + cheapest way to cut response size on a wide scan. + """ + block_height: bool block_timestamp: bool checksum: bool @@ -72,6 +79,19 @@ class RecordsResponseFilter(TypedDict, total=False): class RecordsFilter(TypedDict, total=False): + """Narrows an encrypted-record query by range, program, and paging. + + Every key is optional and they compose as an AND. ``start``/``end`` bound the + block range; ``programs``/``records``/``functions`` filter by origin (with + ``program``/``record`` as the singular forms); ``page`` and + ``results_per_page`` walk large result sets; ``spent`` selects by spend + status. ``response`` controls which fields come back — see + :class:`RecordsResponseFilter`. + + Note that the scanner applies this filter server-side, so its contents tell + the service what you are looking for. + """ + commitments: list[str] response: RecordsResponseFilter start: int @@ -87,6 +107,14 @@ class RecordsFilter(TypedDict, total=False): class OwnedRecordsResponseFilter(TypedDict, total=False): + """Selects which fields the scanner returns for each owned record. + + Set a key to True to include that field. Differs from + :class:`RecordsResponseFilter` by carrying the ownership-derived ``tag`` and + ``spent`` fields, which only exist once a record has been matched to a + registered view key. + """ + commitment: bool owner: bool tag: bool @@ -106,6 +134,17 @@ class OwnedRecordsResponseFilter(TypedDict, total=False): class OwnedFilter(TypedDict, total=False): + """Narrows an owned-record query for one registered UUID. + + ``uuid`` picks which registration to read; it defaults to the scanner object's + configured UUID when omitted. ``unspent`` drops already-spent records, ``nonces`` + restricts to specific record nonces, and ``decrypt`` asks the service to + return plaintext — which requires it to hold your view key. Prefer local + decryption (see ``set_decrypt_enabled``) to keep the plaintext off the wire. + + ``filter`` and ``responseFilter`` nest the query and projection controls. + """ + uuid: str unspent: bool decrypt: bool @@ -115,6 +154,14 @@ class OwnedFilter(TypedDict, total=False): class OwnedRecord(TypedDict, total=False): + """One record the scanner matched to a registered view key. + + Which keys are present depends on the projection requested via + :class:`OwnedRecordsResponseFilter`, so treat every field as optional and read + it with ``.get``. ``record_plaintext`` appears only once the record has been + decrypted — either locally or by the service. + """ + block_height: int block_timestamp: int commitment: str diff --git a/sdk/python/aleo/async_network_client.py b/sdk/python/aleo/async_network_client.py index 32e6cdb7..2ba8d2dd 100644 --- a/sdk/python/aleo/async_network_client.py +++ b/sdk/python/aleo/async_network_client.py @@ -165,6 +165,18 @@ def _net(self) -> Any: # ── Mutators ────────────────────────────────────────────────────────── def set_host(self, host: str) -> None: + """Re-point the client at a different API host. + + Re-derives the read base, origin, and the delegated prover/scanner bases + from *host*, discarding any prover or scanner URI set earlier — call + :meth:`set_prover_uri` / :meth:`set_record_scanner_uri` again afterwards + if you had overridden them. Local only; opens no connection. + + Args: + host: Versioned API root, e.g. ``"https://api.provable.com/v2"``. + Off the hosted Provable API the prover and scanner bases are + left unset, since those services exist only there. + """ read_host, origin, prover_default, scanner_default = self._resolve_urls(host) self._origin = origin self._base_url = origin @@ -173,15 +185,39 @@ def set_host(self, host: str) -> None: self._record_scanner_uri = scanner_default def set_prover_uri(self, prover_uri: str) -> None: + """Point delegated proving at a specific prover host. + + Use this to prove against a non-default prover; the current network name + is appended for you, so pass the base without it. + + Args: + prover_uri: Prover base without the network suffix, e.g. + ``"https://api.provable.com/prove"``. + """ self._prover_uri = f"{prover_uri}/{self._network}" def set_record_scanner_uri(self, record_scanner_uri: str) -> None: + """Point record scanning at a specific scanner host. + + The current network name is appended for you, so pass the base without + it. Note that delegating a scan shares your view key with the service. + + Args: + record_scanner_uri: Scanner base without the network suffix, e.g. + ``"https://api.provable.com/scanner"``. + """ self._record_scanner_uri = f"{record_scanner_uri}/{self._network}" def set_account(self, account: Any) -> None: + """Attach an account for calls that need one to sign or decrypt. + + Args: + account: The account to associate with this client. + """ self._account = account def get_account(self) -> Any: + """Return the account attached by :meth:`set_account`, or None if unset.""" return self._account @property @@ -200,12 +236,35 @@ def scanner_uri(self) -> str | None: return self._record_scanner_uri def set_header(self, name: str, value: str) -> None: + """Set a header sent with every subsequent request. + + Overwrites any existing value for *name*, including the SDK defaults. + + Args: + name: Header name. + value: Header value. + """ self.headers[name] = value def remove_header(self, name: str) -> None: + """Stop sending a header, if it was set. Absent names are ignored. + + Args: + name: Header name to drop. + """ self.headers.pop(name, None) def set_verbose_errors(self, verbose: bool) -> None: + """Choose whether broadcasts ask the node to pre-check the transaction. + + When enabled (the default), :meth:`submit_transaction` broadcasts with + ``check_transaction=true`` so the node reports why a transaction is + invalid instead of silently dropping it — at the cost of extra node-side + verification work. + + Args: + verbose: True to request the pre-check, False to broadcast bare. + """ self._verbose_errors = verbose # ── Internal HTTP ───────────────────────────────────────────────────── @@ -306,53 +365,220 @@ async def _ensure_jwt( # ── Block endpoints ─────────────────────────────────────────────────── async def get_block(self, height: int) -> Any: + """Fetch the block at *height*. + + Args: + height: Block height to read. + + Returns: + The block as decoded JSON. + + Raises: + AleoNetworkError: If no block exists at that height, or the node + rejects the request. + """ return await self._get(f"/block/{height}", "getBlock") async def get_block_by_hash(self, block_hash: str) -> Any: + """Fetch a block by its hash. + + Args: + block_hash: Block hash (``ab1…``). + + Returns: + The block as decoded JSON. + + Raises: + AleoNetworkError: If the hash matches no block on this network. + """ return await self._get(f"/block/{block_hash}", "getBlockByHash") async def get_block_range(self, start: int, end: int) -> list[Any]: + """Fetch a range of blocks in one request. + + Args: + start: First height to fetch; must be non-negative. + end: Last height of the range; must be at least *start*, and no more + than 50 above it. + + Returns: + The blocks in ascending height order. + + Raises: + ValueError: If *start* is negative, exceeds *end*, or the span is + wider than 50 blocks — checked locally, before any request. + AleoNetworkError: If the node rejects the request. + """ validate_block_range(start, end) return await self._get(f"/blocks?start={start}&end={end}", "getBlockRange") async def get_latest_block(self) -> Any: + """Fetch the newest block the node has. + + Returns: + The latest block as decoded JSON. + + Raises: + AleoNetworkError: If the node rejects the request. + """ return await self._get("/block/latest", "getLatestBlock") async def get_latest_height(self) -> int: + """Fetch the current chain tip height. + + Cheaper than :meth:`get_latest_block` when you only need the number. + + Returns: + The height of the newest block. + + Raises: + AleoNetworkError: If the node rejects the request. + """ return int(await self._get("/block/height/latest", "getLatestHeight")) async def get_latest_block_hash(self) -> str: + """Fetch the hash of the newest block. + + Returns: + The latest block hash (``ab1…``). + + Raises: + AleoNetworkError: If the node rejects the request. + """ return str(await self._get("/block/hash/latest", "getLatestBlockHash")) async def get_latest_committee(self) -> Any: + """Fetch the current validator committee. + + Returns: + The committee — members and their stake — as decoded JSON. + + Raises: + AleoNetworkError: If the node rejects the request. + """ return await self._get("/committee/latest", "getLatestCommittee") async def get_committee_by_height(self, height: int) -> Any: + """Fetch the validator committee as of *height*. + + Args: + height: Block height whose committee you want. + + Returns: + The committee at that height as decoded JSON. + + Raises: + AleoNetworkError: If the node has pruned that height or rejects the + request. + """ return await self._get(f"/committee/{height}", "getCommitteeByHeight") async def get_state_root(self) -> str: + """Fetch the latest global state root. + + The state root pins the chain state an offline query is built against — + see :class:`OfflineQuery`. + + Returns: + The current state root (``sr1…``). + + Raises: + AleoNetworkError: If the node rejects the request. + """ return str(await self._get("/stateRoot/latest", "getStateRoot")) async def get_state_paths(self, commitments: list[str]) -> list[Any]: + """Fetch inclusion proofs for record commitments. + + State paths are what let a proof assert that a record was in the global + state tree without revealing which one — needed to execute offline. + + Args: + commitments: Record commitments to prove inclusion for; each is + URL-escaped before it goes on the wire. + + Returns: + One state path per commitment, in the order requested. + + Raises: + AleoNetworkError: If any commitment is unknown to the node, or the + node rejects the request. + """ csv = ",".join(quote(c, safe="") for c in commitments) return await self._get(f"/statePaths?commitments={csv}", "getStatePaths") # ── Program endpoints ───────────────────────────────────────────────── async def get_program(self, program_id: str, edition: int | None = None) -> str: + """Fetch a program's Aleo instructions source. + + Args: + program_id: Program to read, e.g. ``"credits.aleo"``. + edition: Which amendment to read; omit for the newest one on chain. + + Returns: + The program source text. + + Raises: + AleoNetworkError: If the program (or that edition of it) is not + deployed on this network. + """ if edition is not None: return await self._get(f"/program/{program_id}/{edition}", "getProgramVersion") return await self._get(f"/program/{program_id}", "getProgramVersion") async def get_latest_program_edition(self, program_id: str) -> int: + """Fetch the newest edition number for a program. + + Pass the result to :meth:`get_program` to pin a read to the edition you + checked, rather than racing a later amendment. + + Args: + program_id: Program to read, e.g. ``"credits.aleo"``. + + Returns: + The newest edition number on chain. + + Raises: + AleoNetworkError: If the program is not deployed on this network. + """ raw = await self._get_raw(f"/program/{program_id}/latest_edition", "getLatestProgramEdition") return int(json.loads(raw)) async def get_program_amendment_count(self, program_id: str) -> Any: + """Fetch how many times a program has been amended. + + Args: + program_id: Program to read, e.g. ``"credits.aleo"``. + + Returns: + The amendment count as decoded JSON. + + Raises: + AleoNetworkError: If the program is not deployed on this network. + """ raw = await self._get_raw(f"/program/{program_id}/amendment_count", "getProgramAmendmentCount") return json.loads(raw) async def get_program_object(self, program_id: str, edition: int | None = None) -> Any: + """Fetch a program and parse it into a ``Program``. + + Use this over :meth:`get_program` when you want to inspect functions, + mappings, or imports rather than hold the raw text. The result belongs to + the extension module matching this client's network — mainnet and testnet + types are not interchangeable. + + Args: + program_id: Program to read, e.g. ``"credits.aleo"``. + edition: Which amendment to read; omit for the newest one on chain. + + Returns: + The parsed program. + + Raises: + AleoNetworkError: If the program is not deployed on this network. + ValueError: If the fetched source fails to parse. + """ Program = self._net().Program source = await self.get_program(program_id, edition) return Program.from_source(source) @@ -362,6 +588,26 @@ async def get_program_imports( program_id: str, imports: dict[str, str] | None = None, ) -> dict[str, str]: + """Fetch a program's full import closure, sources included. + + Walks imports depth-first, so an import's own imports are resolved before + it. Each distinct program is fetched once, but this still costs one + request per program in the closure. + + Args: + program_id: Program whose imports to resolve. + imports: Already-known ``{program_id: source}`` entries to treat as + fetched; mutated in place and returned. Pass a populated dict to + reuse a closure across calls. + + Returns: + Every transitive import as ``{program_id: source}``. The program + itself is not included. + + Raises: + AleoNetworkError: If the program or any of its imports is not + deployed on this network. + """ if imports is None: imports = {} source = await self.get_program(program_id) @@ -385,6 +631,20 @@ async def _collect_program_imports( return imports async def get_program_import_names(self, program_id: str) -> list[str]: + """Fetch the names a program imports directly. + + Unlike :meth:`get_program_imports` this does not recurse and does not + fetch the imported sources — one request, names only. + + Args: + program_id: Program whose imports to list. + + Returns: + The directly-imported program IDs, in declaration order. + + Raises: + AleoNetworkError: If the program is not deployed on this network. + """ Program = self._net().Program source = await self.get_program(program_id) prog = Program.from_source(source) @@ -393,6 +653,25 @@ async def get_program_import_names(self, program_id: str) -> list[str]: async def get_program_mapping_plaintext( self, program_id: str, mapping_name: str, key: str ) -> Any: + """Read a mapping entry and parse it into a ``Plaintext``. + + Use this over :meth:`get_program_mapping_value` when the value is a struct + or record and you want to index into it instead of parsing the string + yourself. + + Args: + program_id: Program that owns the mapping. + mapping_name: Mapping to read. + key: Mapping key, written as the Aleo literal the mapping is keyed by. + + Returns: + The parsed value, from the extension module matching this client's + network. + + Raises: + AleoNetworkError: If the program or mapping does not exist. + ValueError: If the returned value fails to parse as plaintext. + """ Plaintext = self._net().Plaintext raw = await self._get_raw( f"/program/{program_id}/mapping/{mapping_name}/{key}", @@ -402,22 +681,78 @@ async def get_program_mapping_plaintext( return Plaintext.from_string(_json.loads(raw)) async def get_transaction_object(self, tx_id: str) -> Any: + """Fetch a transaction and parse it into a ``Transaction``. + + Use this over :meth:`get_transaction` when you want to walk transitions, + inputs, or outputs as typed objects. + + Args: + tx_id: Transaction ID (``at1…``). + + Returns: + The parsed transaction, from the extension module matching this + client's network. + + Raises: + AleoNetworkError: If the node does not know this transaction. + ValueError: If the response fails to parse as a transaction. + """ Transaction = self._net().Transaction raw = await self._get_raw(f"/transaction/{tx_id}", "getTransactionObject") return Transaction.from_json(raw) async def get_program_mapping_names(self, program_id: str) -> list[str]: + """Fetch the names of a program's mappings. + + Args: + program_id: Program whose mappings to list. + + Returns: + The mapping names declared by the program. + + Raises: + AleoNetworkError: If the program is not deployed on this network. + """ return await self._get(f"/program/{program_id}/mappings", "getProgramMappingNames") async def get_program_mapping_value( self, program_id: str, mapping_name: str, key: str ) -> str: + """Read one entry out of a program mapping. + + Args: + program_id: Program that owns the mapping, e.g. ``"credits.aleo"``. + mapping_name: Mapping to read, e.g. ``"account"``. + key: Mapping key, written as the Aleo literal the mapping is keyed by + (an address for ``credits.aleo/account``). + + Returns: + The stored value as a string. An unset key reads back as the mapping's + zero value rather than raising. + + Raises: + AleoNetworkError: If the program or mapping does not exist, or the key + is malformed for the mapping's key type. + """ return await self._get( f"/program/{program_id}/mapping/{mapping_name}/{key}", "getProgramMappingValue", ) async def get_public_balance(self, address: str) -> int: + """Read an address's public ``credits.aleo`` balance. + + Public balance only — credits held privately in records are invisible + here, so a funded account can legitimately report 0. + + Args: + address: The address to look up (``aleo1…``). + + Returns: + The balance in microcredits, or 0 if the account has no public + balance. Network failures also read back as 0 rather than raising, + so do not use this to probe whether the node is reachable. + """ try: val = await self.get_program_mapping_value("credits.aleo", "account", address) return int(val) if val else 0 @@ -427,23 +762,97 @@ async def get_public_balance(self, address: str) -> int: # ── Transaction endpoints ───────────────────────────────────────────── async def get_transaction(self, tx_id: str) -> Any: + """Fetch a transaction by ID. + + Returns the transaction whether or not it has been confirmed; use + :meth:`get_confirmed_transaction` when you need its on-chain outcome. + + Args: + tx_id: Transaction ID (``at1…``). + + Returns: + The transaction as decoded JSON. + + Raises: + AleoNetworkError: If the node does not know this transaction. + """ return await self._get(f"/transaction/{tx_id}", "getTransaction") async def get_confirmed_transaction(self, tx_id: str) -> Any: + """Fetch a transaction along with its confirmed outcome. + + Args: + tx_id: Transaction ID (``at1…``). + + Returns: + The confirmed transaction as decoded JSON, including the ``status`` + field that distinguishes an accepted transaction from a rejected one. + + Raises: + AleoNetworkError: If the transaction is still unconfirmed or unknown + to the node. + """ return await self._get(f"/transaction/confirmed/{tx_id}", "getConfirmedTransaction") async def get_transactions(self, block_height: int) -> list[Any]: + """Fetch every transaction in one block. + + Args: + block_height: Height of the block to read. + + Returns: + The block's transactions as decoded JSON; empty if the block held + none. + + Raises: + AleoNetworkError: If no block exists at that height. + """ return await self._get(f"/block/{block_height}/transactions", "getTransactions") async def get_transactions_in_mempool(self) -> list[Any]: + """Fetch the transactions this node is holding unconfirmed. + + Mempool contents are per-node and change constantly — a transaction + missing here may still be in flight elsewhere. + + Returns: + The node's pending transactions as decoded JSON. + + Raises: + AleoNetworkError: If the node rejects the request — public nodes + often decline to expose their mempool. + """ return await self._get("/memoryPool/transactions", "getTransactionsInMempool") async def get_transition_id(self, input_or_output_id: str) -> str: + """Find which transition produced or consumed an input/output ID. + + Args: + input_or_output_id: A transition input or output ID to trace. + + Returns: + The enclosing transition ID (``au1…``). + + Raises: + AleoNetworkError: If nothing on chain matches that ID. + """ return await self._get( f"/find/transitionID/{input_or_output_id}", "getTransitionId" ) async def get_deployment_transaction_id_for_program(self, program_id: str) -> str: + """Find the transaction that deployed a program. + + Args: + program_id: Deployed program, e.g. ``"credits.aleo"``. + + Returns: + The deployment transaction ID (``at1…``), with the node's surrounding + JSON quotes stripped. + + Raises: + AleoNetworkError: If the program is not deployed on this network. + """ raw = await self._get( f"/find/transactionID/deployment/{program_id}", "getDeploymentTransactionIDForProgram", @@ -451,12 +860,47 @@ async def get_deployment_transaction_id_for_program(self, program_id: str) -> st return strip_quotes(str(raw)) async def get_deployment_transaction_for_program(self, program_id: str) -> Any: + """Fetch the deployment transaction for a program. Costs two requests. + + Resolves the deployment ID, then fetches that transaction. + + Args: + program_id: Deployed program, e.g. ``"credits.aleo"``. + + Returns: + The deployment transaction as decoded JSON. + + Raises: + AleoNetworkError: If the program is not deployed on this network, or + the node cannot return its deployment transaction. + """ tx_id = await self.get_deployment_transaction_id_for_program(program_id) return await self.get_transaction(tx_id) # ── POST endpoints ──────────────────────────────────────────────────── async def submit_transaction(self, transaction: Any) -> str: + """Broadcast a proven transaction to the network. + + This spends the transaction's fee and is not reversible. Returning + successfully means the node accepted the broadcast, NOT that the + transaction was confirmed — pass the ID to + :meth:`wait_for_transaction_confirmation` for that. + + When verbose errors are on (the default) the node pre-checks the + transaction and explains rejections; see :meth:`set_verbose_errors`. + + Args: + transaction: The proven transaction; stringified before broadcast, so + either a ``Transaction`` or its serialized form works. + + Returns: + The broadcast transaction ID (``at1…``). + + Raises: + AleoNetworkError: If the node rejects the transaction — invalid proof, + insufficient fee, or a stale state root. + """ tx_str = str(transaction) endpoint = ( f"{self._host}/transaction/broadcast?check_transaction=true" @@ -467,6 +911,18 @@ async def submit_transaction(self, transaction: Any) -> str: return json.loads(resp.text) async def submit_solution(self, solution: str) -> str: + """Broadcast a prover solution to the network. + + Args: + solution: The serialized solution to submit. + + Returns: + The accepted solution's ID. + + Raises: + AleoNetworkError: If the node rejects the solution — stale epoch or + insufficient proof target. + """ resp = await self._post( f"{self._host}/solution/broadcast", solution, "submitSolution" ) @@ -480,6 +936,33 @@ async def wait_for_transaction_confirmation( check_interval: float = 2.0, timeout: float = 45.0, ) -> Any: + """Poll until a transaction is confirmed, yielding to the event loop. + + Sleeps with :func:`asyncio.sleep` between polls, so other tasks keep + running while this one waits. + + Polls the confirmed-transaction endpoint every *check_interval* seconds. + Transient failures — including the 404s expected while the transaction is + still propagating — are swallowed and retried; only an outright rejection + or a malformed ID stops the loop early. + + Args: + tx_id: Transaction ID to watch (``at1…``). + check_interval: Seconds to wait between polls. + timeout: Seconds to keep polling before giving up. Exceeding it means + the transaction never appeared, not that it failed — it may still + confirm afterwards. + + Returns: + The confirmed transaction as decoded JSON, once its status is + ``accepted``. + + Raises: + TimeoutError: If the transaction has not been confirmed within + *timeout* seconds. + AleoNetworkError: If the network rejected the transaction, or *tx_id* + is malformed. + """ import asyncio import time as _time @@ -528,6 +1011,7 @@ async def submit_proving_request_safe( consumer_id: str | None = None, jwt_data: dict[str, Any] | None = None, ) -> dict[str, Any]: + """Submit a proving request, returning {ok, data|error, status}. Never raises on HTTP errors.""" # Prover base: {origin}/prove/{network} on the hosted API (set at # construction). Off the hosted API there is no prover unless configured. prover_uri = url or self._prover_uri @@ -645,6 +1129,7 @@ async def submit_proving_request( consumer_id: str | None = None, jwt_data: dict[str, Any] | None = None, ) -> Any: + """Submit a proving request, raising AleoProvingError on failure.""" result = await self.submit_proving_request_safe( proving_request, url=url, diff --git a/sdk/python/aleo/async_record_scanner.py b/sdk/python/aleo/async_record_scanner.py index 628af59a..c55c3f14 100644 --- a/sdk/python/aleo/async_record_scanner.py +++ b/sdk/python/aleo/async_record_scanner.py @@ -124,18 +124,78 @@ def __init__( # ── Mutators ───────────────────────────────────────────────────────── def set_api_key(self, api_key: str | tuple[str, str] | dict[str, str]) -> None: + """Set the API key used to mint and refresh JWTs. + + Paired with :meth:`set_consumer_id`; with both set the scanner refreshes + its own JWT as needed. Local only — no request is made here. + + Parameters + ---------- + api_key: + The key as a bare string, a ``(header, value)`` pair, or a + ``{"header": ..., "value": ...}`` dict. A bare string uses the + default Provable API-key header. + """ self._api_key = normalize_api_key(api_key) def set_consumer_id(self, consumer_id: str) -> None: + """Set the consumer whose JWTs this scanner mints. + + Note that the scanner and the delegated prover share one consumer, and the + auth server keeps a single active JWT per consumer — so minting here can + invalidate the prover's cached JWT, and vice versa. Both sides re-mint on + rejection, so this self-heals. + + Parameters + ---------- + consumer_id: + The consumer ID registered with the auth service. + """ self.consumer_id = consumer_id def set_jwt_data(self, jwt_data: dict[str, Any] | None) -> None: + """Install a pre-minted JWT, skipping the next refresh. + + Parameters + ---------- + jwt_data: + ``{"jwt": str, "expiration": int}`` with the expiry in epoch + milliseconds. A JWT within five minutes of expiring is treated as + already expired and refreshed. Pass ``None`` to clear the cached + token and force a fresh mint on the next call. + """ self.jwt_data = jwt_data def set_auto_re_register(self, enabled: bool) -> None: + """Choose whether a dropped registration is silently re-established. + + When enabled, a 422 — the service reporting this UUID is not registered — + triggers a re-register and one retry, which re-sends the view key to the + service. + + Parameters + ---------- + enabled: + True to re-register and retry, False to surface the 422 as an error. + """ self.auto_re_register = enabled def set_decrypt_enabled(self, enabled: bool) -> None: + """Choose whether fetched records are decrypted in place. + + Decryption is local, using a stored view key — enabling it sends nothing + extra to the service. :meth:`owned` decrypts opportunistically, silently + leaving records encrypted when no view key is stored for the UUID; + :meth:`find_credits_record` and :meth:`find_credits_records` instead + require this to be on. + + Parameters + ---------- + enabled: + True to decrypt in place. While False, the ``find_credits_record*`` + helpers raise :exc:`DecryptionNotEnabledError` rather than returning + ciphertext they cannot search. + """ self.decrypt_enabled = enabled def add_view_key(self, view_key: Any) -> None: @@ -144,6 +204,18 @@ def add_view_key(self, view_key: Any) -> None: self._view_keys[str(uuid_field)] = view_key def remove_view_key(self, uuid: str) -> None: + """Forget a stored view key, so it can no longer decrypt records. + + Only drops the local copy — a registration already lodged with the hosted + scanner still holds that view key; call ``revoke()`` to withdraw it. + Unknown UUIDs are ignored. + + Parameters + ---------- + uuid: + UUID string the key was stored under, as computed by + :meth:`add_view_key`. + """ self._view_keys.pop(uuid, None) def set_uuid(self, key_material: Any) -> None: diff --git a/sdk/python/aleo/codegen/__main__.py b/sdk/python/aleo/codegen/__main__.py index 274f6cb0..28db5a9f 100644 --- a/sdk/python/aleo/codegen/__main__.py +++ b/sdk/python/aleo/codegen/__main__.py @@ -24,6 +24,20 @@ def _generate(abi_path: Path, out_path: Path) -> None: def main(argv: list[str] | None = None) -> int: + """Run the codegen CLI, writing generated modules to disk. + + Args: + argv: Command-line arguments; defaults to ``sys.argv[1:]``. + + Returns: + A process exit status — 0 on success, 1 if an ABI was unreadable or a + struct reference could not be resolved. Those failures are printed to + stderr rather than raised. + + Raises: + SystemExit: If neither ``--config`` nor both of ``--abi``/``--out`` were + given; argparse reports the usage error and exits. + """ p = argparse.ArgumentParser(prog="aleo.codegen") p.add_argument("--abi", type=Path, help="path to ABI JSON") p.add_argument("--out", type=Path, help="output .py path") diff --git a/sdk/python/aleo/codegen/_emit.py b/sdk/python/aleo/codegen/_emit.py index 68d3f2ed..9a8480d5 100644 --- a/sdk/python/aleo/codegen/_emit.py +++ b/sdk/python/aleo/codegen/_emit.py @@ -163,6 +163,11 @@ def _toposort(structs: list[dict[str, Any]]) -> list[dict[str, Any]]: seen: set[str] = set() def visit(name: str) -> None: + """Append *name* after its dependencies, skipping repeats. + + Names absent from this ABI are ignored — cross-program references are + rejected earlier, by ``_check_struct_refs``. + """ if name in seen or name not in by_name: return seen.add(name) @@ -224,6 +229,17 @@ class that is never generated (NameError at import/decode time), and two local = set(names) def check(ty: Any, context: str) -> None: + """Assert every struct *ty* references is defined in this program. + + Args: + ty: An ABI type to walk for struct references. + context: Where the type came from, quoted in the error so a failure + names the offending field. + + Raises: + ValueError: If a reference is undefined or points at another program — + the generated class would not exist. + """ for entry in _iter_struct_refs(ty): ref = entry["Struct"] name, prog = ref["path"][-1], ref.get("program", program) diff --git a/sdk/python/aleo/encryptor.py b/sdk/python/aleo/encryptor.py index e4c5379c..e32ca8c5 100644 --- a/sdk/python/aleo/encryptor.py +++ b/sdk/python/aleo/encryptor.py @@ -1,3 +1,9 @@ +"""Password-based encryption for Aleo private keys. + +Wraps a private key in a ciphertext that only the original secret reopens, so key +material can be written to disk or shipped between processes. All local — nothing +here touches the network. +""" from __future__ import annotations # Intentionally network-agnostic: private-key encryption (keys/ciphertext) does diff --git a/sdk/python/aleo/facade/async_client.py b/sdk/python/aleo/facade/async_client.py index 1cabd8d9..4aca3e21 100644 --- a/sdk/python/aleo/facade/async_client.py +++ b/sdk/python/aleo/facade/async_client.py @@ -32,10 +32,15 @@ import asyncio from contextlib import asynccontextmanager +from decimal import Decimal from typing import Any, AsyncGenerator from .._client_common import AleoNetworkError -from .._facade_common import credits_to_microcredits, microcredits_to_credits +from .._facade_common import ( + CreditsAmount, + credits_to_microcredits, + microcredits_to_credits, +) from .._scanner_common import OwnedRecord, RecordNotFoundError from .errors import ( ExecutionError, @@ -116,6 +121,7 @@ def raw(self) -> Any: @property def source(self) -> str: + """The program's Leo/Aleo source text.""" return str(self._raw.source) @property @@ -257,6 +263,17 @@ def scanner(self) -> Any: @scanner.setter def scanner(self, scanner: Any) -> None: + """Replace the underlying async record scanner. + + Assigning here bypasses the lazy default, so the client's provider config + is not consulted afterwards. + + Parameters + ---------- + scanner: + A pre-configured + :class:`~aleo.async_record_scanner.AsyncRecordScanner`. + """ self._scanner = scanner async def register(self, account: Any, start: int = 0) -> dict[str, Any]: @@ -268,9 +285,18 @@ async def register(self, account: Any, start: int = 0) -> dict[str, Any]: return await scanner.register(account.view_key, start) async def revoke(self) -> dict[str, Any]: + """Revoke the registered account's scanning registration. + + Delegates to :meth:`~aleo.async_record_scanner.AsyncRecordScanner.revoke` using the + scanner's configured UUID (derived from the registered account's view key). + """ return await self.scanner.revoke() async def status(self) -> dict[str, Any]: + """Return the scanning status for the registered account. + + Delegates to :meth:`~aleo.async_record_scanner.AsyncRecordScanner.status`. + """ return await self.scanner.status() async def find( @@ -407,43 +433,183 @@ def _nc(self) -> Any: return self._client.network_client async def get_latest_height(self) -> int: + """Return the latest block height. + + Returns + ------- + int + The current chain height. + """ return int(await self._nc().get_latest_height()) async def get_latest_block(self) -> Any: + """Return the latest block object (raw JSON dict). + + Returns + ------- + Any + Latest block as returned by the node. + """ return await self._nc().get_latest_block() async def get_block(self, height: int) -> Any: + """Return the block at *height*. + + Parameters + ---------- + height: + Block height (non-negative integer). + + Returns + ------- + Any + Block data dict. + """ return await self._nc().get_block(height) async def get_block_by_hash(self, block_hash: str) -> Any: + """Return the block identified by *block_hash*. + + Parameters + ---------- + block_hash: + Hex block hash string. + + Returns + ------- + Any + Block data dict. + """ return await self._nc().get_block_by_hash(block_hash) async def get_block_range(self, start: int, end: int) -> list[Any]: + """Return blocks in the range [*start*, *end*]. + + Parameters + ---------- + start: + Inclusive start height. + end: + Inclusive end height. + + Returns + ------- + list[Any] + Sequence of block dicts. + """ return await self._nc().get_block_range(start, end) async def get_latest_block_hash(self) -> str: + """Return the hash of the latest block. + + Returns + ------- + str + Block hash string. + """ return str(await self._nc().get_latest_block_hash()) async def get_state_root(self) -> str: + """Return the latest state root. + + Returns + ------- + str + State root string (``"sr1…"``). + """ return str(await self._nc().get_state_root()) async def get_program(self, program_id: str, edition: int | None = None) -> str: + """Return the Leo source for *program_id*. + + Parameters + ---------- + program_id: + Aleo program identifier (e.g. ``"credits.aleo"``). + edition: + Optional edition number. + + Returns + ------- + str + Program source text. + """ return await self._nc().get_program(program_id, edition) async def get_program_mapping_names(self, program_id: str) -> list[str]: + """Return the mapping names defined in *program_id*. + + Parameters + ---------- + program_id: + Aleo program identifier. + + Returns + ------- + list[str] + List of mapping names. + """ return await self._nc().get_program_mapping_names(program_id) async def get_program_mapping_value( self, program_id: str, mapping_name: str, key: str ) -> str: + """Return the current value at (*program_id*, *mapping_name*, *key*). + + Parameters + ---------- + program_id: + Aleo program identifier. + mapping_name: + Name of the mapping. + key: + Mapping key. + + Returns + ------- + str + Serialised mapping value. + """ return await self._nc().get_program_mapping_value( program_id, mapping_name, key ) async def get_public_balance(self, address: str) -> int: + """Return the public credits balance for *address* in microcredits. + + Queries the ``credits.aleo`` ``account`` mapping. Returns ``0`` when + the address has no balance or the mapping value is absent. + + Parameters + ---------- + address: + Aleo address string (``"aleo1…"``). + + Returns + ------- + int + Balance in microcredits. + """ return int(await self._nc().get_public_balance(address)) async def get_transaction(self, tx_id: str) -> Any: + """Return the (possibly unconfirmed) transaction for *tx_id*. + + Parameters + ---------- + tx_id: + Transaction ID string. + + Returns + ------- + Any + Transaction data dict. + + Raises + ------ + TransactionNotFound + If no transaction exists for *tx_id* (a 404 from the node). + """ try: return await self._nc().get_transaction(tx_id) except AleoNetworkError as exc: @@ -452,6 +618,23 @@ async def get_transaction(self, tx_id: str) -> Any: raise async def get_confirmed_transaction(self, tx_id: str) -> Any: + """Return the confirmed transaction for *tx_id*. + + Parameters + ---------- + tx_id: + Transaction ID string. + + Returns + ------- + Any + Confirmed transaction data dict. + + Raises + ------ + TransactionNotFound + If no confirmed transaction exists for *tx_id* (a 404 from the node). + """ try: return await self._nc().get_confirmed_transaction(tx_id) except AleoNetworkError as exc: @@ -472,9 +655,36 @@ async def get_transaction_object(self, tx_id: str) -> Any: raise async def get_transactions(self, block_height: int) -> list[Any]: + """Return all transactions in the block at *block_height*. + + Parameters + ---------- + block_height: + Block height to query. + + Returns + ------- + list[Any] + List of transaction dicts. + """ return await self._nc().get_transactions(block_height) async def submit_transaction(self, transaction: Any) -> str: + """Broadcast *transaction* to the network and return the transaction ID. + + Accepts either a :class:`Transaction` object (anything with a + ``__str__`` serialisation the node accepts) or a raw JSON string. + + Parameters + ---------- + transaction: + A :class:`Transaction` object or a serialised transaction string. + + Returns + ------- + str + The transaction ID returned by the node. + """ return str(await self._nc().submit_transaction(transaction)) send_raw_transaction = submit_transaction @@ -486,6 +696,36 @@ async def wait_for_transaction( timeout: float = 45.0, poll_interval: float = 2.0, ) -> Any: + """Poll until *tx_id* is confirmed, then return the transaction data. + + Delegates to + :meth:`~aleo.async_network_client.AsyncAleoNetworkClient.wait_for_transaction_confirmation` + and maps its :exc:`TimeoutError` to the facade-typed + :exc:`~aleo.facade.errors.TransactionConfirmationTimeout`. + + Parameters + ---------- + tx_id: + Transaction ID to wait on. + timeout: + Maximum seconds to wait before raising + :exc:`~aleo.facade.errors.TransactionConfirmationTimeout`. + Default is 45 s. + poll_interval: + Seconds between polls. Default is 2 s. + + Returns + ------- + Any + Confirmed transaction data dict. + + Raises + ------ + TransactionConfirmationTimeout + If the transaction is not confirmed within *timeout* seconds. + AleoNetworkError + If the node explicitly rejects the transaction. + """ try: return await self._nc().wait_for_transaction_confirmation( tx_id, @@ -888,6 +1128,7 @@ def __init__(self, provider: object) -> None: @property def provider(self) -> HTTPProvider: + """The :class:`~aleo.facade.provider.HTTPProvider` used to build this client.""" return self._provider @property @@ -911,10 +1152,19 @@ def process(self) -> Any: @property def default_account(self) -> Any: + """The default account used when a verb omits a signer.""" return self._default_account @default_account.setter def default_account(self, account: Any) -> None: + """Set the account verbs fall back to when no signer is passed. + + Parameters + ---------- + account: + The account to sign with by default. Set to ``None`` to require an + explicit signer on every verb. + """ self._default_account = account # ── Record provider ──────────────────────────────────────────────────── @@ -926,6 +1176,15 @@ def record_provider(self) -> Any: @record_provider.setter def record_provider(self, provider: Any) -> None: + """Replace the provider that auto-sources records for private fees. + + Parameters + ---------- + provider: + An async record provider — its ``get_unspent_credits_record`` must be + ``async def``. ``None`` disables automatic sourcing, which makes + private fees require an explicit ``fee_record``. + """ self._record_provider = provider # ── Network identity (sync — local) ──────────────────────────────────── @@ -942,6 +1201,11 @@ def network_id(self) -> int: @property def network_name(self) -> str: + """Human-readable network name string. + + Returns ``"mainnet"`` or ``"testnet"`` (the normalised provider + value, not the full snarkvm network display name). + """ return self._provider.network # ── Connectivity (async) ──────────────────────────────────────────────── @@ -977,15 +1241,54 @@ async def get_balance(self, address: str) -> int: # ── Unit conversions (sync) ───────────────────────────────────────────── - def to_microcredits(self, credits: float | int) -> int: - return credits_to_microcredits(credits) + def to_microcredits( + self, credits: CreditsAmount, *, allow_rounding: bool = False + ) -> int: + """Convert a credits amount to integer microcredits, exactly. + + Local and synchronous. See :meth:`Aleo.to_microcredits` — computed in + decimal, so ``1.005`` gives 1_005_000 rather than 1_004_999. - def from_microcredits(self, microcredits: int) -> float: + Parameters + ---------- + credits: + Credits amount; ``str`` and ``Decimal`` are exact. + allow_rounding: + Permit input finer than one microcredit, truncating toward zero. + + Raises + ------ + ValueError + If *credits* is finer than a microcredit and *allow_rounding* is + False, or is not a usable number. + """ + return credits_to_microcredits(credits, allow_rounding=allow_rounding) + + def from_microcredits(self, microcredits: int) -> Decimal: + """Convert an integer microcredits amount to credits, exactly. + + Local and synchronous. Returns a :class:`~decimal.Decimal` — see + :meth:`Aleo.from_microcredits` for why a float will not do. + + Parameters + ---------- + microcredits: + Integer microcredits (e.g. ``1_500_000``). + """ return microcredits_to_credits(microcredits) # ── Address validation (sync) ─────────────────────────────────────────── def is_valid_address(self, s: str) -> bool: + """Return ``True`` if *s* is a valid Aleo address. + + Wraps the network module's ``Address.is_valid`` class method. + + Parameters + ---------- + s: + The candidate address string. + """ try: net = self._provider.network if net == "testnet": diff --git a/sdk/python/aleo/facade/client.py b/sdk/python/aleo/facade/client.py index 32998f48..e626c03b 100644 --- a/sdk/python/aleo/facade/client.py +++ b/sdk/python/aleo/facade/client.py @@ -7,10 +7,15 @@ """ from __future__ import annotations +from decimal import Decimal from typing import Any from .._client_common import AleoNetworkError -from .._facade_common import credits_to_microcredits, microcredits_to_credits +from .._facade_common import ( + CreditsAmount, + credits_to_microcredits, + microcredits_to_credits, +) from .provider import HTTPProvider # AleoNetworkClient imported at runtime (avoid circular at module-level) @@ -99,6 +104,14 @@ def default_account(self) -> Any: @default_account.setter def default_account(self, account: Any) -> None: + """Set the account verbs fall back to when no signer is passed. + + Parameters + ---------- + account: + The account to sign with by default. Set to ``None`` to require an + explicit signer on every verb. + """ self._default_account = account # ── Record provider ────────────────────────────────────────────────────── @@ -108,15 +121,23 @@ def record_provider(self) -> Any: """The :class:`~aleo._facade_common.RecordProvider` used to auto-source records. Defaults to :attr:`records` (``aleo.records``), which wraps a delegated - record scanner. Assign a custom provider (e.g. a self-hosted scanner - wrapper) to keep your view key private, or set it to ``None`` to disable - automatic record sourcing (private fees then require an explicit - ``fee_record``). + record scanner. Assign a custom provider to keep your view key out of + that service, or set it to ``None`` to disable automatic record sourcing + (private fees then require an explicit ``fee_record``). """ return self._record_provider @record_provider.setter def record_provider(self, provider: Any) -> None: + """Replace the provider that auto-sources records for private fees. + + Parameters + ---------- + provider: + A :class:`~aleo._facade_common.RecordProvider`, or ``None`` to disable + automatic sourcing — private fees then require an explicit + ``fee_record``. + """ self._record_provider = provider # ── Network identity ─────────────────────────────────────────────────── @@ -193,20 +214,41 @@ def get_balance(self, address: str) -> int: # ── Unit conversions ─────────────────────────────────────────────────── - def to_microcredits(self, credits: float | int) -> int: - """Convert a credits amount to integer microcredits. + def to_microcredits( + self, credits: CreditsAmount, *, allow_rounding: bool = False + ) -> int: + """Convert a credits amount to integer microcredits, exactly. - ``1 credit == 1_000_000 microcredits`` + ``1 credit == 1_000_000 microcredits``. Computed in decimal, so + ``1.005`` gives 1_005_000 rather than the 1_004_999 a binary float + multiply would truncate to. Parameters ---------- credits: - Credits amount as a float or integer (e.g. ``1.5``). + Credits amount. ``str`` and ``Decimal`` are exact; a ``float`` is + read as the decimal literal it prints as, so prefer ``"1.005"`` over + ``1.005`` when the amount comes from text. + allow_rounding: + Permit input finer than one microcredit, truncating toward zero. + Off by default, so precision loss raises instead of silently + underpaying. + + Raises + ------ + ValueError + If *credits* is finer than a microcredit and *allow_rounding* is + False, or is not a usable number. """ - return credits_to_microcredits(credits) + return credits_to_microcredits(credits, allow_rounding=allow_rounding) + + def from_microcredits(self, microcredits: int) -> Decimal: + """Convert an integer microcredits amount to credits, exactly. - def from_microcredits(self, microcredits: int) -> float: - """Convert an integer microcredits amount to credits. + Returns a :class:`~decimal.Decimal`, not a float — microcredits are a + ``u64`` and past 2**53 a float cannot hold the integer. It compares + equal to the obvious float, but mixing it into float arithmetic raises; + call ``float(...)`` deliberately if you want that. Parameters ---------- diff --git a/sdk/python/aleo/facade/records.py b/sdk/python/aleo/facade/records.py index 44542847..4b0235ae 100644 --- a/sdk/python/aleo/facade/records.py +++ b/sdk/python/aleo/facade/records.py @@ -13,9 +13,8 @@ scanner is a *hosted* service, so :meth:`RecordsModule.register` sends your account's **view key** (sealed-box encrypted in transit) to that service — which can then decrypt every record you own. This is a real privacy - tradeoff. If you do not want to share your view key with a hosted scanner, - point ``aleo.records.scanner`` at a **self-hosted** endpoint, or assign your - own :class:`~aleo._facade_common.RecordProvider` implementation to + tradeoff. To keep the view key out of the service, assign your own + :class:`~aleo._facade_common.RecordProvider` implementation to ``aleo.record_provider``. """ from __future__ import annotations @@ -37,8 +36,7 @@ class RecordsModule: This module talks to a **delegated record scanner**. Registering an account (:meth:`register`) shares that account's **view key** with the scanning service, which can then decrypt every record the account owns. - For a self-custodial alternative, repoint :attr:`scanner` at a - self-hosted endpoint or assign a custom + To avoid that, assign a custom :class:`~aleo._facade_common.RecordProvider` to ``aleo.record_provider``. Parameters @@ -73,8 +71,8 @@ def _build_scanner(self) -> Any: (see :func:`~aleo.facade.provider.scanner_base`); its base, creds (api key + consumer id, shared with the delegated prover), network and transport are all derived from the client's - :class:`~aleo.facade.provider.HTTPProvider`. Point :attr:`scanner` - elsewhere to use a self-hosted scanning endpoint. + :class:`~aleo.facade.provider.HTTPProvider`. Assign :attr:`scanner` to + replace it. """ from ..record_scanner import RecordScanner from .provider import scanner_base @@ -101,8 +99,7 @@ def scanner(self) -> Any: """The underlying :class:`~aleo.record_scanner.RecordScanner` (escape hatch). Built lazily on first access from the client's provider config. Assign a - pre-configured scanner (e.g. one pointed at a self-hosted endpoint) to - override the default hosted service and keep your view key private. + pre-configured scanner to override the default. """ if self._scanner is None: self._scanner = self._build_scanner() @@ -110,6 +107,16 @@ def scanner(self) -> Any: @scanner.setter def scanner(self, scanner: Any) -> None: + """Replace the underlying record scanner. + + Assigning here bypasses the lazy default, so the client's provider config + is not consulted afterwards. + + Parameters + ---------- + scanner: + A pre-configured :class:`~aleo.record_scanner.RecordScanner`. + """ self._scanner = scanner # ── Registration / lifecycle ───────────────────────────────────────────── @@ -127,10 +134,9 @@ def register(self, account: Any, start: int = 0) -> dict[str, Any]: **view key** (sealed-box encrypted in transit) to the scanning service so it can decrypt the records you own on your behalf. The service can therefore see every record belonging to *account*. If - that is not acceptable, repoint :attr:`scanner` at a self-hosted - endpoint before calling :meth:`register`, or supply your own + that is not acceptable, supply your own :class:`~aleo._facade_common.RecordProvider` via - ``aleo.record_provider``. + ``aleo.record_provider`` instead of registering. Parameters ---------- diff --git a/sdk/python/aleo/network_client.py b/sdk/python/aleo/network_client.py index f601753a..ed8d3886 100644 --- a/sdk/python/aleo/network_client.py +++ b/sdk/python/aleo/network_client.py @@ -184,6 +184,18 @@ def _http( # ── Mutators ────────────────────────────────────────────────────────── def set_host(self, host: str) -> None: + """Re-point the client at a different API host. + + Re-derives the read base, origin, and the delegated prover/scanner bases + from *host*, discarding any prover or scanner URI set earlier — call + :meth:`set_prover_uri` / :meth:`set_record_scanner_uri` again afterwards + if you had overridden them. Local only; opens no connection. + + Args: + host: Versioned API root, e.g. ``"https://api.provable.com/v2"``. + Off the hosted Provable API the prover and scanner bases are + left unset, since those services exist only there. + """ read_host, origin, prover_default, scanner_default = self._resolve_urls(host) self._origin = origin self._base_url = origin @@ -194,15 +206,39 @@ def set_host(self, host: str) -> None: self._record_scanner_uri = scanner_default def set_prover_uri(self, prover_uri: str) -> None: + """Point delegated proving at a specific prover host. + + Use this to prove against a non-default prover; the current network name + is appended for you, so pass the base without it. + + Args: + prover_uri: Prover base without the network suffix, e.g. + ``"https://api.provable.com/prove"``. + """ self._prover_uri = f"{prover_uri}/{self._network}" def set_record_scanner_uri(self, record_scanner_uri: str) -> None: + """Point record scanning at a specific scanner host. + + The current network name is appended for you, so pass the base without + it. Note that delegating a scan shares your view key with the service. + + Args: + record_scanner_uri: Scanner base without the network suffix, e.g. + ``"https://api.provable.com/scanner"``. + """ self._record_scanner_uri = f"{record_scanner_uri}/{self._network}" def set_account(self, account: Any) -> None: + """Attach an account for calls that need one to sign or decrypt. + + Args: + account: The account to associate with this client. + """ self._account = account def get_account(self) -> Any: + """Return the account attached by :meth:`set_account`, or None if unset.""" return self._account # ── Derived-endpoint accessors ──────────────────────────────────────── @@ -223,12 +259,35 @@ def scanner_uri(self) -> str | None: return self._record_scanner_uri def set_header(self, name: str, value: str) -> None: + """Set a header sent with every subsequent request. + + Overwrites any existing value for *name*, including the SDK defaults. + + Args: + name: Header name. + value: Header value. + """ self.headers[name] = value def remove_header(self, name: str) -> None: + """Stop sending a header, if it was set. Absent names are ignored. + + Args: + name: Header name to drop. + """ self.headers.pop(name, None) def set_verbose_errors(self, verbose: bool) -> None: + """Choose whether broadcasts ask the node to pre-check the transaction. + + When enabled (the default), :meth:`submit_transaction` broadcasts with + ``check_transaction=true`` so the node reports why a transaction is + invalid instead of silently dropping it — at the cost of extra node-side + verification work. + + Args: + verbose: True to request the pre-check, False to broadcast bare. + """ self._verbose_errors = verbose # ── Internal HTTP ───────────────────────────────────────────────────── @@ -329,53 +388,220 @@ def _ensure_jwt( # ── Block endpoints ─────────────────────────────────────────────────── def get_block(self, height: int) -> Any: + """Fetch the block at *height*. + + Args: + height: Block height to read. + + Returns: + The block as decoded JSON. + + Raises: + AleoNetworkError: If no block exists at that height, or the node + rejects the request. + """ return self._get(f"/block/{height}", "getBlock") def get_block_by_hash(self, block_hash: str) -> Any: + """Fetch a block by its hash. + + Args: + block_hash: Block hash (``ab1…``). + + Returns: + The block as decoded JSON. + + Raises: + AleoNetworkError: If the hash matches no block on this network. + """ return self._get(f"/block/{block_hash}", "getBlockByHash") def get_block_range(self, start: int, end: int) -> list[Any]: + """Fetch a range of blocks in one request. + + Args: + start: First height to fetch; must be non-negative. + end: Last height of the range; must be at least *start*, and no more + than 50 above it. + + Returns: + The blocks in ascending height order. + + Raises: + ValueError: If *start* is negative, exceeds *end*, or the span is + wider than 50 blocks — checked locally, before any request. + AleoNetworkError: If the node rejects the request. + """ validate_block_range(start, end) return self._get(f"/blocks?start={start}&end={end}", "getBlockRange") def get_latest_block(self) -> Any: + """Fetch the newest block the node has. + + Returns: + The latest block as decoded JSON. + + Raises: + AleoNetworkError: If the node rejects the request. + """ return self._get("/block/latest", "getLatestBlock") def get_latest_height(self) -> int: + """Fetch the current chain tip height. + + Cheaper than :meth:`get_latest_block` when you only need the number. + + Returns: + The height of the newest block. + + Raises: + AleoNetworkError: If the node rejects the request. + """ return int(self._get("/block/height/latest", "getLatestHeight")) def get_latest_block_hash(self) -> str: + """Fetch the hash of the newest block. + + Returns: + The latest block hash (``ab1…``). + + Raises: + AleoNetworkError: If the node rejects the request. + """ return str(self._get("/block/hash/latest", "getLatestBlockHash")) def get_latest_committee(self) -> Any: + """Fetch the current validator committee. + + Returns: + The committee — members and their stake — as decoded JSON. + + Raises: + AleoNetworkError: If the node rejects the request. + """ return self._get("/committee/latest", "getLatestCommittee") def get_committee_by_height(self, height: int) -> Any: + """Fetch the validator committee as of *height*. + + Args: + height: Block height whose committee you want. + + Returns: + The committee at that height as decoded JSON. + + Raises: + AleoNetworkError: If the node has pruned that height or rejects the + request. + """ return self._get(f"/committee/{height}", "getCommitteeByHeight") def get_state_root(self) -> str: + """Fetch the latest global state root. + + The state root pins the chain state an offline query is built against — + see :class:`OfflineQuery`. + + Returns: + The current state root (``sr1…``). + + Raises: + AleoNetworkError: If the node rejects the request. + """ return str(self._get("/stateRoot/latest", "getStateRoot")) def get_state_paths(self, commitments: list[str]) -> list[Any]: + """Fetch inclusion proofs for record commitments. + + State paths are what let a proof assert that a record was in the global + state tree without revealing which one — needed to execute offline. + + Args: + commitments: Record commitments to prove inclusion for; each is + URL-escaped before it goes on the wire. + + Returns: + One state path per commitment, in the order requested. + + Raises: + AleoNetworkError: If any commitment is unknown to the node, or the + node rejects the request. + """ csv = ",".join(quote(c, safe="") for c in commitments) return self._get(f"/statePaths?commitments={csv}", "getStatePaths") # ── Program endpoints ───────────────────────────────────────────────── def get_program(self, program_id: str, edition: int | None = None) -> str: + """Fetch a program's Aleo instructions source. + + Args: + program_id: Program to read, e.g. ``"credits.aleo"``. + edition: Which amendment to read; omit for the newest one on chain. + + Returns: + The program source text. + + Raises: + AleoNetworkError: If the program (or that edition of it) is not + deployed on this network. + """ if edition is not None: return self._get(f"/program/{program_id}/{edition}", "getProgramVersion") return self._get(f"/program/{program_id}", "getProgramVersion") def get_latest_program_edition(self, program_id: str) -> int: + """Fetch the newest edition number for a program. + + Pass the result to :meth:`get_program` to pin a read to the edition you + checked, rather than racing a later amendment. + + Args: + program_id: Program to read, e.g. ``"credits.aleo"``. + + Returns: + The newest edition number on chain. + + Raises: + AleoNetworkError: If the program is not deployed on this network. + """ raw = self._get_raw(f"/program/{program_id}/latest_edition", "getLatestProgramEdition") return int(json.loads(raw)) def get_program_amendment_count(self, program_id: str) -> Any: + """Fetch how many times a program has been amended. + + Args: + program_id: Program to read, e.g. ``"credits.aleo"``. + + Returns: + The amendment count as decoded JSON. + + Raises: + AleoNetworkError: If the program is not deployed on this network. + """ raw = self._get_raw(f"/program/{program_id}/amendment_count", "getProgramAmendmentCount") return json.loads(raw) def get_program_object(self, program_id: str, edition: int | None = None) -> Any: + """Fetch a program and parse it into a ``Program``. + + Use this over :meth:`get_program` when you want to inspect functions, + mappings, or imports rather than hold the raw text. The result belongs to + the extension module matching this client's network — mainnet and testnet + types are not interchangeable. + + Args: + program_id: Program to read, e.g. ``"credits.aleo"``. + edition: Which amendment to read; omit for the newest one on chain. + + Returns: + The parsed program. + + Raises: + AleoNetworkError: If the program is not deployed on this network. + ValueError: If the fetched source fails to parse. + """ Program = self._net().Program source = self.get_program(program_id, edition) return Program.from_source(source) @@ -385,6 +611,26 @@ def get_program_imports( program_id: str, imports: dict[str, str] | None = None, ) -> dict[str, str]: + """Fetch a program's full import closure, sources included. + + Walks imports depth-first, so an import's own imports are resolved before + it. Each distinct program is fetched once, but this still costs one + request per program in the closure. + + Args: + program_id: Program whose imports to resolve. + imports: Already-known ``{program_id: source}`` entries to treat as + fetched; mutated in place and returned. Pass a populated dict to + reuse a closure across calls. + + Returns: + Every transitive import as ``{program_id: source}``. The program + itself is not included. + + Raises: + AleoNetworkError: If the program or any of its imports is not + deployed on this network. + """ if imports is None: imports = {} source = self.get_program(program_id) @@ -408,17 +654,58 @@ def _collect_program_imports( return imports def get_program_import_names(self, program_id: str) -> list[str]: + """Fetch the names a program imports directly. + + Unlike :meth:`get_program_imports` this does not recurse and does not + fetch the imported sources — one request, names only. + + Args: + program_id: Program whose imports to list. + + Returns: + The directly-imported program IDs, in declaration order. + + Raises: + AleoNetworkError: If the program is not deployed on this network. + """ Program = self._net().Program source = self.get_program(program_id) prog = Program.from_source(source) return [str(imp) for imp in prog.imports] def get_program_mapping_names(self, program_id: str) -> list[str]: + """Fetch the names of a program's mappings. + + Args: + program_id: Program whose mappings to list. + + Returns: + The mapping names declared by the program. + + Raises: + AleoNetworkError: If the program is not deployed on this network. + """ return self._get(f"/program/{program_id}/mappings", "getProgramMappingNames") def get_program_mapping_value( self, program_id: str, mapping_name: str, key: str ) -> str: + """Read one entry out of a program mapping. + + Args: + program_id: Program that owns the mapping, e.g. ``"credits.aleo"``. + mapping_name: Mapping to read, e.g. ``"account"``. + key: Mapping key, written as the Aleo literal the mapping is keyed by + (an address for ``credits.aleo/account``). + + Returns: + The stored value as a string. An unset key reads back as the mapping's + zero value rather than raising. + + Raises: + AleoNetworkError: If the program or mapping does not exist, or the key + is malformed for the mapping's key type. + """ return self._get( f"/program/{program_id}/mapping/{mapping_name}/{key}", "getProgramMappingValue", @@ -427,6 +714,25 @@ def get_program_mapping_value( def get_program_mapping_plaintext( self, program_id: str, mapping_name: str, key: str ) -> Any: + """Read a mapping entry and parse it into a ``Plaintext``. + + Use this over :meth:`get_program_mapping_value` when the value is a struct + or record and you want to index into it instead of parsing the string + yourself. + + Args: + program_id: Program that owns the mapping. + mapping_name: Mapping to read. + key: Mapping key, written as the Aleo literal the mapping is keyed by. + + Returns: + The parsed value, from the extension module matching this client's + network. + + Raises: + AleoNetworkError: If the program or mapping does not exist. + ValueError: If the returned value fails to parse as plaintext. + """ Plaintext = self._net().Plaintext raw = self._get_raw( f"/program/{program_id}/mapping/{mapping_name}/{key}", @@ -435,6 +741,19 @@ def get_program_mapping_plaintext( return Plaintext.from_string(json.loads(raw)) def get_public_balance(self, address: str) -> int: + """Read an address's public ``credits.aleo`` balance. + + Public balance only — credits held privately in records are invisible + here, so a funded account can legitimately report 0. + + Args: + address: The address to look up (``aleo1…``). + + Returns: + The balance in microcredits, or 0 if the account has no public + balance. Network failures also read back as 0 rather than raising, + so do not use this to probe whether the node is reachable. + """ try: val = self.get_program_mapping_value("credits.aleo", "account", address) return int(val) if val else 0 @@ -444,28 +763,118 @@ def get_public_balance(self, address: str) -> int: # ── Transaction endpoints ───────────────────────────────────────────── def get_transaction(self, tx_id: str) -> Any: + """Fetch a transaction by ID. + + Returns the transaction whether or not it has been confirmed; use + :meth:`get_confirmed_transaction` when you need its on-chain outcome. + + Args: + tx_id: Transaction ID (``at1…``). + + Returns: + The transaction as decoded JSON. + + Raises: + AleoNetworkError: If the node does not know this transaction. + """ return self._get(f"/transaction/{tx_id}", "getTransaction") def get_confirmed_transaction(self, tx_id: str) -> Any: + """Fetch a transaction along with its confirmed outcome. + + Args: + tx_id: Transaction ID (``at1…``). + + Returns: + The confirmed transaction as decoded JSON, including the ``status`` + field that distinguishes an accepted transaction from a rejected one. + + Raises: + AleoNetworkError: If the transaction is still unconfirmed or unknown + to the node. + """ return self._get(f"/transaction/confirmed/{tx_id}", "getConfirmedTransaction") def get_transaction_object(self, tx_id: str) -> Any: + """Fetch a transaction and parse it into a ``Transaction``. + + Use this over :meth:`get_transaction` when you want to walk transitions, + inputs, or outputs as typed objects. + + Args: + tx_id: Transaction ID (``at1…``). + + Returns: + The parsed transaction, from the extension module matching this + client's network. + + Raises: + AleoNetworkError: If the node does not know this transaction. + ValueError: If the response fails to parse as a transaction. + """ Transaction = self._net().Transaction raw = self._get_raw(f"/transaction/{tx_id}", "getTransactionObject") return Transaction.from_json(raw) def get_transactions(self, block_height: int) -> list[Any]: + """Fetch every transaction in one block. + + Args: + block_height: Height of the block to read. + + Returns: + The block's transactions as decoded JSON; empty if the block held + none. + + Raises: + AleoNetworkError: If no block exists at that height. + """ return self._get(f"/block/{block_height}/transactions", "getTransactions") def get_transactions_in_mempool(self) -> list[Any]: + """Fetch the transactions this node is holding unconfirmed. + + Mempool contents are per-node and change constantly — a transaction + missing here may still be in flight elsewhere. + + Returns: + The node's pending transactions as decoded JSON. + + Raises: + AleoNetworkError: If the node rejects the request — public nodes + often decline to expose their mempool. + """ return self._get("/memoryPool/transactions", "getTransactionsInMempool") def get_transition_id(self, input_or_output_id: str) -> str: + """Find which transition produced or consumed an input/output ID. + + Args: + input_or_output_id: A transition input or output ID to trace. + + Returns: + The enclosing transition ID (``au1…``). + + Raises: + AleoNetworkError: If nothing on chain matches that ID. + """ return self._get( f"/find/transitionID/{input_or_output_id}", "getTransitionId" ) def get_deployment_transaction_id_for_program(self, program_id: str) -> str: + """Find the transaction that deployed a program. + + Args: + program_id: Deployed program, e.g. ``"credits.aleo"``. + + Returns: + The deployment transaction ID (``at1…``), with the node's surrounding + JSON quotes stripped. + + Raises: + AleoNetworkError: If the program is not deployed on this network. + """ raw = self._get( f"/find/transactionID/deployment/{program_id}", "getDeploymentTransactionIDForProgram", @@ -473,12 +882,47 @@ def get_deployment_transaction_id_for_program(self, program_id: str) -> str: return strip_quotes(str(raw)) def get_deployment_transaction_for_program(self, program_id: str) -> Any: + """Fetch the deployment transaction for a program. Costs two requests. + + Resolves the deployment ID, then fetches that transaction. + + Args: + program_id: Deployed program, e.g. ``"credits.aleo"``. + + Returns: + The deployment transaction as decoded JSON. + + Raises: + AleoNetworkError: If the program is not deployed on this network, or + the node cannot return its deployment transaction. + """ tx_id = self.get_deployment_transaction_id_for_program(program_id) return self.get_transaction(tx_id) # ── POST endpoints ──────────────────────────────────────────────────── def submit_transaction(self, transaction: Any) -> str: + """Broadcast a proven transaction to the network. + + This spends the transaction's fee and is not reversible. Returning + successfully means the node accepted the broadcast, NOT that the + transaction was confirmed — pass the ID to + :meth:`wait_for_transaction_confirmation` for that. + + When verbose errors are on (the default) the node pre-checks the + transaction and explains rejections; see :meth:`set_verbose_errors`. + + Args: + transaction: The proven transaction; stringified before broadcast, so + either a ``Transaction`` or its serialized form works. + + Returns: + The broadcast transaction ID (``at1…``). + + Raises: + AleoNetworkError: If the node rejects the transaction — invalid proof, + insufficient fee, or a stale state root. + """ tx_str = str(transaction) endpoint = ( f"{self._host}/transaction/broadcast?check_transaction=true" @@ -489,6 +933,18 @@ def submit_transaction(self, transaction: Any) -> str: return json.loads(resp.text) def submit_solution(self, solution: str) -> str: + """Broadcast a prover solution to the network. + + Args: + solution: The serialized solution to submit. + + Returns: + The accepted solution's ID. + + Raises: + AleoNetworkError: If the node rejects the solution — stale epoch or + insufficient proof target. + """ resp = self._post( f"{self._host}/solution/broadcast", solution, "submitSolution" ) @@ -502,6 +958,30 @@ def wait_for_transaction_confirmation( check_interval: float = 2.0, timeout: float = 45.0, ) -> Any: + """Poll until a transaction is confirmed, blocking the calling thread. + + Polls the confirmed-transaction endpoint every *check_interval* seconds. + Transient failures — including the 404s expected while the transaction is + still propagating — are swallowed and retried; only an outright rejection + or a malformed ID stops the loop early. + + Args: + tx_id: Transaction ID to watch (``at1…``). + check_interval: Seconds to wait between polls. + timeout: Seconds to keep polling before giving up. Exceeding it means + the transaction never appeared, not that it failed — it may still + confirm afterwards. + + Returns: + The confirmed transaction as decoded JSON, once its status is + ``accepted``. + + Raises: + TimeoutError: If the transaction has not been confirmed within + *timeout* seconds. + AleoNetworkError: If the network rejected the transaction, or *tx_id* + is malformed. + """ import time as _time start = _time.monotonic() diff --git a/sdk/python/aleo/record_scanner.py b/sdk/python/aleo/record_scanner.py index 40eb2289..5cebcfdc 100644 --- a/sdk/python/aleo/record_scanner.py +++ b/sdk/python/aleo/record_scanner.py @@ -201,18 +201,78 @@ def _post_raw(self, url: str, body: str) -> requests.Response: # ── Mutators ───────────────────────────────────────────────────────── def set_api_key(self, api_key: str | tuple[str, str] | dict[str, str]) -> None: + """Set the API key used to mint and refresh JWTs. + + Paired with :meth:`set_consumer_id`; with both set the scanner refreshes + its own JWT as needed. Local only — no request is made here. + + Parameters + ---------- + api_key: + The key as a bare string, a ``(header, value)`` pair, or a + ``{"header": ..., "value": ...}`` dict. A bare string uses the + default Provable API-key header. + """ self._api_key = normalize_api_key(api_key) def set_consumer_id(self, consumer_id: str) -> None: + """Set the consumer whose JWTs this scanner mints. + + Note that the scanner and the delegated prover share one consumer, and the + auth server keeps a single active JWT per consumer — so minting here can + invalidate the prover's cached JWT, and vice versa. Both sides re-mint on + rejection, so this self-heals. + + Parameters + ---------- + consumer_id: + The consumer ID registered with the auth service. + """ self.consumer_id = consumer_id def set_jwt_data(self, jwt_data: dict[str, Any] | None) -> None: + """Install a pre-minted JWT, skipping the next refresh. + + Parameters + ---------- + jwt_data: + ``{"jwt": str, "expiration": int}`` with the expiry in epoch + milliseconds. A JWT within five minutes of expiring is treated as + already expired and refreshed. Pass ``None`` to clear the cached + token and force a fresh mint on the next call. + """ self.jwt_data = jwt_data def set_auto_re_register(self, enabled: bool) -> None: + """Choose whether a dropped registration is silently re-established. + + When enabled, a 422 — the service reporting this UUID is not registered — + triggers a re-register and one retry, which re-sends the view key to the + service. + + Parameters + ---------- + enabled: + True to re-register and retry, False to surface the 422 as an error. + """ self.auto_re_register = enabled def set_decrypt_enabled(self, enabled: bool) -> None: + """Choose whether fetched records are decrypted in place. + + Decryption is local, using a stored view key — enabling it sends nothing + extra to the service. :meth:`owned` decrypts opportunistically, silently + leaving records encrypted when no view key is stored for the UUID; + :meth:`find_credits_record` and :meth:`find_credits_records` instead + require this to be on. + + Parameters + ---------- + enabled: + True to decrypt in place. While False, the ``find_credits_record*`` + helpers raise :exc:`DecryptionNotEnabledError` rather than returning + ciphertext they cannot search. + """ self.decrypt_enabled = enabled def add_view_key(self, view_key: Any) -> None: @@ -221,6 +281,18 @@ def add_view_key(self, view_key: Any) -> None: self._view_keys[str(uuid_field)] = view_key def remove_view_key(self, uuid: str) -> None: + """Forget a stored view key, so it can no longer decrypt records. + + Only drops the local copy — a registration already lodged with the hosted + scanner still holds that view key; call ``revoke()`` to withdraw it. + Unknown UUIDs are ignored. + + Parameters + ---------- + uuid: + UUID string the key was stored under, as computed by + :meth:`add_view_key`. + """ self._view_keys.pop(uuid, None) def set_uuid(self, key_material: Any) -> None: diff --git a/sdk/python/aleo/testing/devnode.py b/sdk/python/aleo/testing/devnode.py index b991bcfa..1a139790 100644 --- a/sdk/python/aleo/testing/devnode.py +++ b/sdk/python/aleo/testing/devnode.py @@ -133,10 +133,20 @@ def __init__( @property def socket_addr(self) -> str: + """The node's ``host:port``, as passed to the binary's ``--socket-addr``. + + Loopback only — the devnode is never reachable off this machine. Reads the + resolved port, so it is meaningful only once the node has started. + """ return f"127.0.0.1:{self.port}" @property def base_url(self) -> str: + """The node's REST root, ready to hand to ``HTTPProvider`` or a client. + + Plain HTTP on loopback. Treated as a literal read base rather than the + hosted API, so no delegated prover or scanner is wired up against it. + """ return f"http://{self.socket_addr}" # ── Lifecycle ─────────────────────────────────────────────────────────── diff --git a/sdk/python/tests/test_facade_client.py b/sdk/python/tests/test_facade_client.py index f4a5d739..a5fef9c6 100644 --- a/sdk/python/tests/test_facade_client.py +++ b/sdk/python/tests/test_facade_client.py @@ -4,6 +4,8 @@ """ from __future__ import annotations +from decimal import Decimal + import pytest import responses as resp_lib @@ -312,6 +314,54 @@ def test_unit_conversion_round_trip() -> None: assert a.from_microcredits(a.to_microcredits(original)) == original +# Precision: these amounts silently lost value when the conversion went +# through binary floating point (1.005 truncated to 1_004_999), and a +# microcredit round-trip was not the identity for ~1.4% of values. + + +def test_to_microcredits_does_not_lose_a_microcredit() -> None: + a = Aleo(HTTPProvider(BASE)) + # float * 1e6 lands on 1004999.9999999999; int() then truncates down + assert a.to_microcredits(1.005) == 1_005_000 + assert a.to_microcredits("1.005") == 1_005_000 + assert a.to_microcredits(Decimal("1.005")) == 1_005_000 + + +def test_to_microcredits_rejects_sub_microcredit_precision() -> None: + a = Aleo(HTTPProvider(BASE)) + with pytest.raises(ValueError, match="finer than the chain"): + a.to_microcredits(0.9999999) + # opt in to the old truncating behaviour explicitly + assert a.to_microcredits(0.9999999, allow_rounding=True) == 999_999 + + +def test_to_microcredits_rejects_nonsense() -> None: + a = Aleo(HTTPProvider(BASE)) + for bad in ("abc", float("nan"), float("inf")): + with pytest.raises(ValueError): + a.to_microcredits(bad) + + +def test_from_microcredits_is_exact_past_float_range() -> None: + a = Aleo(HTTPProvider(BASE)) + # microcredits are u64; beyond 2**53 a float cannot hold the integer + for micro in (2**53 + 1, 2**64 - 1): + assert a.to_microcredits(a.from_microcredits(micro)) == micro + + +def test_microcredit_round_trip_is_identity() -> None: + a = Aleo(HTTPProvider(BASE)) + # 2884 of these failed when from_microcredits returned a float + for micro in range(0, 5_000): + assert a.to_microcredits(a.from_microcredits(micro)) == micro + + +def test_from_microcredits_still_compares_equal_to_float() -> None: + a = Aleo(HTTPProvider(BASE)) + assert a.from_microcredits(1_500_000) == 1.5 + assert float(a.from_microcredits(1_500_000)) == 1.5 + + # --------------------------------------------------------------------------- # is_valid_address # --------------------------------------------------------------------------- diff --git a/sdk/python/tests/test_proving.py b/sdk/python/tests/test_proving.py index c54144d4..efc6dbc6 100644 --- a/sdk/python/tests/test_proving.py +++ b/sdk/python/tests/test_proving.py @@ -12,7 +12,7 @@ Run them locally with: python -m pytest python/tests -v -m slow They are excluded from CI via -m "not slow". -Endpoint note (verified empirically): Query.rest() wants the BASE url +Endpoint note (verified empirically): Query.rest() requires the BASE url `https://api.explorer.provable.com/v2` — snarkvm's REST query appends the network path (`/mainnet/stateRoot/latest`) itself; passing `.../v2/mainnet` would double the network segment. diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index d279720f..483318ae 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -100,7 +100,7 @@ business, not the user's): 1. **Their own playbook.** Ask whether they have instructions of their own — a strategy file, notes, a memory store, output from a previous session. If so, read it and treat it as the plan: their document - decides what to do, the verbs here describe how each step works. + decides what to do, the methods here describe how each step works. 2. **A suggested journey.** Frame the setting first — Shield Swap is a private exchange on Aleo's test network: trading uses test tokens, and @@ -135,7 +135,7 @@ business, not the user's): and the integration checklist. 4. **A free-form prompt.** Whatever they describe, map it onto the - verbs and journeys above before improvising against the SDK. + methods and journeys above before improvising against the SDK. ### While acting @@ -166,21 +166,21 @@ where the signing keys live: | Browser dApp (wallet-signed) | The TypeScript stack: `@provablehq/shield-swap-sdk` + Veil react hooks — not this package | The user's wallet signs and proves. | What every integration must handle (each enforced or automated by the -verbs above — this list is the review checklist for code that bypasses +methods above — this list is the review checklist for code that bypasses them): - **Auth is layered**: a bearer credential (24h session JWT from the challenge/verify handshake, or a durable `ss_…` API token — data/trading endpoints only) AND a one-time invite redemption per account. - **Dynamic-dispatch imports**: every record-spending write must register - the involved token programs with the prover (the verbs resolve this via + the involved token programs with the prover (the methods resolve this via the token registry; pass `imports=`/`token_*_program=` to override). - **Tokens are private records**: spendable balances do not appear in public reads; one covering record funds an amount — no aggregation. - **Amounts are raw native units end to end** (the AMM does no decimal scaling); quote in canonical decimals, transact in raw base units, display human. -- **Wrapped assets route automatically** (swap/claim/LP verbs dispatch to +- **Wrapped assets route automatically** (swap/claim/LP methods dispatch to the routers per token shape) — fund them with UNDERLYING records; never hand wrapper records around. - **A `SwapHandle` is the only key to a swap's output** — persist before @@ -191,13 +191,13 @@ them): Suggested path for a new integrator: (1) `onboard()` a profile — it doubles as a test fixture; (2) walk swap → `collect_all()` once with the -Tier 1 verbs so the mechanics are concrete; (3) read the reference below +Tier 1 methods so the mechanics are concrete; (3) read the reference below for the surface your app needs; (4) `tests/integration/` and `scripts/rehearsal.py` in the repo are working reference implementations of the full journey. -Every write verb returns a prepared `DexCall`: nothing touches the -network until a terminal verb — `.simulate()` (local, free), +Every write method returns a prepared `DexCall`: nothing touches the +network until a terminal method — `.simulate()` (local, free), `.transact()` (local proving, slow), or `.delegate()` (delegated proving — the practical path). @@ -243,10 +243,9 @@ Whether this authenticated account has redeemed an invite code. Redeem a pasted invite — always a REFERRAL code. User-shared invites are referral codes (``/referral/redeem``); -that is the ONLY kind a person pastes. Access codes are a -programmatic self-registration flow — see -:meth:`generate_access_codes` / :meth:`redeem_access_code` — -never routed through here. +that is the ONLY kind a person pastes. Access codes are a separate +programmatic tier — see :meth:`redeem_access_code` — never routed +through here. Sessions moved to the ``/auth/*`` endpoints, so no token comes back — re-authenticate if needed (one is still adopted if the API @@ -275,11 +274,22 @@ management still require a session JWT. ### `api.get_pools(self) -> 'list[PoolEntry]'` +Every pool the DEX lists, each with its two tokens' metadata. +An entry exposes the pool's own fields directly — ``entry.key`` is the +``pool_key`` that ``swap``, ``mint``, and ``collect`` take. Its +``token0_info`` / ``token1_info`` carry that token's ``symbol`` and +``decimals``, but the API does not guarantee them — check for ``None`` +before reading. ### `api.get_tokens(self) -> 'list[models.TokenDoc]'` +Every token the DEX lists, with its id, symbol, and decimals. +``decimals`` converts between the two amount conventions. +The API returns canonical decimal amounts (``"1.5"``), if using this +value to call on-chain methods — ``swap(amount_in=…)``, ``mint``, +``collect`` — conversion to raw base units is necessary. ### `api.get_route(self, *, token_in: 'str', token_out: 'str', amount_in: 'Any' = None) -> 'models.RouteResultDoc'` @@ -311,7 +321,7 @@ Derives at ``start_counter, +1, …`` and probes the program's ``used_blinded_addresses`` mapping until one is free. ``max_scan`` fails fast when something is systematically wrong (e.g. wrong program). -### Chain verbs +### Chain methods ### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int', slippage_bps: 'int' = 50, expected_out: 'Optional[int]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` @@ -323,7 +333,7 @@ with UNDERLYING records — the deposit happens in-transaction. Resolves the intent against live pool state, derives a single-use blinded identity from the signer's view key, selects an unspent token record (or takes *token_record* verbatim), and returns a prepared -call. The terminal verb (``transact``/``delegate``) returns a +call. The terminal method (``transact``/``delegate``) returns a :class:`~aleo_shield_swap.types.SwapHandle` — persist it if the process might die before the claim. @@ -428,9 +438,20 @@ privately). Requires a configured record provider. ### `derive_pool_key(self, token0: 'str', token1: 'str', fee: 'int') -> 'str'` +Compute the pool key for a token pair and fee tier. Local — no network. +Deriving a key never implies the pool exists; pass the result to +:meth:`is_pool_initialized` before quoting or trading against it. The +derivation is sensitive to token order and is network-scoped, so swapping +*token0* and *token1*, or reusing a key across mainnet and testnet, yields +a valid-looking ``field`` that matches nothing on chain. *fee* is the +contract's ``u16`` fee tier. ### `derive_tick_key(self, pool_key: 'str', tick: 'int') -> 'str'` +Compute the key of one tick within a pool. Local — no network. +*tick* is a signed index, and the returned ``field`` is what reads that +tick's on-chain state — the initialized-tick list ``mint`` validates its +hints against. Network-scoped like :meth:`derive_pool_key`. diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index 8f8094ca..a570d1df 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -3,7 +3,7 @@ Typed Python client for the **shield_swap** AMM on Aleo. Sits on top of the Aleo Python SDK's facade (`aleo.Aleo`): signer, record provider, proving configuration, and network all come from the client you bind — this package -adds the DEX verbs, the typed results, and the off-chain DEX API, nothing +adds the DEX methods, the typed results, and the off-chain DEX API, nothing else. ```python @@ -44,7 +44,7 @@ Requires `aleo-sdk>=0.3` (this repo's SDK; imports as `aleo`) and Python 3.10+. ## Agents `AGENTS.md` (generated from the SDK's docstrings — always current) is the -one page an agent needs: the five-verb lifecycle, the conversation pattern, +one page an agent needs: the five-method lifecycle, the conversation pattern, and the building-block reference. It ships in the wheel: ```bash @@ -59,7 +59,7 @@ can run the same lifecycle through `python -m aleo_shield_swap.mcp`. ## How calls work Every read returns a value immediately. Every write returns a prepared -`DexCall` — nothing touches the network until you invoke a terminal verb: +`DexCall` — nothing touches the network until you invoke a terminal method: ```python call = dex.swap(pool_key=key, token_in_id=token, amount_in=10**9) @@ -69,7 +69,7 @@ call.transact(account) # proves locally, broadcasts, pays the fee call.delegate(account) # proves via the delegated proving service ``` -`transact` and `delegate` return the verb's *typed result* (a `SwapHandle`, +`transact` and `delegate` return the method's *typed result* (a `SwapHandle`, `MintResult`, `ClaimResult`, …) built from the transaction's root-transition outputs — not a bare transaction id. Local proving downloads SNARK parameters on first use and takes minutes for the larger entrypoints; `delegate` is the @@ -80,7 +80,7 @@ Chain reads and writes live directly on `ShieldSwap`; the off-chain DEX API is namespaced under `.api`, so a call site always shows whether a value came from the chain or the service. -## The verb surface +## The method surface **Chain reads** (node REST API): @@ -128,8 +128,8 @@ funds required; the session rides as httpOnly cookies + a CSRF header and is short-lived — mint a durable `ss_…` token via `create_api_token` for anything long-running). Accounts additionally need a one-time invite: `redeem_code` takes the **referral code** a human pasted; access codes are -a separate programmatic self-registration tier -(`generate_access_codes` / `redeem_access_code`). +a separate programmatic tier (`redeem_access_code`). Minting invite or access +codes is deliberately not exposed by this SDK — obtain a code out-of-band. ## Privacy @@ -147,11 +147,10 @@ Two conveniences trade secret material for service: - The hosted record scanner behind `get_private_balances` / `aleo.records.register` shares the account's **view key** with the scanning service, which can then see everything the account owns. - Self-host the scanner if that is unacceptable. ## Async -`AsyncShieldSwap` / `AsyncApiClient` mirror the sync surface verb-for-verb on +`AsyncShieldSwap` / `AsyncApiClient` mirror the sync surface method-for-method on `aleo.AsyncAleo` (install the `[async]` extra): ```python @@ -164,7 +163,7 @@ handle = await (await dex.swap(pool_key=key, token_in_id=token, ## Agent tools and MCP -`shield_swap_tools()` returns JSON-schema tool definitions for the whole verb +`shield_swap_tools()` returns JSON-schema tool definitions for the whole method surface; `dispatch_tool(dex, name, args)` executes one. For MCP hosts, the `[mcp]` extra ships a stdio server over the same definitions: diff --git a/shield-swap-sdk/codegen/gen_context.py b/shield-swap-sdk/codegen/gen_context.py index deee2cba..a6415400 100644 --- a/shield-swap-sdk/codegen/gen_context.py +++ b/shield-swap-sdk/codegen/gen_context.py @@ -74,7 +74,7 @@ 1. **Their own playbook.** Ask whether they have instructions of their own — a strategy file, notes, a memory store, output from a previous session. If so, read it and treat it as the plan: their document - decides what to do, the verbs here describe how each step works. + decides what to do, the methods here describe how each step works. 2. **A suggested journey.** Frame the setting first — Shield Swap is a private exchange on Aleo's test network: trading uses test tokens, and @@ -109,7 +109,7 @@ and the integration checklist. 4. **A free-form prompt.** Whatever they describe, map it onto the - verbs and journeys above before improvising against the SDK. + methods and journeys above before improvising against the SDK. ### While acting @@ -140,21 +140,21 @@ | Browser dApp (wallet-signed) | The TypeScript stack: `@provablehq/shield-swap-sdk` + Veil react hooks — not this package | The user's wallet signs and proves. | What every integration must handle (each enforced or automated by the -verbs above — this list is the review checklist for code that bypasses +methods above — this list is the review checklist for code that bypasses them): - **Auth is layered**: a bearer credential (24h session JWT from the challenge/verify handshake, or a durable `ss_…` API token — data/trading endpoints only) AND a one-time invite redemption per account. - **Dynamic-dispatch imports**: every record-spending write must register - the involved token programs with the prover (the verbs resolve this via + the involved token programs with the prover (the methods resolve this via the token registry; pass `imports=`/`token_*_program=` to override). - **Tokens are private records**: spendable balances do not appear in public reads; one covering record funds an amount — no aggregation. - **Amounts are raw native units end to end** (the AMM does no decimal scaling); quote in canonical decimals, transact in raw base units, display human. -- **Wrapped assets route automatically** (swap/claim/LP verbs dispatch to +- **Wrapped assets route automatically** (swap/claim/LP methods dispatch to the routers per token shape) — fund them with UNDERLYING records; never hand wrapper records around. - **A `SwapHandle` is the only key to a swap's output** — persist before @@ -165,7 +165,7 @@ Suggested path for a new integrator: (1) `onboard()` a profile — it doubles as a test fixture; (2) walk swap → `collect_all()` once with the -Tier 1 verbs so the mechanics are concrete; (3) read the reference below +Tier 1 methods so the mechanics are concrete; (3) read the reference below for the surface your app needs; (4) `tests/integration/` and `scripts/rehearsal.py` in the repo are working reference implementations of the full journey.""" @@ -205,8 +205,8 @@ def render() -> str: "", DEVELOPER_OPTIONS, "", - "Every write verb returns a prepared `DexCall`: nothing touches the", - "network until a terminal verb — `.simulate()` (local, free),", + "Every write method returns a prepared `DexCall`: nothing touches the", + "network until a terminal method — `.simulate()` (local, free),", "`.transact()` (local proving, slow), or `.delegate()` (delegated", "proving — the practical path).", "", @@ -236,7 +236,7 @@ def render() -> str: "", ] parts += [_entry(n, getattr(derivations, n)) for n in TIER2_DERIVATIONS] - parts += ["### Chain verbs", ""] + parts += ["### Chain methods", ""] parts += [_entry(n, getattr(ShieldSwap, n)) for n in TIER2_CLIENT] return "\n".join(parts) + "\n" diff --git a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md index d279720f..483318ae 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md +++ b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md @@ -100,7 +100,7 @@ business, not the user's): 1. **Their own playbook.** Ask whether they have instructions of their own — a strategy file, notes, a memory store, output from a previous session. If so, read it and treat it as the plan: their document - decides what to do, the verbs here describe how each step works. + decides what to do, the methods here describe how each step works. 2. **A suggested journey.** Frame the setting first — Shield Swap is a private exchange on Aleo's test network: trading uses test tokens, and @@ -135,7 +135,7 @@ business, not the user's): and the integration checklist. 4. **A free-form prompt.** Whatever they describe, map it onto the - verbs and journeys above before improvising against the SDK. + methods and journeys above before improvising against the SDK. ### While acting @@ -166,21 +166,21 @@ where the signing keys live: | Browser dApp (wallet-signed) | The TypeScript stack: `@provablehq/shield-swap-sdk` + Veil react hooks — not this package | The user's wallet signs and proves. | What every integration must handle (each enforced or automated by the -verbs above — this list is the review checklist for code that bypasses +methods above — this list is the review checklist for code that bypasses them): - **Auth is layered**: a bearer credential (24h session JWT from the challenge/verify handshake, or a durable `ss_…` API token — data/trading endpoints only) AND a one-time invite redemption per account. - **Dynamic-dispatch imports**: every record-spending write must register - the involved token programs with the prover (the verbs resolve this via + the involved token programs with the prover (the methods resolve this via the token registry; pass `imports=`/`token_*_program=` to override). - **Tokens are private records**: spendable balances do not appear in public reads; one covering record funds an amount — no aggregation. - **Amounts are raw native units end to end** (the AMM does no decimal scaling); quote in canonical decimals, transact in raw base units, display human. -- **Wrapped assets route automatically** (swap/claim/LP verbs dispatch to +- **Wrapped assets route automatically** (swap/claim/LP methods dispatch to the routers per token shape) — fund them with UNDERLYING records; never hand wrapper records around. - **A `SwapHandle` is the only key to a swap's output** — persist before @@ -191,13 +191,13 @@ them): Suggested path for a new integrator: (1) `onboard()` a profile — it doubles as a test fixture; (2) walk swap → `collect_all()` once with the -Tier 1 verbs so the mechanics are concrete; (3) read the reference below +Tier 1 methods so the mechanics are concrete; (3) read the reference below for the surface your app needs; (4) `tests/integration/` and `scripts/rehearsal.py` in the repo are working reference implementations of the full journey. -Every write verb returns a prepared `DexCall`: nothing touches the -network until a terminal verb — `.simulate()` (local, free), +Every write method returns a prepared `DexCall`: nothing touches the +network until a terminal method — `.simulate()` (local, free), `.transact()` (local proving, slow), or `.delegate()` (delegated proving — the practical path). @@ -243,10 +243,9 @@ Whether this authenticated account has redeemed an invite code. Redeem a pasted invite — always a REFERRAL code. User-shared invites are referral codes (``/referral/redeem``); -that is the ONLY kind a person pastes. Access codes are a -programmatic self-registration flow — see -:meth:`generate_access_codes` / :meth:`redeem_access_code` — -never routed through here. +that is the ONLY kind a person pastes. Access codes are a separate +programmatic tier — see :meth:`redeem_access_code` — never routed +through here. Sessions moved to the ``/auth/*`` endpoints, so no token comes back — re-authenticate if needed (one is still adopted if the API @@ -275,11 +274,22 @@ management still require a session JWT. ### `api.get_pools(self) -> 'list[PoolEntry]'` +Every pool the DEX lists, each with its two tokens' metadata. +An entry exposes the pool's own fields directly — ``entry.key`` is the +``pool_key`` that ``swap``, ``mint``, and ``collect`` take. Its +``token0_info`` / ``token1_info`` carry that token's ``symbol`` and +``decimals``, but the API does not guarantee them — check for ``None`` +before reading. ### `api.get_tokens(self) -> 'list[models.TokenDoc]'` +Every token the DEX lists, with its id, symbol, and decimals. +``decimals`` converts between the two amount conventions. +The API returns canonical decimal amounts (``"1.5"``), if using this +value to call on-chain methods — ``swap(amount_in=…)``, ``mint``, +``collect`` — conversion to raw base units is necessary. ### `api.get_route(self, *, token_in: 'str', token_out: 'str', amount_in: 'Any' = None) -> 'models.RouteResultDoc'` @@ -311,7 +321,7 @@ Derives at ``start_counter, +1, …`` and probes the program's ``used_blinded_addresses`` mapping until one is free. ``max_scan`` fails fast when something is systematically wrong (e.g. wrong program). -### Chain verbs +### Chain methods ### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int', slippage_bps: 'int' = 50, expected_out: 'Optional[int]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` @@ -323,7 +333,7 @@ with UNDERLYING records — the deposit happens in-transaction. Resolves the intent against live pool state, derives a single-use blinded identity from the signer's view key, selects an unspent token record (or takes *token_record* verbatim), and returns a prepared -call. The terminal verb (``transact``/``delegate``) returns a +call. The terminal method (``transact``/``delegate``) returns a :class:`~aleo_shield_swap.types.SwapHandle` — persist it if the process might die before the claim. @@ -428,9 +438,20 @@ privately). Requires a configured record provider. ### `derive_pool_key(self, token0: 'str', token1: 'str', fee: 'int') -> 'str'` +Compute the pool key for a token pair and fee tier. Local — no network. +Deriving a key never implies the pool exists; pass the result to +:meth:`is_pool_initialized` before quoting or trading against it. The +derivation is sensitive to token order and is network-scoped, so swapping +*token0* and *token1*, or reusing a key across mainnet and testnet, yields +a valid-looking ``field`` that matches nothing on chain. *fee* is the +contract's ``u16`` fee tier. ### `derive_tick_key(self, pool_key: 'str', tick: 'int') -> 'str'` +Compute the key of one tick within a pool. Local — no network. +*tick* is a signed index, and the returned ``field`` is what reads that +tick's on-chain state — the initialized-tick list ``mint`` validates its +hints against. Network-scoped like :meth:`derive_pool_key`. diff --git a/shield-swap-sdk/python/aleo_shield_swap/_calls.py b/shield-swap-sdk/python/aleo_shield_swap/_calls.py index 84a1dd51..39c48359 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_calls.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_calls.py @@ -1,6 +1,6 @@ """DexCall — a facade BoundCall plus a typed result builder. -Preserves the facade's verb ladder on every DEX write: the consumer picks +Preserves the facade's call semantics on every DEX write: the consumer picks the proving path (``simulate`` / ``transact`` / ``delegate``) and gets a typed result back instead of a bare transaction id. @@ -71,7 +71,7 @@ def root_outputs(decoded_transitions: list[dict[str, Any]], class DexCall(Generic[R]): """A prepared DEX write. ``build_result(transaction_id, root_outputs) - -> R`` turns the submitted transaction into the verb's typed result.""" + -> R`` turns the submitted transaction into the method's typed result.""" def __init__(self, aleo: Any, bound: Any, build_result: Callable[[str, list[str]], R]) -> None: diff --git a/shield-swap-sdk/python/aleo_shield_swap/_core.py b/shield-swap-sdk/python/aleo_shield_swap/_core.py index 052975fe..c5b2a0c0 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_core.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_core.py @@ -239,6 +239,12 @@ def register_program_sources(aleo: Any, sources: dict[str, str]) -> None: added: set[str] = set() def add(pid: str) -> None: + """Register *pid* and its imports, dependencies first. + + Skips ``credits.aleo`` (already seeded), programs handled in this pass, + anything absent from *sources*, and programs the process already holds — + so repeated calls are cheap and safe. + """ if pid == "credits.aleo" or pid in added or pid not in sources: return added.add(pid) diff --git a/shield-swap-sdk/python/aleo_shield_swap/_routing.py b/shield-swap-sdk/python/aleo_shield_swap/_routing.py index 42edecf0..815a7917 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_routing.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_routing.py @@ -16,16 +16,34 @@ class Route(NamedTuple): + """Which program and function a call must go through. + + Dispatch target only — it says nothing about the arguments, which differ + between the core and router entrypoints. + """ + program: str function: str def swap_route(input_wrapped: bool) -> Route: + """Route a swap by whether the INPUT token is wrapped. + + A wrapped input must enter through the router, which unwraps it; a plain + ARC-20 calls the core directly. The output shape does not matter here — it is + settled at claim time by :func:`claim_route`. + """ return (Route(ROUTER_ID, "swap_from_wrapped") if input_wrapped else Route(PROGRAM_ID, "swap")) def claim_route(output_wrapped: bool, refund_wrapped: bool) -> Route: + """Route a claim by the wrapped-ness of its output and refund legs. + + Either leg being wrapped forces the claim through the router, since only it + can wrap on the way out; a claim with both legs plain goes to the core. The + two flags come from the finalized ``SwapOutput``, not from the original swap. + """ if output_wrapped and refund_wrapped: return Route(ROUTER_ID, "claim_to_wrapped_refund_wrapped") if output_wrapped: @@ -46,12 +64,24 @@ def _lp_route(w0: bool, w1: bool, stem: str, core_fn: str) -> Route: def mint_route(w0: bool, w1: bool) -> Route: + """Route a mint by which of the pool's two tokens are wrapped. + + Any wrapped side sends the call through the LP router; both plain goes to the + core. *w0* and *w1* follow the pool's token order, so passing them reversed + silently picks the wrong entrypoint. + """ return _lp_route(w0, w1, "mint_from", "mint") def increase_route(w0: bool, w1: bool) -> Route: + """Route a liquidity increase — same wrapped-ness rule as :func:`mint_route`.""" return _lp_route(w0, w1, "increase_from", "increase_liquidity") def collect_route(w0: bool, w1: bool) -> Route: + """Route a fee collection by which pool tokens are wrapped. + + Mirrors :func:`mint_route`, but the router wraps on the way OUT rather than + unwrapping on the way in. + """ return _lp_route(w0, w1, "collect_to", "collect") diff --git a/shield-swap-sdk/python/aleo_shield_swap/agent.py b/shield-swap-sdk/python/aleo_shield_swap/agent.py index 7607e3e9..30071f69 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/agent.py +++ b/shield-swap-sdk/python/aleo_shield_swap/agent.py @@ -2,11 +2,11 @@ ``shield_swap_tools()`` returns tool definitions in the Claude API ``tools=`` shape (name / description / input_schema) — they plug into any framework -that speaks JSON-schema tools. ``dispatch_tool(dex, name, args)`` executes -one against a :class:`~aleo_shield_swap.client.ShieldSwap` (write verbs run +that accepts JSON-schema tools. ``dispatch_tool(dex, name, args)`` executes +one against a :class:`~aleo_shield_swap.client.ShieldSwap` (write methods run ``.delegate()``) and returns a JSON-serializable result. The surface is the curated lifecycle set — swap handles and counters live in the profile -journal, so agents never carry state between calls; the long tail of verbs +journal, so agents never carry state between calls; the long tail of methods is reachable by writing Python against the client instead. """ from __future__ import annotations diff --git a/shield-swap-sdk/python/aleo_shield_swap/api.py b/shield-swap-sdk/python/aleo_shield_swap/api.py index 96244064..d7cf9075 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/api.py +++ b/shield-swap-sdk/python/aleo_shield_swap/api.py @@ -197,10 +197,9 @@ def redeem_code(self, code: str) -> models.AccessRedeemResponse: """Redeem a pasted invite — always a REFERRAL code. User-shared invites are referral codes (``/referral/redeem``); - that is the ONLY kind a person pastes. Access codes are a - programmatic self-registration flow — see - :meth:`generate_access_codes` / :meth:`redeem_access_code` — - never routed through here. + that is the ONLY kind a person pastes. Access codes are a separate + programmatic tier — see :meth:`redeem_access_code` — never routed + through here. Sessions moved to the ``/auth/*`` endpoints, so no token comes back — re-authenticate if needed (one is still adopted if the API @@ -213,16 +212,13 @@ def redeem_code(self, code: str) -> models.AccessRedeemResponse: self._token = token return out - def generate_access_codes(self, count: int = 1) -> list[str]: - """Mint access codes (``POST /access/generate``) — the - self-registration tier for operators/services; requires an - account with generate rights.""" - return list(self._post("/access/generate", {"count": count})["data"]["codes"]) - def redeem_access_code(self, code: str) -> models.AccessRedeemResponse: """Redeem a programmatically minted access code - (``POST /access/redeem``) — the self-registration counterpart of - :meth:`generate_access_codes`; not for human-pasted invites.""" + (``POST /access/redeem``) — not for human-pasted invites, which are + referral codes and go through :meth:`redeem_code`. + + Minting these is deliberately not exposed by this SDK; obtain a code + out-of-band from an operator.""" data = self._post("/access/redeem", {"code": code})["data"] return _build(models.AccessRedeemResponse, data) @@ -262,6 +258,14 @@ def create_api_token(self, name: str, # ── Pools & tokens ───────────────────────────────────────────────────── def get_pools(self) -> list[PoolEntry]: + """Every pool the DEX lists, each with its two tokens' metadata. + + An entry exposes the pool's own fields directly — ``entry.key`` is the + ``pool_key`` that ``swap``, ``mint``, and ``collect`` take. Its + ``token0_info`` / ``token1_info`` carry that token's ``symbol`` and + ``decimals``, but the API does not guarantee them — check for ``None`` + before reading. + """ entries = self._get("/pools")["data"] return [ PoolEntry( @@ -273,6 +277,13 @@ def get_pools(self) -> list[PoolEntry]: ] def get_tokens(self) -> list[models.TokenDoc]: + """Every token the DEX lists, with its id, symbol, and decimals. + + ``decimals`` converts between the two amount conventions. + The API returns canonical decimal amounts (``"1.5"``), if using this + value to call on-chain methods — ``swap(amount_in=…)``, ``mint``, + ``collect`` — conversion to raw base units is necessary. + """ return [_build(models.TokenDoc, t) for t in self._get("/tokens")["data"]] # ── Trading ──────────────────────────────────────────────────────────── @@ -288,10 +299,24 @@ def get_route(self, *, token_in: str, token_out: str, return _build(models.RouteResultDoc, self._get("/route", params)["data"]) def get_swap(self, swap_id: str) -> models.SwapDoc: + """The indexer's record of one swap, by its id. + + Note: The API may lag slightly behind chain state, so a recently + broadcast swap may not be visible immediately and can be retried if a + caller has confirmed a swap on chain — raises :class:`DexApiError` (404) + until it is. + """ return _build(models.SwapDoc, self._get(f"/swaps/{swap_id}")["data"]) def get_ohlcv(self, pool_key: str, *, granularity: str, from_ts: str, to_ts: str) -> list[models.OhlcvDoc]: + """Candles for one pool over a time window. + + *granularity* is one of ``"1m"``, ``"5m"``, ``"15m"``, ``"30m"``, + ``"1h"``, ``"6h"``, ``"12h"``, ``"1d"``. *from_ts* and *to_ts* are unix + seconds — *from_ts* inclusive, *to_ts* exclusive. Anything else raises + :class:`DexApiError`. + """ data = self._get(f"/pools/{pool_key}/ohlcv", {"granularity": granularity, "from": from_ts, "to": to_ts})["data"] return [_build(models.OhlcvDoc, o) for o in data] @@ -299,6 +324,12 @@ def get_ohlcv(self, pool_key: str, *, granularity: str, # ── Balances ─────────────────────────────────────────────────────────── def get_public_balances(self, user: str) -> list[models.TokenBalanceDoc]: + """Public token balances for an address, as the API sees them. + + Public only — tokens held privately in records are invisible here, so this + understates a shielded account. Use ``ShieldSwap.get_private_balances`` + to get private balances. + """ return [_build(models.TokenBalanceDoc, b) for b in self._get("/balances", {"user": user})["data"]] @@ -327,6 +358,11 @@ def __repr__(self) -> str: @property def is_authenticated(self) -> bool: + """True once a credential is held — a cookie-session CSRF token or a JWT. + + Reflects only that a credential was stored, not that it is still valid; + an expired session shows True here and fails on the next call. + """ return bool(self._token or self._csrf) def _headers(self) -> dict[str, str]: @@ -377,6 +413,7 @@ async def authenticate(self, address: str, sign: Any) -> str: return self._csrf def set_token(self, token: str) -> None: + """Adopt a previously issued JWT — see :meth:`ApiClient.set_token`.""" self._token = token # ── Lifecycle (async mirror of ApiClient) ────────────────────────────── @@ -395,11 +432,6 @@ async def redeem_code(self, code: str) -> models.AccessRedeemResponse: self._token = token return out - async def generate_access_codes(self, count: int = 1) -> list[str]: - """Mint access codes — see :meth:`ApiClient.generate_access_codes`.""" - return list((await self._post("/access/generate", - {"count": count}))["data"]["codes"]) - async def redeem_access_code(self, code: str) -> models.AccessRedeemResponse: """Redeem an access code — see :meth:`ApiClient.redeem_access_code`.""" data = (await self._post("/access/redeem", {"code": code}))["data"] @@ -428,6 +460,7 @@ async def create_api_token(self, name: str, (await self._post("/api-tokens", body))["data"]) async def get_pools(self) -> list[PoolEntry]: + """Every pool with its tokens' metadata — see :meth:`ApiClient.get_pools`.""" entries = (await self._get("/pools"))["data"] return [ PoolEntry( @@ -439,25 +472,34 @@ async def get_pools(self) -> list[PoolEntry]: ] async def get_tokens(self) -> list[models.TokenDoc]: + """Every listed token — see :meth:`ApiClient.get_tokens`.""" return [_build(models.TokenDoc, t) for t in (await self._get("/tokens"))["data"]] async def get_route(self, *, token_in: str, token_out: str, amount_in: int | None = None) -> models.RouteResultDoc: + """Best route between two tokens — see :meth:`ApiClient.get_route`. + + As on the sync client, *amount_in* is stringified onto the query and the + API reads it as a canonical decimal amount, not base units. + """ params: dict[str, Any] = {"token_in": token_in, "token_out": token_out} if amount_in is not None: params["amount_in"] = str(amount_in) return _build(models.RouteResultDoc, (await self._get("/route", params))["data"]) async def get_swap(self, swap_id: str) -> models.SwapDoc: + """One swap by id — see :meth:`ApiClient.get_swap`.""" return _build(models.SwapDoc, (await self._get(f"/swaps/{swap_id}"))["data"]) async def get_ohlcv(self, pool_key: str, *, granularity: str, from_ts: str, to_ts: str) -> list[models.OhlcvDoc]: + """Candles for one pool — see :meth:`ApiClient.get_ohlcv`.""" data = (await self._get(f"/pools/{pool_key}/ohlcv", {"granularity": granularity, "from": from_ts, "to": to_ts}))["data"] return [_build(models.OhlcvDoc, o) for o in data] async def get_public_balances(self, user: str) -> list[models.TokenBalanceDoc]: + """Public balances for *user* — see :meth:`ApiClient.get_public_balances`.""" return [_build(models.TokenBalanceDoc, b) for b in (await self._get("/balances", {"user": user}))["data"]] diff --git a/shield-swap-sdk/python/aleo_shield_swap/async_client.py b/shield-swap-sdk/python/aleo_shield_swap/async_client.py index e1f7a5ae..b1a4208b 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/async_client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/async_client.py @@ -2,7 +2,7 @@ Reads, balances, and the two-transaction private-swap flow of :class:`~aleo_shield_swap.client.ShieldSwap`, with every I/O method -``async``. Liquidity verbs are sync-client-only for now. Pure logic is +``async``. Liquidity methods are sync-client-only for now. Pure logic is shared from ``_core``/``derivations`` — only the I/O differs. """ from __future__ import annotations @@ -53,10 +53,20 @@ def __init__(self, aleo: Any, bound: Any, self._build = build_result def simulate(self, account: Any = None) -> Any: - # AsyncBoundCall.simulate is synchronous (local authorization). + """Local authorization — no proof, no send; inspect before spending. + + Synchronous even on the async client: ``AsyncBoundCall.simulate`` only + authorizes locally, so there is nothing to await. + """ return self._bound.simulate(account) async def transact(self, account: Any = None, **fee_kwargs: Any) -> R: + """Prove locally, broadcast, and build the typed result. + + Root-transition outputs are harvested from the built transaction + before broadcast, so the result is complete without waiting for + confirmation. + """ tx = await self._bound.build_transaction(account, **fee_kwargs) outputs = root_outputs(tx.decoded(), self._bound.program_id, self._bound.function_name) @@ -64,6 +74,14 @@ async def transact(self, account: Any = None, **fee_kwargs: Any) -> R: return self._build(tx.id, outputs) async def delegate(self, account: Any = None, **fee_kwargs: Any) -> R: + """Delegate proving to the DPS (fee master pays by default) and build + the typed result from the root transition. + + Outputs are harvested from the DPS payload itself when it carries + the full transaction, so ``wait=False`` returns as soon as the + broadcast is accepted — callers that read chain state later (e.g. + ``collect_all``) don't need to block on confirmation here. + """ payload = await self._bound.delegate(account, **fee_kwargs) tx_id = extract_tx_id(payload) await self._aleo.network.wait_for_transaction(tx_id) @@ -103,12 +121,18 @@ async def _mapping_value(self, mapping: str, key: str) -> Optional[str]: # ── Chain reads ────────────────────────────────────────────────────────── async def get_pool(self, pool_key: str) -> g.PoolState: + """Static pool configuration (token pair, fee, decimal scales).""" raw = await self._mapping_value("pools", pool_key) if raw is None: raise PoolNotFoundError(pool_key) return g.PoolState.from_plaintext(raw) async def get_slot(self, pool_key: str) -> SlotView: + """Live trading state (sqrt price, tick, in-range liquidity). + + Raises :class:`PoolNotFoundError` when the pool does not exist, or + :class:`PoolNotInitializedError` when it exists but has no slot yet. + """ raw = await self._mapping_value("slots", pool_key) if raw is None: if await self._mapping_value("pools", pool_key) is not None: @@ -117,6 +141,12 @@ async def get_slot(self, pool_key: str) -> SlotView: return SlotView(g.Slot.from_plaintext(raw)) async def get_swap_output(self, swap: "SwapHandle | str") -> g.SwapOutput: + """Chain-computed output of a finalized swap request. + + Accepts the :class:`SwapHandle` from ``swap()`` or a bare swap id. + Raises :class:`SwapOutputNotFinalizedError` when the entry is absent — + not finalized yet (retry after a few blocks) or already claimed. + """ swap_id = swap.swap_id if isinstance(swap, SwapHandle) else swap if not swap_id: raise ValueError("SwapHandle has no swap_id yet — wait for the " @@ -127,15 +157,47 @@ async def get_swap_output(self, swap: "SwapHandle | str") -> g.SwapOutput: return g.SwapOutput.from_plaintext(raw) async def is_pool_initialized(self, pool_key: str) -> bool: + """True once *pool_key* has been initialized on chain. Reads a mapping. + + A pool must be initialized before it will accept swaps or liquidity, so + check this before quoting against a key you derived rather than listed. + An absent mapping entry reads as False, which is also what an unknown key + gives you — this does not distinguish the two. + """ raw = await self._mapping_value("initialized_pools", pool_key) return raw is not None and "true" in raw # ── Pure derivations (sync — no I/O) ───────────────────────────────────── def derive_pool_key(self, token0: str, token1: str, fee: int) -> str: + """Compute the pool key for a token pair and fee tier. Local — no network. + + Deriving a key does not mean the pool exists; pass the result to + :meth:`is_pool_initialized` before trading against it. The token order + matters, and the derivation is network-scoped, so a key computed for one + network will not match the other. + + Args: + token0: First token id of the pair. + token1: Second token id of the pair. + fee: Fee tier in the contract's ``u16`` units. + + Returns: + The pool key as a ``field`` literal. + """ return _derive_pool_key(token0, token1, fee, network=self._aleo.network_name) def derive_tick_key(self, pool_key: str, tick: int) -> str: + """Compute the key of one tick within a pool. Local — no network. + + Args: + pool_key: Pool the tick belongs to. + tick: Signed tick index. + + Returns: + The tick key as a ``field`` literal, usable to read the tick's + on-chain state. + """ return _derive_tick_key(pool_key, tick, network=self._aleo.network_name) # ── Async record/identity/imports helpers ─────────────────────────────── @@ -243,6 +305,9 @@ def _account(self, account: Any = None) -> Any: async def get_private_balances(self, programs: list[str], account: Any = None) -> dict[str, int]: + """Sum of unspent record amounts per wrapper program (spendable + privately). Requires a configured record provider. + """ provider = self._aleo.record_provider out: dict[str, int] = {} for program in programs: @@ -258,6 +323,15 @@ async def get_private_balances(self, programs: list[str], async def get_balances(self, address: Optional[str] = None, account: Any = None) -> dict[str, dict[str, Any]]: + """Public + private + total per token id, joined via the API's + token registry. Defaults to the bound account's address; returns + only tokens actually held. + + Private balances can only be scanned for the bound account's view + key — when *address* names someone else, ``private`` is 0 for every + token (their records are not scannable) rather than silently mixing + in the caller's own private holdings. + """ acct = account if account is not None else self._aleo.default_account addr = address or (str(acct.address) if acct is not None else None) if addr is None: @@ -299,6 +373,26 @@ async def swap(self, *, pool_key: str, token_in_id: str, amount_in: int, wrapper_proofs: Optional[str] = None, imports: Optional[dict[str, str]] = None, account: Any = None) -> AsyncDexCall[SwapHandle]: + """Request a private swap — phase one of the two-transaction flow. + + Wrapped inputs route via the swap router automatically; fund them + with UNDERLYING records — the deposit happens in-transaction. + + Resolves the intent against live pool state, derives a single-use + blinded identity from the signer's view key, selects an unspent token + record (or takes *token_record* verbatim), and returns a prepared + call. The terminal method (``transact``/``delegate``) returns a + :class:`~aleo_shield_swap.types.SwapHandle` — persist it if the + process might die before the claim. + + Quote first (``dex.api.get_route``) and pass *expected_out*: without + it a spot estimate is used, which ignores fees and price impact. + Pass *identity* (from journal-reserved counters) to skip the + on-chain probe — required for concurrent swaps. The default + *deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- + proving latency; a tight deadline aborts at finalize when proving + outlives it. + """ acct = self._account(account) pool = await self.get_pool(pool_key) slot = await self.get_slot(pool_key) @@ -347,6 +441,12 @@ async def swap(self, *, pool_key: str, token_in_id: str, amount_in: int, bound = getattr(prog.functions, route.function)(*inputs) def build_result(tx_id: str, outputs: list[Any]) -> SwapHandle: + """Assemble the swap handle, carrying the blinding secret forward. + + The first public ``field`` output is the swap id; it stays ``None`` if + the transition published none, which leaves the handle unclaimable + until the id is recovered from the transaction. + """ swap_id = next((o for o in outputs if isinstance(o, str) and o.endswith("field")), None) return SwapHandle( @@ -363,6 +463,21 @@ async def claim_swap_output(self, handle: SwapHandle, *, wrapper_proofs: Optional[str] = None, imports: Optional[dict[str, str]] = None, account: Any = None) -> AsyncDexCall[ClaimResult]: + """Claim a private swap's output — phase two of the lifecycle. + + Reads the chain-computed result from ``swap_outputs`` (never an + off-chain service — these amounts gate money movement), proves + ownership of the blinded identity, and claims. A wrapped output or + refund routes automatically through the router, which unwraps to + the signer in the same transaction — even for swaps that started + as direct core calls. The output and any refund arrive as private + records owned by the signer (output first, refund second); the + mapping entry is consumed. + + Raises :class:`SwapOutputNotFinalizedError` **at prepare time** when + the output is not readable yet (retry after a few blocks) or was + already claimed. + """ self._account(account) if not handle.swap_id: raise ValueError("handle.swap_id is not set — recover it from the " diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index af56af68..1886aa6a 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -123,7 +123,7 @@ def from_profile(cls, home: Any = None) -> "ShieldSwap": # DPS api key, which the credentials stage provisions. aleo.records.register(aleo.default_account) except Exception: - pass # offline or scanner down — record verbs will surface it + pass # offline or scanner down — record methods will surface it dex = cls(aleo) dex.profile = profile dex.journal = Journal(profile.journal_path) @@ -229,6 +229,13 @@ def get_swap_output(self, swap: "SwapHandle | str") -> g.SwapOutput: return g.SwapOutput.from_plaintext(raw) def is_pool_initialized(self, pool_key: str) -> bool: + """True once *pool_key* has been initialized on chain. Reads a mapping. + + A pool must be initialized before it will accept swaps or liquidity, so + check this before quoting against a key you derived rather than listed. + An absent mapping entry reads as False, which is also what an unknown key + gives you — this does not distinguish the two. + """ raw = self._mapping_value("initialized_pools", pool_key) return raw is not None and "true" in raw @@ -315,10 +322,25 @@ def status(self) -> SessionStatus: # ── Pure derivations (no network) ──────────────────────────────────────── def derive_pool_key(self, token0: str, token1: str, fee: int) -> str: + """Compute the pool key for a token pair and fee tier. Local — no network. + + Deriving a key never implies the pool exists; pass the result to + :meth:`is_pool_initialized` before quoting or trading against it. The + derivation is sensitive to token order and is network-scoped, so swapping + *token0* and *token1*, or reusing a key across mainnet and testnet, yields + a valid-looking ``field`` that matches nothing on chain. *fee* is the + contract's ``u16`` fee tier. + """ return _derive_pool_key(token0, token1, fee, network=self._aleo.network_name) def derive_tick_key(self, pool_key: str, tick: int) -> str: + """Compute the key of one tick within a pool. Local — no network. + + *tick* is a signed index, and the returned ``field`` is what reads that + tick's on-chain state — the initialized-tick list ``mint`` validates its + hints against. Network-scoped like :meth:`derive_pool_key`. + """ return _derive_tick_key(pool_key, tick, network=self._aleo.network_name) def find_tick_predecessor(self, pool_key: str, new_tick: int, @@ -470,7 +492,7 @@ def swap( Resolves the intent against live pool state, derives a single-use blinded identity from the signer's view key, selects an unspent token record (or takes *token_record* verbatim), and returns a prepared - call. The terminal verb (``transact``/``delegate``) returns a + call. The terminal method (``transact``/``delegate``) returns a :class:`~aleo_shield_swap.types.SwapHandle` — persist it if the process might die before the claim. @@ -544,6 +566,12 @@ def swap( route.function)(*inputs) def build_result(tx_id: str, outputs: list[Any]) -> SwapHandle: + """Assemble the swap handle, carrying the blinding secret forward. + + The first public ``field`` output is the swap id; it stays ``None`` if + the transition published none, which leaves the handle unclaimable + until the id is recovered from the transaction. + """ swap_id = next( (o for o in outputs if isinstance(o, str) and o.endswith("field")), None, @@ -635,6 +663,11 @@ def claim_swap_output( route.function)(*inputs) def build_result(tx_id: str, outputs: list[Any]) -> ClaimResult: + """Pair the claim's transaction id with the amounts read pre-flight. + + The amounts come from the finalized swap output fetched before + building, not from *outputs* — the claim transition publishes none. + """ return ClaimResult(tx_id, out.amount_out, out.amount_remaining) return DexCall(self._aleo, bound, build_result) @@ -701,8 +734,8 @@ def _quote_expected_out(self, *, token_in_id: str, token_out_id: str, amount_in: int) -> Optional[int]: """Base-unit expected output for a trade, via the route quote. - The route endpoint speaks canonical decimal amounts, the contract - speaks base units — this converts in both directions using the + The route endpoint returns canonical decimal amounts, the contract + takes base units — this converts in both directions using the token registry's decimals. None (spot fallback) when either token is unknown or no route is quotable. """ @@ -913,6 +946,11 @@ def create_pool( bound = self._aleo.programs.get(self.program).functions.create_pool(*inputs) def build_result(tx_id: str, outputs: list[Any]) -> TxResult: + """Pull the new pool's key out of the transition's public outputs. + + The first ``field`` output is the key; it stays ``None`` if the + transition published none. + """ key = next((o for o in outputs if isinstance(o, str) and o.endswith("field")), None) return TxResult(position_token_id=key, transaction_id=tx_id) @@ -1025,6 +1063,12 @@ def mint( base_build = self._position_result(MintResult) def build_result(tx_id: str, outputs: list[Any]) -> MintResult: + """Build the mint result and journal the new position. + + Journaling is what lets a later session find this position without a + record scan; it is skipped when no journal is configured, or when the + transition published no position id to key it by. + """ result = base_build(tx_id, outputs) if self.journal is not None and result.position_token_id: self.journal.record_position(result.position_token_id, @@ -1185,6 +1229,12 @@ def burn( base_build = self._position_result(TxResult) def build_result(tx_id: str, outputs: list[Any]) -> TxResult: + """Build the burn result and mark the position closed in the journal. + + Uses the id parsed from the spent position record rather than the + transition outputs, so the journal is updated even when the burn + publishes nothing. + """ result = base_build(tx_id, outputs) if self.journal is not None and pid: self.journal.record_position_burned(pid, tx_id) @@ -1196,6 +1246,11 @@ def build_result(tx_id: str, outputs: list[Any]) -> TxResult: def _position_result(result_cls: Any) -> Any: """Result builder: first public ``field`` output is the position id.""" def build(tx_id: str, outputs: list[Any]) -> Any: + """Construct *result_cls* from the position id and transaction id. + + The position id is the first public ``field`` output, or ``None`` if + the transition published none. + """ pid = next((o for o in outputs if isinstance(o, str) and o.endswith("field")), None) return result_cls(pid, tx_id) return build diff --git a/shield-swap-sdk/python/aleo_shield_swap/errors.py b/shield-swap-sdk/python/aleo_shield_swap/errors.py index 4c2263ef..aa23ce51 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/errors.py +++ b/shield-swap-sdk/python/aleo_shield_swap/errors.py @@ -28,12 +28,24 @@ def __init__(self, swap_id: str) -> None: class PoolNotFoundError(ShieldSwapError): + """No pool exists at this key. + + Usually a key derived for the wrong token order, fee tier, or network — the + derivation succeeds regardless, so a bad key only surfaces on the first read. + """ + def __init__(self, pool_key: str) -> None: super().__init__(f"Pool {pool_key} does not exist on-chain.") self.pool_key = pool_key class PoolNotInitializedError(ShieldSwapError): + """The pool exists but has no slot yet, so it cannot quote or trade. + + Distinct from :class:`PoolNotFoundError`: the pool was created but never + initialized. Trading against it only works once someone initializes it. + """ + def __init__(self, pool_key: str) -> None: super().__init__(f"Pool {pool_key} exists but is not initialized.") self.pool_key = pool_key diff --git a/shield-swap-sdk/python/aleo_shield_swap/journal.py b/shield-swap-sdk/python/aleo_shield_swap/journal.py index be81c872..b102ca7e 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/journal.py +++ b/shield-swap-sdk/python/aleo_shield_swap/journal.py @@ -3,7 +3,7 @@ One JSONL file per profile. State (pending claims, open positions, the counter cursor) is always derived by replaying events, so a crash between append and action never corrupts anything; the worst case is an event whose -action never happened, which downstream verbs tolerate (a claim of a swap +action never happened, which downstream methods tolerate (a claim of a swap that never landed just reports not-finalized). Counter reservation is the concurrency-critical piece: blinded identities @@ -27,7 +27,35 @@ class Journal: - """Event log at *path* (created on first append).""" + """A participant's durable record of swaps, positions, and counters. + + Backs the JSONL file at *path* (created on first append). It holds the two + things the chain cannot give back: + + * every swap's ``blinding_factor`` — the secret + :meth:`~aleo_shield_swap.client.ShieldSwap.claim_swap_output` proves + knowledge of. A swap whose handle is lost cannot be claimed by + anyone, which is the point of blinding it. + * which blinding counters this account has spent, so no two swaps derive + the same blinded address. :meth:`reserve_counters` issues each one once + under a file lock, and a failed swap burns its counter rather than + recycling it. + + Nothing is stored as state — every event is appended and the views are + replayed from the log, so a crash between append and action leaves an event + whose action never happened rather than a corrupt file. Read it through + :meth:`pending_claims`, :meth:`open_positions`, and :meth:`counter_cursor` + rather than :meth:`events`. + + ``ShieldSwap.from_profile()`` wires one up per profile; ``swap_many`` and + ``collect_all`` require it and raise without one. Treat the file as key + material — it carries every blinding factor the account has used. + + Args: + path: Where the log lives, usually ``Profile.journal_path``. Its + parent is created on first append, alongside a ``.lock`` sibling + used to serialize writers. + """ def __init__(self, path: "Path | str") -> None: self.path = Path(path) @@ -50,12 +78,34 @@ def _locked(self) -> Iterator[None]: # ── Raw events ─────────────────────────────────────────────────────────── def append(self, type: str, **fields: Any) -> None: + """Write one event to the log, stamped and under the writer lock. + + Creates the file (and its parent) on first use. Fields are JSON-encoded + as given, so pass only JSON-safe values. Appending is not the action — + an event whose action never happened is expected and tolerated on replay. + + Args: + type: Event type, matched by name when state is derived. + **fields: Event payload; a ``ts`` wall-clock stamp is added. + """ event = {"type": type, "ts": time.time(), **fields} with self._locked(): with self.path.open("a") as f: f.write(json.dumps(event) + "\n") def events(self) -> list[dict[str, Any]]: + """Replay the whole log in append order. + + Reads without taking the lock, so a concurrent append may or may not be + included. An absent file reads as no events rather than raising. + + Returns: + Every event recorded so far. + + Raises: + json.JSONDecodeError: If a line is not valid JSON — a truncated write + from a crash mid-append. + """ if not self.path.exists(): return [] return [json.loads(line) @@ -89,28 +139,76 @@ def counter_cursor(self) -> int: # ── Typed events ───────────────────────────────────────────────────────── def record_swap(self, handle: SwapHandle, counter: int) -> None: + """Log a submitted swap so a later session can claim it. + + Persists the handle's blinding factor — the secret needed to claim — so + the journal file is sensitive. A handle with no ``swap_id`` is still + recorded, but :meth:`pending_claims` skips it as needing manual recovery. + + Args: + handle: The swap handle to persist. + counter: The counter this swap's blinded identity consumed, so it is + never reissued. + """ self.append("swap", counter=counter, **{k: getattr(handle, k) for k in _HANDLE_FIELDS}) def record_swap_failed(self, counter: int, error: str) -> None: + """Burn a counter whose swap never landed. + + The counter stays spent — reusing it would risk a blinded-identity + collision — so this advances the cursor without producing a claimable + swap. + + Args: + counter: The counter to retire. + error: Why the swap failed, kept for diagnosis. + """ self.append("swap_failed", counter=counter, error=error) def record_claim(self, swap_id: str, transaction_id: str, amount_out: int) -> None: + """Mark a swap claimed, dropping it from :meth:`pending_claims`. + + Args: + swap_id: The claimed swap. + transaction_id: Transaction that settled the claim. + amount_out: Amount received, in base units. + """ self.append("claim", swap_id=swap_id, transaction_id=transaction_id, amount_out=amount_out) def record_position(self, position_token_id: str, pool_key: str, transaction_id: str) -> None: + """Log a minted position so it is found without a record scan. + + Args: + position_token_id: The position's token id. + pool_key: Pool the position belongs to. + transaction_id: Transaction that minted it. + """ self.append("position", position_token_id=position_token_id, pool_key=pool_key, transaction_id=transaction_id) def record_position_burned(self, position_token_id: str, transaction_id: str) -> None: + """Mark a position closed, dropping it from :meth:`open_positions`. + + Args: + position_token_id: The burned position's token id. + transaction_id: Transaction that burned it. + """ self.append("position_burned", position_token_id=position_token_id, transaction_id=transaction_id) def record_stage(self, name: str, action: str, detail: str = "") -> None: + """Log progress through a registration stage, for resumable onboarding. + + Args: + name: Stage name. + action: What happened at that stage. + detail: Optional extra context. + """ self.append("stage", name=name, action=action, detail=detail) # ── Derived state ──────────────────────────────────────────────────────── diff --git a/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py b/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py index 439707f3..0ff122a1 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py +++ b/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py @@ -46,12 +46,24 @@ def wrapper_programs(self) -> list[str]: return self._wrappers def funded(self) -> bool: + """True once any wrapper-underlying program holds a private balance. + + Reads private balances, so it needs the account's view key and costs a + record scan per wrapper program. Any non-zero balance counts as funded. + """ balances = self.dex.get_private_balances(self.wrapper_programs()) return any(v > 0 for v in balances.values()) @dataclass class Stage: + """One resumable step of registration: how to tell it is done, and how to do it. + + ``is_done`` is checked before ``run``, so a stage already satisfied is skipped + — that is what makes onboarding idempotent across sessions. Both receive the + shared context; ``run`` returns a one-line detail for the journal. + """ + name: str is_done: Callable[[_Ctx], bool] run: Callable[[_Ctx], str] # returns a one-line detail diff --git a/shield-swap-sdk/python/aleo_shield_swap/mcp.py b/shield-swap-sdk/python/aleo_shield_swap/mcp.py index 36540754..5e87adca 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/mcp.py +++ b/shield-swap-sdk/python/aleo_shield_swap/mcp.py @@ -6,7 +6,7 @@ advertises the exact JSON schema from :func:`~aleo_shield_swap.agent .shield_swap_tools` — FastMCP infers schemas from handler signatures, which would collapse every tool to one opaque ``args`` object. Tools run against -the synchronous :class:`~aleo_shield_swap.client.ShieldSwap` (the full verb +the synchronous :class:`~aleo_shield_swap.client.ShieldSwap` (its full method surface) in a worker thread, keeping the event loop free. Environment: @@ -90,6 +90,12 @@ def _build_dex() -> Any: def main() -> None: + """Serve the shield_swap MCP tools over stdio until the client disconnects. + + Builds a DEX client first, which binds a key from ``ALEO_PRIVATE_KEY`` or + else creates/loads the local participant profile on disk. Blocks for the + lifetime of the server; requires the ``[mcp]`` extra. + """ import anyio from mcp.server.stdio import stdio_server diff --git a/shield-swap-sdk/python/aleo_shield_swap/profile.py b/shield-swap-sdk/python/aleo_shield_swap/profile.py index 9141521f..bd39bc71 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/profile.py +++ b/shield-swap-sdk/python/aleo_shield_swap/profile.py @@ -47,8 +47,33 @@ def _initial_key(network: str) -> tuple[str, str]: class Profile: """A participant's persistent identity and credentials. + Holds exactly one Aleo address, generated on first use and reused every + session after. For several addresses, create a profile per address — each + needs its own home directory (``SHIELD_SWAP_HOME``, or *home* on + :meth:`load_or_create`), since the journal and credentials are per-profile + too. + Load with :meth:`load_or_create`; the profile is created (with fresh key - material) when the home directory has none yet. + material) when the home directory has none yet. The same call does both, + so callers never branch on whether one exists:: + + from pathlib import Path + from aleo_shield_swap import Profile + + # First run: generates a key and writes it owner-only. + profile = Profile.load_or_create() + print(profile.address) # aleo1… + + # Every run after: the same call returns that key, unchanged. + assert Profile.load_or_create().address == profile.address + + # A second address needs its own home. Pass a resolved path — ``~`` + # is not expanded, so a "~/..." string creates a literal ``~`` dir. + other = Profile.load_or_create(Path.home() / ".shield-swap-alt") + + That last call generates a fresh key only when no key is being imported; + with ``SHIELD_SWAP_PRIVATE_KEY`` set, every new home adopts that one key + instead, so all of them share an address. """ def __init__(self, home: Path, data: dict[str, Any]) -> None: @@ -62,6 +87,10 @@ def __repr__(self) -> str: @staticmethod def default_home() -> Path: + """Where profiles live by default: ``$SHIELD_SWAP_HOME`` or ``~/.shield-swap``. + + Local only — returns the path whether or not it exists. + """ env = os.environ.get("SHIELD_SWAP_HOME") return Path(env) if env else Path.home() / ".shield-swap" @@ -69,6 +98,25 @@ def default_home() -> Path: def load_or_create(cls, home: "Path | str | None" = None, *, network: str = "testnet", endpoint: str = DEFAULT_ENDPOINT) -> "Profile": + """Load the profile at *home*, creating one with fresh keys if absent. + + Creating a profile writes a private key to disk at mode 600; an existing + profile file has its mode tightened to 600 on load, healing a loose one. + The key comes from ``SHIELD_SWAP_PRIVATE_KEY`` (or the file named by + ``SHIELD_SWAP_PRIVATE_KEY_FILE``) when set, so a user with an existing + account supplies it out-of-band rather than pasting it into a chat. + + *network* and *endpoint* apply only when creating — they are ignored for an + existing profile, which keeps the values it was created with. + + Args: + home: Profile directory; defaults to :meth:`default_home`. + network: Network to bind a NEW profile to. + endpoint: API endpoint to record on a NEW profile. + + Returns: + The loaded or newly created profile. + """ home = Path(home) if home is not None else cls.default_home() path = home / _PROFILE if path.exists(): @@ -85,24 +133,44 @@ def load_or_create(cls, home: "Path | str | None" = None, *, @property def address(self) -> str: + """The profile's Aleo address (``aleo1…``) — the public half of its key.""" return self._data["address"] @property def private_key(self) -> str: + """The profile's private key, read from disk. + + Whoever holds this controls the account and can decrypt its records — do + not log it, echo it into a conversation, or send it to a service. + """ return self._data["private_key"] @property def network(self) -> str: + """Network this profile is bound to; fixed when the profile was created. + + Key derivations are network-scoped, so reusing a profile against the other + network yields keys that do not match anything on chain. + """ return self._data["network"] @property def endpoint(self) -> str: + """API endpoint recorded for this profile, or the package default. + + Older profiles predate the stored field and fall back to the default. + """ return self._data.get("endpoint", DEFAULT_ENDPOINT) # ── Credentials ────────────────────────────────────────────────────────── @property def credentials(self) -> dict[str, str]: + """DEX credentials saved for this profile — API tokens and session data. + + Re-read from disk on every access, so a value written by another process + is picked up. A missing file reads as an empty dict rather than raising. + """ path = self.home / _CREDENTIALS return json.loads(path.read_text()) if path.exists() else {} @@ -113,4 +181,9 @@ def save_credentials(self, **kv: Optional[str]) -> None: @property def journal_path(self) -> Path: + """Where this profile's :class:`~aleo_shield_swap.journal.Journal` lives. + + Returns the path whether or not the file exists — the journal creates it + on first append. + """ return self.home / "journal.jsonl" diff --git a/shield-swap-sdk/python/aleo_shield_swap/skills/SKILL.md b/shield-swap-sdk/python/aleo_shield_swap/skills/SKILL.md index 7801f397..a4e238fe 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/skills/SKILL.md +++ b/shield-swap-sdk/python/aleo_shield_swap/skills/SKILL.md @@ -9,9 +9,9 @@ installed version): python -m aleo_shield_swap Follow its Tier 1 lifecycle and conversation pattern. Write Python against -the SDK; don't re-implement flows the verbs already provide, and don't read +the SDK; don't re-implement flows the methods already provide, and don't read SDK source unless the guide genuinely lacks the answer. Preconditions are -enforced in code — on error, read the exception message; it names the verb +enforced in code — on error, read the exception message; it names the method that fixes it. To install this skill into a Claude Code project, copy this directory to diff --git a/shield-swap-sdk/python/aleo_shield_swap/types.py b/shield-swap-sdk/python/aleo_shield_swap/types.py index 89bc8948..f6ded0d8 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/types.py +++ b/shield-swap-sdk/python/aleo_shield_swap/types.py @@ -1,7 +1,7 @@ """Semantic layer over the generated wire classes. The generated ``_generated.py`` classes carry the wire shapes; the classes -here carry meaning: the persistable swap handle, typed verb results, and a +here carry meaning: the persistable swap handle, typed method results, and a ``Slot`` view with Q128.128 price math and range helpers. """ from __future__ import annotations @@ -36,10 +36,29 @@ class SwapHandle: program: str def to_json(self) -> str: + """Serialize the handle to JSON for storage or hand-off. + + Includes the blinding factor, so the output is secret-bearing — treat it + like a key. Round-trips through :meth:`from_json`. + """ return json.dumps(asdict(self)) @classmethod def from_json(cls, s: str) -> "SwapHandle": + """Rebuild a handle from :meth:`to_json` output. + + Args: + s: JSON produced by :meth:`to_json`. + + Returns: + The restored handle, ready to claim. + + Raises: + json.JSONDecodeError: If *s* is not valid JSON. + TypeError: If the JSON omits a field or carries an unknown one — the + handle is rebuilt strictly, so a partial object fails loudly + rather than producing an unclaimable handle. + """ return cls(**json.loads(s)) @@ -54,13 +73,19 @@ class ClaimResult: @dataclass(frozen=True) class MintResult: + """Outcome of a mint: the new position's id and the transaction that made it. + + ``position_token_id`` is ``None`` when the transition published no id, which + leaves the position discoverable only by a record scan. + """ + position_token_id: Optional[str] transaction_id: str @dataclass(frozen=True) class TxResult: - """Result of a liquidity verb; ``position_token_id`` when the verb + """Result of a liquidity method; ``position_token_id`` when the method re-issues/identifies a position, else ``None``.""" position_token_id: Optional[str] @@ -85,6 +110,11 @@ def __repr__(self) -> str: @property def raw(self) -> Slot: + """The wrapped slot (escape hatch), with no fixed-point interpretation. + + Prefer :meth:`price` and the other helpers — reading ``sqrt_price`` off + this directly means re-deriving the Q128.128 conversion yourself. + """ return self._slot def price(self, decimals0: int, decimals1: int) -> Decimal: diff --git a/shield-swap-sdk/scripts/rehearsal.py b/shield-swap-sdk/scripts/rehearsal.py index 2a52b357..284c3901 100644 --- a/shield-swap-sdk/scripts/rehearsal.py +++ b/shield-swap-sdk/scripts/rehearsal.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Stress-test rehearsal: the four flows, end to end, via the tier-1 verbs. +"""Stress-test rehearsal: the four flows, end to end, via the tier-1 methods. Usage: python scripts/rehearsal.py [--code INVITE] [--home DIR] Needs: network access. ``--code`` takes a REFERRAL code (human-pasted by diff --git a/shield-swap-sdk/tests/integration/test_devnode_lifecycle.py b/shield-swap-sdk/tests/integration/test_devnode_lifecycle.py index 57295658..b89fef55 100644 --- a/shield-swap-sdk/tests/integration/test_devnode_lifecycle.py +++ b/shield-swap-sdk/tests/integration/test_devnode_lifecycle.py @@ -1,4 +1,4 @@ -"""Full AMM lifecycle on a devnode, through the ShieldSwap verbs. +"""Full AMM lifecycle on a devnode, through the ShieldSwap methods. Python analog of the TS suite's ``devnodeLifecycle.actions.e2e.test.ts``: a non-admin user creates two pools (same pair, two fee tiers), mints and diff --git a/shield-swap-sdk/tests/test_api_client.py b/shield-swap-sdk/tests/test_api_client.py index 8f6a3da7..94f4eae8 100644 --- a/shield-swap-sdk/tests/test_api_client.py +++ b/shield-swap-sdk/tests/test_api_client.py @@ -3,7 +3,7 @@ import pytest -from aleo_shield_swap.api import ApiClient +from aleo_shield_swap.api import ApiClient, AsyncApiClient from aleo_shield_swap.errors import DexApiError POOLS = json.loads((Path(__file__).parent / "fixtures" / "pools_response.json").read_text()) @@ -207,19 +207,22 @@ async def test_async_lifecycle_endpoints(): await api.request_airdrop("aleo1a") -def test_access_code_self_registration_flow(): - # Operators mint access codes and redeem them programmatically — - # a separate surface from the human referral-paste path. +def test_access_code_redeem_flow(): + # Access codes are redeemed programmatically — a separate surface from the + # human referral-paste path. Minting is deliberately not exposed by the + # SDK, so a code arrives out-of-band from an operator. api, s = _lifecycle_client( - _Resp(200, {"data": {"codes": ["ACODE12CHARS"]}}), _Resp(200, {"data": {"code": "ACODE12CHARS", "status": "redeemed"}}), ) - codes = api.generate_access_codes() - assert codes == ["ACODE12CHARS"] - out = api.redeem_access_code(codes[0]) + out = api.redeem_access_code("ACODE12CHARS") assert out.status == "redeemed" - assert [c[1] for c in s.calls] == ["https://x/access/generate", - "https://x/access/redeem"] + assert [c[1] for c in s.calls] == ["https://x/access/redeem"] + + +def test_minting_access_codes_is_not_exposed(): + # Guard the removal: the SDK must not offer a way to mint invites. + for cls in (ApiClient, AsyncApiClient): + assert not hasattr(cls, "generate_access_codes"), cls.__name__ def test_cookie_session_outranks_bearer(): diff --git a/shield-swap-sdk/tests/test_gen_context.py b/shield-swap-sdk/tests/test_gen_context.py index 64ead9dc..24d2c7e1 100644 --- a/shield-swap-sdk/tests/test_gen_context.py +++ b/shield-swap-sdk/tests/test_gen_context.py @@ -23,11 +23,11 @@ def test_tier1_lifecycle_and_conversation_pattern(): def test_tier2_covers_building_blocks_and_stages(): page = _render() - for verb in ("swap_many", "claim_swap_output", "collect_all", + for method in ("swap_many", "claim_swap_output", "collect_all", "increase_liquidity", "decrease_liquidity", "derive_pool_key", "simulate", "blinded_identity_at", "redeem_code", "request_airdrop"): - assert verb in page, verb + assert method in page, method # stages rendered FROM the list, not hand-written from aleo_shield_swap.lifecycle import REGISTRATION_STAGES for stage in REGISTRATION_STAGES: @@ -42,6 +42,8 @@ def test_committed_page_is_current(): def test_page_stays_compact(): - # ~5k tokens — cheap context, enforced. Raised 20k → 22k with the - # router-dispatch surface (wrapper_proofs / withdrawal params). - assert len(_render()) < 22_000 + # ~6k tokens — cheap context, enforced. Raised 20k → 22k with the + # router-dispatch surface (wrapper_proofs / withdrawal params); 22k → 24k + # when get_pools/get_tokens/derive_pool_key/derive_tick_key picked up full + # docstrings (they rendered blank before). + assert len(_render()) < 24_000 diff --git a/shield-swap-sdk/tests/test_liquidity.py b/shield-swap-sdk/tests/test_liquidity.py index 5b136b22..8fde02ee 100644 --- a/shield-swap-sdk/tests/test_liquidity.py +++ b/shield-swap-sdk/tests/test_liquidity.py @@ -1,4 +1,4 @@ -"""Liquidity verbs — exact input orders per the deployed shield_swap.aleo: +"""Liquidity methods — exact input orders per the deployed shield_swap.aleo: create_pool: [token0, token1, fee u16, sqrt_price U256, spacing u32, tick i32] mint: [nonce field, record0, record1, recipient, withdrawal, MintPositionRequest, token0, token1, signer_proofs,