feat: migrate shield-swap-sdk to the new shield_swap.aleo stack - #62
Open
iamalwaysuncomfortable wants to merge 24 commits into
Open
feat: migrate shield-swap-sdk to the new shield_swap.aleo stack#62iamalwaysuncomfortable wants to merge 24 commits into
iamalwaysuncomfortable wants to merge 24 commits into
Conversation
- /auth/verify: send challenge_id from the challenge payload - sessions are httpOnly cookies + X-CSRF-Token (legacy body-JWT kept) - is_authenticated covers both credential kinds - salted retry on DPS consumer-username collisions - lifecycle live test: invite codes are pasted, never generated; shed SHIELD_SWAP_PRIVATE_KEY so the fresh-profile premise holds
…nt under cookie auth - _headers: prefer the live session (csrf + cookies) over Authorization; ss_ tokens don't cover the /access tier and the server reads the header first - 401 while both credentials are loaded drops the expired session and retries as bearer (15-min sessions) - _auth_done recognizes cookie sessions; CSRF never persisted as jwt; from_profile prefers the durable ss_ token over stale session creds
Slot-derived insert hints only validate for a pool's FIRST position — finalize asserts the true linked-list predecessors, so every later mint was rejected on populated pools. find_tick_predecessor walks the ticks mapping from the MIN sentinel (fresh pools anchor at the sentinel; initialized ticks return themselves since validation is skipped).
… 0.3.0 shield-swap-sdk now floors aleo-sdk at 0.3 (Array codegen runtime). Docs updated: staging auth/session + referral-vs-access invites, live- verified LP behaviors (tick-list hints, wrapped-side exact amounts, slippage headroom), sdk-abi example re-pointed at the new stack.
iamalwaysuncomfortable
temporarily deployed
to
pypi-abi
July 28, 2026 21:46 — with
GitHub Actions
Inactive
iamalwaysuncomfortable
temporarily deployed
to
pypi
July 28, 2026 21:46 — with
GitHub Actions
Inactive
iamalwaysuncomfortable
temporarily deployed
to
pypi-shield-swap
July 28, 2026 21:47 — with
GitHub Actions
Inactive
* 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
* fix(shield-swap): resolve the DEX API host per network The hard-coded staging host (amm-api-staging.dev.provable.com) now 404s — the deployment moved and shield_swap.aleo is live on mainnet as well as testnet. There are two API hosts now, one per network, so a single module-level constant cannot be right for both. Replaces DEFAULT_API_URL's baked value with SHIELD_SWAP_API_URLS keyed by network and api_url_for(network) to resolve it. ShieldSwap and AsyncShieldSwap now default api_url from their bound client's network_name, so the off-chain indexer always matches the chain being read — a testnet pool key means nothing to the mainnet indexer, and the old default silently guaranteed one of the two was wrong. SHIELD_SWAP_API_URL still overrides every network. An unknown network raises rather than falling back, and the standalone-ApiClient default points at testnet deliberately: an accidental default must not reach mainnet. Verified both hosts serve /tokens and /pools unauthenticated and gate /access/status with 401. 179 passed (shield-swap; +5 new). * fix(shield-swap): walk the tick list on increase_liquidity; OHLCV takes unix seconds Two correctness bugs, both confirmed against the live testnet. increase_liquidity derived its insert hints from pick_insert_hint, which reads slot.next_init_below/above — those bracket the pool's CURRENT tick, not the target. Any bound further out than one initialized tick therefore got a hint above itself, which finalize rejects after the fee is spent. mint already walked the on-chain list (aa71c33); increase_liquidity now does the same via find_tick_predecessor. pick_insert_hint is deleted rather than left in place. It was unused after this change, unexported, untested, and its own docstring conceded it returns hints the contract rejects — a known-wrong helper is a trap. get_ohlcv typed from_ts/to_ts as str, but the API declares from/to as int64 unix seconds (inclusive start, exclusive end). test_api_get_ohlcv passed ISO-8601 strings and failed with 400 "query parameters do not match the expected schema" — it had never run in CI, being live-marked. Both are now int, and the test passes real unix seconds. Verified: 14/14 live reads pass against api.testnet.swap.shield.fi (the OHLCV test was the only red one), 179 shield-swap, 873 sdk, 11 devnode. * chore(shield-swap): regen OpenAPI per-network; pick up UsdcUsdQuote + pool valuation * feat(shield-swap): owned-position views with the contract's view math get_owned_positions(pool_key=?) and get_owned_position(token_id) answer "what do I hold and what is it worth right now" without a transaction. A position spans two sources that neither side can answer alone: the private PositionNFT record carries identity (pool, range, withdrawal) and no amounts; the public positions/slots/ticks mappings carry amounts and no identity. Callers previously had to persist token ids externally and reimplement two pieces of contract math to display a position. position_math.py mirrors the amm-v3 view helpers bit-exactly — amounts_for_liquidity (view_amounts_for_liquidity), fee_growth_inside (get_fee_growth_inside), fee_owed, and u256_wrapping_sub (u256::u256_sub). Fee growth is 256-bit and modular by design: an outside counter may exceed the global one and the difference wraps at 2^256, so every subtraction goes through the wrapping helper — a plain - would raise where the contract wraps. The 17 math vectors are transcribed from the contract's own tests/test_amm_helpers.leo, including the wrap-negative fee_growth_inside cases, so a divergence in either implementation fails the suite rather than silently producing wrong balances. state is None while a mint finalizes (record spendable, mapping not written) and when a boundary tick is uninitialized — the identity stays usable in both cases. Burned positions cannot appear, since burn consumes the record. 196 passed (+27: 17 math vectors, 10 join/filter/lag paths). * feat(shield-swap): swap reserves its blinding counter and journals the handle swap() derived its blinded identity through next_blinded_identity, which scans for the first counter the chain does not carry. Correct in sequence, unsafe in parallel: two swaps starting together read identical chain state, reach the same counter, and the second reverts at finalize once the first consumes it. Nothing surfaces locally — at proving time the address genuinely was unused, because the check and the use are not atomic. swap_many already avoided this by reserving from the journal; single swap() did not. It now takes the same path: reserve one counter under the journal's file lock, derive the identity at it, and record the resulting handle once the broadcast is accepted. Two concurrent swaps can no longer collide. Journaling the handle is the other half. The blinding factor is the only thing that can claim a swap — lose it and the output is unclaimable by anyone, which is the point of blinding it. Recording at accept time (not confirmation) means a crash mid-flight leaves a claimable handle for collect_all(). A journal write that fails after the swap lands raises rather than being swallowed: the swap is already spent, so silently dropping its claim secret is the worse outcome. track=False opts out, an explicit identity= still wins, and without a journal the on-chain probe remains the only option — documented as racing. 206 passed (+5 covering reservation, distinct counters, opt-out, explicit identity, and the journal-less path). * fix(shield-swap): keep pyright clean on the reserved-counter narrowing * fix(shield-swap): the airdrop stage is testnet-only, say so on mainnet The mainnet API publishes neither /airdrop nor /airdrop/{job_id} — verified against both OpenAPI specs — so requesting one there returned 404 and blew up onboard() with a DexApiError. The remedy is the caller funding the account, not a retry, so the stage now raises NotFundedError naming the network and the address. Mainnet onboarding is authenticate -> redeem -> credentials, then the caller funds, then the funded stage passes. * feat(shield-swap): from_profile takes network and endpoint A profile-bound client could only ever be testnet: from_profile called Profile.load_or_create with no arguments, and that defaults network to testnet. With shield_swap.aleo now deployed on mainnet, mainnet was unreachable through the documented entry point. Both apply only when the profile is created — an existing one keeps what it was created with, since its derived pool keys and blinded identities are network-scoped and would not transfer. Give each network its own home. Verified against both live deployments: 5 pools/8 tokens on testnet and 4 pools/8 tokens on mainnet, with locally derived pool keys matching each indexer on both. * fix(shield-swap): the write tier never passed its own credentials conftest gated the write tier on ALEO_E2E_API_KEY / ALEO_E2E_CONSUMER_ID but built its provider without them, so the hosted record scanner answered Unauthorized and every write test failed on the first record read — before reaching the chain. It also never registered the account, which scanning requires. Confirmed by contrast: the same account reads private balances fine when the credentials are wired in (57 credits + test ETH + USDCx on testnet, 0.4 credits + USDCx on mainnet), and fails with exactly this Unauthorized when they are not. The provider now receives both, and the account is registered with the scanner once a key exists. * fix(shield-swap): authenticate the write-tier clients before auth-gated reads get_route is auth-gated; the bespoke clients in test_swap_lifecycle never established a session, so it answered 401 before anything was proved. * fix(shield-swap): quote failures stay errors; expanduser; pyright to zero Four fixes, each a way a wrong value used to reach the chain or the disk. _quote_expected_out swallowed NotAuthenticatedError/NotRedeemedError into a None return, which resolve_swap_params then replaced with a spot estimate. Spot ignores the pool fee, so amount_out_min came out above what the pool can pay: the caller paid for a proof the finalize rejected. "Could not ask" is not "no route" — auth failures now propagate, and swap_many refuses outright when it has no quote and slippage_bps < 10000 rather than proving and broadcasting N swaps engineered to revert. slippage_bps=10000 ("accept any output") still proceeds without one. Profile never called expanduser, so load_or_create("~/x") and SHIELD_SWAP_HOME=~/x each created a literal ~ directory in the cwd and wrote the private key there — where no later run would look for it. Both paths now expand. pyright: 15 errors -> 0. Twelve were real. _lp_programs was annotated str while its own docstring said "None when an explicit record made resolution unnecessary"; the annotation was simply wrong. select_token_record's callers relied on a short-circuit pyright cannot see, so mint and increase now share _fund_side, which resolves record and program together and removes the duplication. authenticate returned self._csrf (str | None) as str. Two dict comprehensions produced list[str | None] despite filtering. The last three were unresolved mcp imports — a declared optional extra, now installed so pyright checks the MCP server rather than skipping it. That surfaced a latent bug in sdk-abi's own stub: _aleo_abi.pyi declared generate_abi with three parameters while the pyo3 signature and the runtime both take four (imports=None). aleo.abi passes four and was correct; the stub was wrong and is now generated from the Rust signature. Bumps all three packages 0.3.1 -> 0.4.0: this branch removes generate_access_codes, replaces DEFAULT_API_URL, and retypes get_ohlcv's timestamps, none of which are patch-compatible. 220 shield-swap, 884 sdk, pyright clean in both packages. * fix(shield-swap): quote the route in canonical amounts, not base units test_private_swap_roundtrip proved, broadcast, and had the network reject at1xze86e… — fee consumed. Diagnosed from the rejected transition's inputs: amount_out_min was 1_844_890_080 while sqrt_price_limit sat at exactly MIN_SQRT_RATIO_X128, the default extreme, so the price bound was not the constraint. The minimum was simply unpayable. Cause, measured against the live API on the ETH/ALEO pool: get_route(amount_in=10000000000000000) -> 1863.544605 (raw base units) get_route(amount_in=0.01) -> 1058.294112 (canonical) /route takes a CANONICAL decimal amount. The test passed raw base units, so the API quoted a trade of 10_000_000 ETH rather than 0.01, returned a price from deep in the book, and the test scaled that into a minimum 76% above what the pool would actually pay. _quote_expected_out was correct throughout — it divides by 10**dec_in before asking and returns 1058294112, matching the canonical quote exactly. The test reimplemented the conversion and got it wrong, so it now calls the helper instead. test_reads_live had the same confusion, passing 10**decimals as amount_in. It never failed because it asserts only shape, but it documented the wrong convention; it now passes "1" with the units spelled out. The write tier now passes for the first time: 2 passed, both roundtrips proved, broadcast, confirmed and claimed against real testnet. * fix(shield-swap): address self-review — async parity, build-time cost, dead code Six findings from reviewing the branch. swap() reserves its counter at BUILD time, not at the terminal method: the blinded address is a transition input, so the identity must exist before anything can be assembled. That means discarding a prepared call — or only calling simulate() — still spends a counter, which contradicts the "nothing happens until a terminal method" contract the README states. The reservation cannot be deferred, so it is documented instead of hidden, with track=False offered as the side-effect-free build. Three tests pin the behaviour rather than leaving it as prose. Async parity: get_owned_positions/get_owned_position now exist on AsyncShieldSwap. sdk/AGENTS.md requires sync+async pairs with shared pure logic, so the PositionNFT record shape moved to _core as POSITION_RECORD_FIELDS + decode_position_record rather than being copied into the second client. test_async_parity fails on any future read method added to one client and not the other, and also fails when its own SYNC_ONLY allowlist goes stale. The async swap docstring pointed at "journal-reserved counters" the async client cannot reserve; it now states that it probes, that the probe races, and that concurrent callers must pass identity explicitly. get_owned_positions read the slot once per position; ten positions in one pool cost ten identical reads. A per-call cache keyed on pool brings that to one, asserted by counting the calls. Record detection keyed on "tick_lower" alone, so any future record type carrying that field would be misread as a position. It now requires the whole field set, with a test using an impostor record that shares one field. amounts_for_liquidity ended in an unreachable `return 0, 0` — its three arms are exhaustive by construction. A silent (0, 0) would read as "holds nothing", so it raises AssertionError instead. amount0_delta divides by both bounds and now documents ZeroDivisionError. The swap docstring renders into AGENTS.md and pushed the page past its compactness budget; it is written tighter rather than raising the cap a third time (23866, 134 spare). 229 shield-swap (+9), 884 sdk, 14 live reads, pyright clean in both. * fix(shield-swap): await the async record scan; both Copilot findings AsyncShieldSwap.get_owned_positions did not await record_provider.find. AsyncRecordsModule.find is `async def`, so `records` was a coroutine and iterating it raised "TypeError: 'coroutine' object is not iterable" — the method could never have worked. Reproduced before fixing. My own parity test missed it because it asserted hasattr, not behaviour: a method that exists and always raises satisfies a presence check. So this adds test_owned_positions_async, which drives the async views against a fake whose find() really is a coroutine — the join, the finalize lag, record filtering, the pool filter, lookup by id, and the empty case. Verified as a real guard by removing the await again: all six fail, and pass once it is restored. Also: test_airdrop_stage_refuses_on_mainnet had its docstring after an assignment, making it a no-op string expression rather than a docstring (ast.get_docstring returned None). Moved to the first statement. Both found by Copilot review; both were real. 235 shield-swap (+6), pyright clean, AGENTS.md current. * fix(shield-swap): swap_many takes expected_out; share the position decoder Two findings from a second review pass. swap_many's refusal message told callers to "quote it yourself and pass expected_out" — and swap_many had no expected_out parameter, so the advice was impossible to follow. It now accepts one, which both makes the message true and gives callers with their own price source (or an unreachable API) a way through. find_position_plaintext matched any dict whose `pool` field equalled the target, so a record of another type carrying that field would be returned as a position and then spent as one. That is the same defect fixed in _owned_from_record last commit; its sibling was left behind. Both now go through decode_position_record, with a test using a lookalike record. The agent page passed its compactness budget again. It had been squeezed to 134 chars of headroom, so the cap moves 24k → 26k deliberately rather than a third squeeze, with the reason recorded alongside the earlier raises. The new paragraph is also written tighter; the page sits at 24130 with 1870 spare. 237 shield-swap (+2), pyright clean.
iamalwaysuncomfortable
deployed
to
pypi-shield-swap
August 6, 2026 05:59 — with
GitHub Actions
Active
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.
Migrates
shield-swap-sdkfrom the retiredshield_swap_v3.aleoto the freshly deployedshield_swap.aleostack, per the migration guide (amm-v3#62 target). Hard cutover — no state carries over.Contract cutover
PROGRAM_ID = shield_swap.aleo;U256 {hi, lo}prices,MerkleProofstruct,PositionNFT.withdrawal, reshapedPoolState/Slot/Tick/Position/SwapOutput, new compliance records;claim_multi_hop_outputandset_token_decimalsgone._MAGIC_X128tick table +MIN/MAX_SQRT_RATIO_X128pinned from the contract and verified against the reference implementation (f(±400000)matches exactly);u256_to_int/int_to_u256_plaintexthelpers.SlotView.pricere-derived;collect_allrequests exactly the chain-reported owed amounts.mint/claim_swap_output/collectcarry[MerkleProof; 2]arrays — empty-tree defaults while the lists are empty,wrapper_proofs=override for later.mint(withdrawal=…)(defaults to recipient) is stored on the NFT;collectalways pays it — therecipientkwarg is removed.CLAIM_OR_SWAP_DOMAINunchanged in the new bytecode; onlyDEFAULT_PROGRAMflips (v3 vectors kept as algorithm tests, new-program vectors pinned).Automatic router dispatch
Every verb detects each token's wrapped/plain shape (
from_wrapper_token_idchain probe, cached; transport errors propagate rather than misclassifying) and dispatches per the guide's §6 matrix:shield_swap_router.swap_from_wrappedwhen the input is wrapped (funded with underlying records; deposit + burn happen in-transaction;amount_out_min > 0enforced at prepare time)SwapOutputshapes (covers the plain-start/wrapped-claim asymmetry)Record selection understands
credits.aleo(microcredits) and skips recipient-bound wrapper records. Mirrored inAsyncShieldSwap.Supporting changes
fmt_array, toposort/ref-check recursion into array elements).DEFAULT_API_URLand regen default nowhttps://amm-api-staging.dev.provable.com(SHIELD_SWAP_API_URLoverride); models regenerated (wrapper_program→amm_token_program, newunderlying_program/underlying_token_iddrive record-program resolution); redeem no longer rotates the session credential.allow_tokenwith idempotent bootstrap.Verification
shield-swap-sdkunit suite: 171 passed