fix(facade): exact credits/microcredits conversion - #66
Merged
iamalwaysuncomfortable merged 1 commit intoJul 30, 2026
Conversation
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
merged commit Jul 30, 2026
2d16bac
into
docs/docstring-coverage-and-invite-minting-removal
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
to_microcreditsandfrom_microcreditsboth 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 tofeat/shield-swap-updateautomatically.The bugs
Inbound — silent underpayment.
credits * 1_000_000in float, thenint()truncating toward zero:One microcredit lost on a completely ordinary amount, with no error. Sub-microcredit input disappeared entirely —
to_microcredits(0.9999999)returned999999.Outbound — not representable.
from_microcreditsreturned a float, but microcredits are au64and past 2**53 a float cannot hold the integer:2884 of the first 200_000 microcredit values failed
micro -> credits -> microidentity — 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 rescues1.005, sinceDecimal(1.005)would faithfully preserve the float's real value of1.00499999999999989...and stay wrong.strandDecimalinput are exact already, soto_microcredits("1.005")is now the precise way to pass an amount that came from text or JSON.Sub-microcredit precision raises
ValueErrornaming the offending value, withallow_rounding=Trueto opt back into truncation. Losing value should be an error, not a default.Breaking change
from_microcreditsreturnsDecimal, notfloat.Equality and
float()still work, so all three pre-existing assertions pass unchanged, includingfrom_microcredits(1_500_000) == 1.5. What breaks is mixing the result into float arithmetic —from_microcredits(x) * 0.5raisesTypeError, as doesjson.dumpsof the result. Callfloat(...)deliberately if you want that, accepting the loss. The docstring says so.This is the deliberate trade for exactness:
Decimalis the correct type for money, and a float simply cannot represent the fullu64range.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
pytest python/tests -m "not slow"(sdk)pyrightstrict (sdk)pytest(shield-swap)The 6 new tests pin exactly what was broken: the
1.005class acrossfloat/str/Decimal, theValueErrorand its escape hatch, NaN/inf/garbage rejection, exactness at2**53 + 1andu64max, identity across a 5000-value sweep, and that float comparison still holds.