Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d3641dc
docs: voice.md docstrings for the undocumented public surface
iamalwaysuncomfortable Jul 30, 2026
2d16bac
fix(facade): exact credits/microcredits conversion (#66)
iamalwaysuncomfortable Jul 30, 2026
f42c118
docs: clarify OwnedFilter uuid default wording
iamalwaysuncomfortable Jul 30, 2026
f5cc97a
docs: drop the self-hosted-scanner recommendation from record docstrings
iamalwaysuncomfortable Jul 30, 2026
309c84b
docs: drop redundant 'Hits the network' notes and the block-range bui…
iamalwaysuncomfortable Jul 30, 2026
f227734
docs: make get_pools/get_tokens concrete, name methods instead of 've…
iamalwaysuncomfortable Jul 30, 2026
b5dc1b1
docs(voice): ban "reach for" and vague hedges
iamalwaysuncomfortable Jul 30, 2026
462f3ab
docs: drop self-hosted-scanner advice everywhere, keep the view-key d…
iamalwaysuncomfortable Jul 30, 2026
7dba09d
docs(voice): drop self-hosting from the privacy stance
iamalwaysuncomfortable Jul 30, 2026
2b144a2
docs: drop self-hosted-scanner mentions from the READMEs
iamalwaysuncomfortable Jul 30, 2026
e64006a
docs(shield-swap): say "methods", not "verbs"
iamalwaysuncomfortable Jul 30, 2026
865a38a
docs: tighten get_pools, explain why token info can be None
iamalwaysuncomfortable Jul 30, 2026
10e0cd1
docs: simplify the token-info None explanation
iamalwaysuncomfortable Jul 30, 2026
6e1e1c6
docs(voice): require plain verbs; reword get_tokens decimals note
iamalwaysuncomfortable Jul 30, 2026
90f3c78
docs: reword get_swap lag note and get_public_balances
iamalwaysuncomfortable Jul 30, 2026
cf26b3b
docs: state get_ohlcv's real granularity values and unix-second bounds
iamalwaysuncomfortable Jul 30, 2026
195dfa6
docs: explain what the Journal is on the class itself
iamalwaysuncomfortable Jul 30, 2026
0c380af
docs: state that a profile holds one Aleo address
iamalwaysuncomfortable Jul 30, 2026
5fde18f
docs: add a Profile create/load usage example
iamalwaysuncomfortable Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion .agents/voice.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`),
Expand All @@ -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``).
4 changes: 2 additions & 2 deletions .claude/skills/shield-swap/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
2 changes: 1 addition & 1 deletion sdk/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
44 changes: 44 additions & 0 deletions sdk/python/aleo/_client_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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()}
Expand All @@ -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)


Expand All @@ -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:
Expand Down
96 changes: 82 additions & 14 deletions sdk/python/aleo/_facade_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand All @@ -46,27 +50,91 @@ 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
--------
>>> credits_to_microcredits(1)
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)
47 changes: 47 additions & 0 deletions sdk/python/aleo/_scanner_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading