Skip to content

fix(facade): exact credits/microcredits conversion - #66

Merged
iamalwaysuncomfortable merged 1 commit into
docs/docstring-coverage-and-invite-minting-removalfrom
fix/exact-credits-microcredits-conversion
Jul 30, 2026
Merged

fix(facade): exact credits/microcredits conversion#66
iamalwaysuncomfortable merged 1 commit into
docs/docstring-coverage-and-invite-minting-removalfrom
fix/exact-credits-microcredits-conversion

Conversation

@iamalwaysuncomfortable

Copy link
Copy Markdown
Member

to_microcredits and from_microcredits both went through binary floating point and lost value. This makes both exact.

Stacked on #65 — base is docs/docstring-coverage-and-invite-minting-removal, because both PRs touch the docstrings of these same two facade methods. Merge #65 first; GitHub will retarget this to feat/shield-swap-update automatically.

The bugs

Inbound — silent underpayment. credits * 1_000_000 in float, then int() truncating toward zero:

>>> aleo.to_microcredits(1.005)   # before
1004999                            # 1.005 * 1e6 == 1004999.9999999999

One microcredit lost on a completely ordinary amount, with no error. Sub-microcredit input disappeared entirely — to_microcredits(0.9999999) returned 999999.

Outbound — not representable. from_microcredits returned a float, but microcredits are a u64 and past 2**53 a float cannot hold the integer:

>>> m = 2**64 - 1
>>> int(aleo.from_microcredits(m) * 1_000_000) - m   # before
1

2884 of the first 200_000 microcredit values failed micro -> credits -> micro identity — about 1.4%.

The fix

Both directions compute in Decimal.

Float input is routed through str(), which recovers the shortest representation that round-trips — the literal the caller actually wrote. That is what rescues 1.005, since Decimal(1.005) would faithfully preserve the float's real value of 1.00499999999999989... and stay wrong. str and Decimal input are exact already, so to_microcredits("1.005") is now the precise way to pass an amount that came from text or JSON.

Sub-microcredit precision raises ValueError naming the offending value, with allow_rounding=True to opt back into truncation. Losing value should be an error, not a default.

>>> aleo.to_microcredits(1.005)
1005000
>>> aleo.to_microcredits("1.005")
1005000
>>> aleo.to_microcredits(0.9999999)
ValueError: 0.9999999 credits is 999999.9000000 microcreditsfiner than the
chain's smallest unit. Round it yourself, or pass allow_rounding=True to
truncate toward zero.
>>> aleo.to_microcredits(0.9999999, allow_rounding=True)
999999

Breaking change

from_microcredits returns Decimal, not float.

Equality and float() still work, so all three pre-existing assertions pass unchanged, including from_microcredits(1_500_000) == 1.5. What breaks is mixing the result into float arithmetic — from_microcredits(x) * 0.5 raises TypeError, as does json.dumps of the result. Call float(...) deliberately if you want that, accepting the loss. The docstring says so.

This is the deliberate trade for exactness: Decimal is the correct type for money, and a float simply cannot represent the full u64 range.

Scope

Contained, and checked rather than assumed: these are user-facing convenience helpers only. No internal fee or amount path consumes them — the SDK is integer microcredits end to end — and shield-swap never calls them, working in raw base units throughout. So no transaction the SDK builds today was affected; only callers who used the helpers to compute an amount.

Verification

Gate Result
pytest python/tests -m "not slow" (sdk) 896 passed, 17 skipped (+6 new)
pyright strict (sdk) 0 errors, 0 warnings
pytest (shield-swap) 174 passed — unaffected

The 6 new tests pin exactly what was broken: the 1.005 class across float/str/Decimal, the ValueError and its escape hatch, NaN/inf/garbage rejection, exactness at 2**53 + 1 and u64 max, identity across a 5000-value sweep, and that float comparison still holds.

Both directions went through binary floating point and lost value.

`credits_to_microcredits` multiplied by 1_000_000 as a float and then
truncated toward zero with int(), so ordinary amounts silently underpaid:
1.005 credits became 1_004_999 microcredits, not 1_005_000. Sub-microcredit
input was dropped with no error at all (0.9999999 -> 999_999).

`microcredits_to_credits` returned a float. Microcredits are a u64, and past
2**53 a float cannot hold the integer — u64 max round-tripped off by one, and
2884 of the first 200_000 microcredit values failed
`micro -> credits -> micro` identity.

Both now compute in Decimal. Float input is routed through str(), which
recovers the shortest representation that round-trips — i.e. the literal the
caller wrote — which is what rescues 1.005; str and Decimal input are exact
already. Sub-microcredit precision now raises ValueError naming the value,
with allow_rounding=True to opt back into truncation, so lost value is an
error rather than a silent underpayment.

`from_microcredits` returns Decimal instead of float. It still compares equal
to the obvious float, so existing assertions hold unchanged, but mixing it
into float arithmetic now raises — convert with float() deliberately if you
want that, accepting the loss.

Scope is contained: these are user-facing convenience helpers only. No
internal fee or amount path consumes them (the SDK is integer microcredits
end to end), and shield-swap does not use them.

Verified: 896 passed (sdk, -m "not slow"; +6 new), pyright strict 0 errors,
shield-swap 174 passed and unaffected.
@iamalwaysuncomfortable
iamalwaysuncomfortable merged commit 2d16bac into docs/docstring-coverage-and-invite-minting-removal Jul 30, 2026
6 checks passed
iamalwaysuncomfortable added a commit that referenced this pull request Jul 30, 2026
* docs: voice.md docstrings for the undocumented public surface

Adds docstrings to the 206 public classes/methods in sdk/python/aleo and
shield-swap-sdk that had none, following .agents/voice.md: present-tense
verb lead, side effects named (network / fee / local-only), and each
argument, return, and raised error described by consequence.

Section syntax follows each file's local idiom rather than one global
rule — numpy `Parameters` inside facade/ (54 existing blocks, and
voice.md shipped in the same commit as those files), Google `Args:`
elsewhere. shield-swap keeps its terser prose voice and its
"see :meth:`ApiClient.X`" convention for async mirrors. Where a sync
counterpart was already documented the text is mirrored, with
async-specific facts corrected: wait_for_transaction_confirmation yields
to the event loop via asyncio.sleep, AsyncDexCall.simulate explains why
it is not awaited, and three cross-references now point at the async
classes instead of RecordScanner / AleoNetworkClient.

Three behaviours were checked against the code rather than assumed:
get_block_range's end-inclusivity varies by node build (the e2e test
says so) so the docstring warns against relying on the last element;
decrypt_enabled gates find_credits_record(s), not owned(), which
decrypts opportunistically; and codegen's main() exits via argparse on a
usage error rather than returning 1.

Also removes ApiClient.generate_access_codes and its async mirror.
Minting invite/access codes should not be part of the SDK surface.
redeem_access_code stays, so a code obtained out-of-band still works,
and human-pasted referral invites keep going through redeem_code. Note
this reduces SDK surface, not access: POST /access/generate is still
reachable directly, and the real gate is the server-side generate right.
test_minting_access_codes_is_not_exposed guards the removal.

shield-swap-sdk/AGENTS.md is generated from these docstrings, so both
copies are regenerated. Four TIER2 entries (api.get_pools,
api.get_tokens, derive_pool_key, derive_tick_key) rendered with blank
bodies before and now carry real text, which pushed the page past
test_gen_context.py's compactness budget — that threshold moves 22k to
24k, with the reason recorded alongside the prior 20k to 22k raise.

Verified: 890 passed (sdk, -m "not slow"), 174 passed (shield-swap),
pyright strict 0 errors on sdk, gen_context.py --check clean. The 15
pyright errors in shield-swap-sdk are pre-existing and unchanged
(confirmed by re-running against HEAD).

* fix(facade): exact credits/microcredits conversion (#66)

Both directions went through binary floating point and lost value.

`credits_to_microcredits` multiplied by 1_000_000 as a float and then
truncated toward zero with int(), so ordinary amounts silently underpaid:
1.005 credits became 1_004_999 microcredits, not 1_005_000. Sub-microcredit
input was dropped with no error at all (0.9999999 -> 999_999).

`microcredits_to_credits` returned a float. Microcredits are a u64, and past
2**53 a float cannot hold the integer — u64 max round-tripped off by one, and
2884 of the first 200_000 microcredit values failed
`micro -> credits -> micro` identity.

Both now compute in Decimal. Float input is routed through str(), which
recovers the shortest representation that round-trips — i.e. the literal the
caller wrote — which is what rescues 1.005; str and Decimal input are exact
already. Sub-microcredit precision now raises ValueError naming the value,
with allow_rounding=True to opt back into truncation, so lost value is an
error rather than a silent underpayment.

`from_microcredits` returns Decimal instead of float. It still compares equal
to the obvious float, so existing assertions hold unchanged, but mixing it
into float arithmetic now raises — convert with float() deliberately if you
want that, accepting the loss.

Scope is contained: these are user-facing convenience helpers only. No
internal fee or amount path consumes them (the SDK is integer microcredits
end to end), and shield-swap does not use them.

Verified: 896 passed (sdk, -m "not slow"; +6 new), pyright strict 0 errors,
shield-swap 174 passed and unaffected.

* docs: clarify OwnedFilter uuid default wording

* docs: drop the self-hosted-scanner recommendation from record docstrings

* docs: drop redundant 'Hits the network' notes and the block-range build caveat

* docs: make get_pools/get_tokens concrete, name methods instead of 'verbs'

* docs(voice): ban "reach for" and vague hedges

* docs: drop self-hosted-scanner advice everywhere, keep the view-key disclosure

* docs(voice): drop self-hosting from the privacy stance

* docs: drop self-hosted-scanner mentions from the READMEs

* docs(shield-swap): say "methods", not "verbs"

* docs: tighten get_pools, explain why token info can be None

* docs: simplify the token-info None explanation

* docs(voice): require plain verbs; reword get_tokens decimals note

* docs: reword get_swap lag note and get_public_balances

* docs: state get_ohlcv's real granularity values and unix-second bounds

* docs: explain what the Journal is on the class itself

* docs: state that a profile holds one Aleo address

* docs: add a Profile create/load usage example
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant