Skip to content

fix(shield-swap): DEX correctness, owned-position views, 0.4.0 - #67

Merged
iamalwaysuncomfortable merged 15 commits into
feat/shield-swap-updatefrom
fix/dex-correctness-owned-positions-0.4.0
Aug 6, 2026
Merged

iamalwaysuncomfortable merged 15 commits into
feat/shield-swap-updatefrom
fix/dex-correctness-owned-positions-0.4.0

Conversation

@iamalwaysuncomfortable

Copy link
Copy Markdown
Member

Six live correctness bugs, two features, and the test harness that was hiding several of them. Base is feat/shield-swap-update.

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.

Live correctness bugs

The DEX API host was decommissioned. amm-api-staging.dev.provable.com returns 404 on every path. The API is deployed per network on separate hosts, so one module-level constant cannot be right for both. SHIELD_SWAP_API_URLS + api_url_for(network) replace it, resolved from the bound client's network_name so the indexer always matches the chain being read. An unknown network raises rather than falling back, and the standalone default points at testnet deliberately: an accidental default must not reach mainnet.

Do not use amm-api.dev.provable.com as a fallback — it still answers 200 but indexes the pre-migration shield_swap_v3.aleo.

increase_liquidity produced tick hints the contract rejects. It read slot.next_init_below/above, which bracket the pool's current tick rather than the target, so any bound further out than one initialized tick got a hint above itself — mined, reverted, fee consumed. mint already walked the on-chain list; increase_liquidity now does too. pick_insert_hint is deleted rather than left in place: unused after this change, unexported, untested, and its own docstring conceded it returned rejected hints.

get_ohlcv had the wrong timestamp type. Declared str; the API's from/to are int64 unix seconds (inclusive start, exclusive end). The live test passed ISO-8601 and got 400 query parameters do not match the expected schema — it had never run in CI, being live-marked.

swap() raced on blinded identities. It derived one via 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 state, reach the same counter, and the second reverts at finalize. Nothing surfaces locally, because at proving time the address genuinely was unused — the check and the use are not atomic. swap_many already reserved from the journal; swap() now takes the same path and records the resulting handle once the broadcast is accepted. track=False opts out.

Journaling the handle is the other half: the blinding factor is the only thing that can claim a swap, so a crash mid-flight would otherwise lose it permanently. A journal write that fails after the swap lands raises rather than being swallowed — the swap is already spent, so dropping its claim secret silently is the worse outcome.

Quote failures turned into unpayable trades. _quote_expected_out swallowed NotAuthenticatedError/NotRedeemedError into a None return, which resolve_swap_params replaced with a spot estimate. Spot ignores the pool fee, so amount_out_min came out above what the pool can pay and 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.

Profile wrote private keys to a literal ~ directory. No expanduser, so load_or_create("~/x") and SHIELD_SWAP_HOME=~/x each created a directory named ~ in the cwd — where no later run would look for the key. Both paths now expand.

Features

Owned-position viewsget_owned_positions(pool_key=?) and get_owned_position(token_id) answer "what do I hold and what is it worth" without a transaction.

A position spans two sources neither of which 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.

position_math.py mirrors the amm-v3 view helpers bit-exactly — amounts_for_liquidity, fee_growth_inside, fee_owed, u256_wrapping_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 vectors are transcribed from the contract's own tests/test_amm_helpers.leo, including the wrap-negative cases, so a divergence in either implementation fails the suite rather than silently producing wrong balances.

state is None while a mint finalizes and when a boundary tick is uninitialized; the identity stays usable in both cases. Burned positions cannot appear, since burn consumes the record.

from_profile(network=, endpoint=) — a profile-bound client could only ever be testnet, because Profile.load_or_create defaults there. With shield_swap.aleo now live on mainnet, mainnet was unreachable through the documented entry point. Both apply only at creation: an existing profile keeps what it was created with, since its derived keys are network-scoped.

Mainnet

Verified against both deployments — testnet 5 pools / 8 tokens, mainnet 4 pools / 8 tokens, with locally derived pool keys matching each indexer on both.

Mainnet publishes neither /airdrop nor /airdrop/{job_id} (confirmed by diffing both OpenAPI specs), so the airdrop stage 404'd there. It now raises NotFundedError naming the network and address, because the remedy is funding the account rather than a retry. Mainnet onboarding is authenticate → redeem → credentials, then the caller funds.

OpenAPI regenerated per network; picked up UsdcUsdQuote and PoolListResponseDoc.valuation. regen-openapi.sh no longer defaults to the dead host.

pyright: 15 → 0, in both packages

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_liquidity 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 instead of 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.

The write tier had never passed

Four defects, each masking the next:

  1. conftest gated on credentials it never passed — required ALEO_E2E_API_KEY/ALEO_E2E_CONSUMER_ID but built its provider without them, so the record scanner answered Unauthorized before any test reached the chain.

  2. Split credentialapi_key on the provider, consumer_id on aleo.network_client. The scanner is built lazily from provider config, so it had a key with no consumer and could not mint a JWT.

  3. No DEX authentication before the auth-gated get_route, so it 401'd before anything was proved.

  4. Raw base units to /route. The last failure got all the way to chain: at1xze86e… proved, broadcast, and was rejected at finalize. 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, ruling out the price bound.

    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. Passing raw 1e16 quoted a trade of 10,000,000 ETH rather than 0.01, returned a price from deep in the book, and produced a minimum 76% above what the pool would pay. _quote_expected_out was correct throughout — it returns 1058294112, matching the canonical quote exactly. The test had reimplemented the conversion; it now calls the helper. test_reads_live had the same confusion and only passed because it asserts shape rather than amounts.

Verification

Gate Result
shield-swap hermetic 220 passed
sdk hermetic 884 passed
shield-swap live reads + ABI drift 17 passed
shield-swap write tier 2 passed — proved, broadcast, confirmed, claimed on real testnet
devnode 11 passed
pyright (both packages) 0 errors

Reviewer notes

CI does not run any of this. The workflow uses -m "not slow and not live and not devnode", so the integration tiers are written, maintained, and never executed — which is exactly why all four harness defects survived, and the same gap that let the ABI drift tests sit silently erroring on a missing import. Fixing the tests without fixing that means they rot again. Worth a scheduled job even if it cannot gate PRs.

aleo-contract-abi-generator must be installed for the drift tests to run at all. Without it they error on import rather than checking anything.

Not in this PR: a shared Provable credential session. Defect 2 above is an instance of a bug class — a credential must reach every consumer of it, and nothing enforces that; two call sites got it wrong in one file. A shared session would make the split unrepresentable rather than a convention. Also absent: rebuilding a lost journal from chain history, and the merkle-proof parameter work.

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).
…es 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.
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).
…e 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).
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.
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.
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.
…ed 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.
…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.
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.
…, 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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the shield-swap SDK for the 0.4.0 release, addressing several live correctness issues (DEX host selection, swap identity races, quote handling, OHLCV timestamp typing, airdrop behavior on mainnet, profile path expansion), and adding new read-side features for valuing owned LP positions by joining private records with on-chain mappings.

Changes:

  • Fix DEX/API correctness issues (per-network API host resolution, stricter quote handling, OHLCV timestamp typing, mainnet airdrop behavior) and harden swap identity handling via journal-backed counter reservation.
  • Add owned-position read views (get_owned_positions, get_owned_position) backed by new contract-mirroring position math helpers plus test vectors.
  • Bump versions across packages to 0.4.0 and refresh generated OpenAPI models/codegen workflow.

Reviewed changes

Copilot reviewed 33 out of 33 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
shield-swap-sdk/tests/test_swap.py Adds tests for swap counter reservation + journal handle tracking behavior.
shield-swap-sdk/tests/test_swap_many.py Updates swap_many quote behavior tests; adds refusal-on-missing-quote coverage.
shield-swap-sdk/tests/test_profile.py Adds regression tests for ~ expansion in profile home paths.
shield-swap-sdk/tests/test_position_math.py Adds KAT-style vectors for position view math (mirroring contract behavior).
shield-swap-sdk/tests/test_package.py Updates expected package version to 0.4.0.
shield-swap-sdk/tests/test_owned_positions.py Adds tests covering record↔mapping join for owned-position views.
shield-swap-sdk/tests/test_lifecycle.py Adds mainnet airdrop-stage refusal test (but contains a docstring placement issue).
shield-swap-sdk/tests/test_async_parity.py Adds sync/async surface parity tests, including owned-position view presence.
shield-swap-sdk/tests/test_api_client.py Adds tests for per-network API host resolution + env override behavior.
shield-swap-sdk/tests/integration/test_swap_lifecycle.py Fixes write-tier harness (credentials wiring + authenticated routing + quote conversion).
shield-swap-sdk/tests/integration/test_reads_live.py Fixes live read tests for canonical routing amounts + OHLCV timestamp types.
shield-swap-sdk/tests/integration/conftest.py Fixes integration fixture credential propagation + optional record registration.
shield-swap-sdk/python/aleo_shield_swap/types.py Introduces OwnedPosition / OwnedPositionState dataclasses.
shield-swap-sdk/python/aleo_shield_swap/tick_hints.py Removes unused/incorrect insert-hint logic.
shield-swap-sdk/python/aleo_shield_swap/profile.py Expands ~ in default/explicit profile paths; updates documentation/comments.
shield-swap-sdk/python/aleo_shield_swap/position_math.py Adds bit-exact position view math helpers (wrapping fee growth, liquidity amounts).
shield-swap-sdk/python/aleo_shield_swap/lifecycle.py Raises NotFundedError for mainnet airdrop stage (no faucet endpoints).
shield-swap-sdk/python/aleo_shield_swap/client.py Core fixes/features: per-network API selection, journal-backed swap counter reservation + handle journaling, owned-position views, quote error propagation, LP funding refactor.
shield-swap-sdk/python/aleo_shield_swap/async_client.py Adds owned-position views and per-network API selection (but currently has an async bug).
shield-swap-sdk/python/aleo_shield_swap/api.py Implements SHIELD_SWAP_API_URLS + api_url_for; fixes OHLCV timestamp typing; tightens CSRF typing.
shield-swap-sdk/python/aleo_shield_swap/AGENTS.md Updates documented signatures/behavior (from_profile args, swap tracking semantics).
shield-swap-sdk/python/aleo_shield_swap/_core.py Adds shared PositionNFT record decoding helper (decode_position_record).
shield-swap-sdk/python/aleo_shield_swap/_api_models.py Regenerated models (adds UsdcUsdQuote, pool list valuation field).
shield-swap-sdk/python/aleo_shield_swap/init.py Exports owned-position types; bumps version to 0.4.0.
shield-swap-sdk/pyproject.toml Bumps shield-swap-sdk version to 0.4.0.
shield-swap-sdk/codegen/regen-openapi.sh Updates OpenAPI regeneration to target per-network hosts (or explicit URL).
shield-swap-sdk/codegen/amm_api.openapi.json Updates vendored OpenAPI spec with latest schema changes.
shield-swap-sdk/AGENTS.md Mirrors AGENTS documentation changes at repo/package root.
sdk/pyproject.toml Bumps aleo-sdk version to 0.4.0.
sdk/Cargo.toml Bumps Rust crate version to 0.4.0.
sdk-abi/python/aleo_abi/_aleo_abi.pyi Fixes stub signature for generate_abi to include optional imports.
sdk-abi/pyproject.toml Bumps aleo-contract-abi-generator version to 0.4.0.
sdk-abi/Cargo.toml Bumps aleo-abi crate version to 0.4.0.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread shield-swap-sdk/python/aleo_shield_swap/async_client.py
Comment thread shield-swap-sdk/tests/test_lifecycle.py
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 34 changed files in this pull request and generated no new comments.

Suppressed comments (4)

shield-swap-sdk/python/aleo_shield_swap/client.py:490

  • pool_key filtering happens after _owned_from_record has already done on-chain reads; pass the pool_key down so records from other pools are skipped before mapping/slot/tick reads.
            owned = self._owned_from_record(plaintext, slots)

shield-swap-sdk/python/aleo_shield_swap/client.py:441

  • When pool_key= is provided, get_owned_positions still calls _owned_from_record for every record and _owned_from_record eagerly reads the on-chain positions entry (and potentially slot/ticks) before the pool filter is applied. That makes pool-scoped queries cost extra network/mapping reads proportional to total owned positions across all pools.

This issue also appears on line 490 of the same file.

        token_id = str(decoded["token_id"])
        pool_key = str(decoded["pool"])
        raw = self._mapping_value("positions", token_id)

shield-swap-sdk/python/aleo_shield_swap/types.py:197

  • OwnedPosition.state can also be None when boundary ticks are uninitialized (because _owned_position_state returns None in that case), but the docstring only mentions the mint-finalizing case. This makes the public type contract misleading for callers.
    *state* is ``None`` while a fresh mint is still finalizing: the record
    exists but ``positions[token_id]`` is not written yet.  Burned positions
    cannot appear at all, because burn consumes the record.

shield-swap-sdk/python/aleo_shield_swap/_core.py:171

  • POSITION_RECORD_FIELDS is described as “Fields a PositionNFT record carries”, but it’s only a subset used for detection (it omits fields like owner/liquidity/_nonce). Tighten the comment so it doesn’t incorrectly document the record schema.
#: Fields a PositionNFT record carries.  Checked as a set, so a future record
#: type sharing one of them is not mistaken for a position.
POSITION_RECORD_FIELDS = ("token_id", "pool", "tick_lower", "tick_upper",
                          "token0_id", "token1_id", "withdrawal")

…coder

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
iamalwaysuncomfortable merged commit 28ed5ed into feat/shield-swap-update Aug 6, 2026
22 checks passed
iamalwaysuncomfortable added a commit that referenced this pull request Sep 11, 2026
* feat(codegen): fixed-length Array ABI type support

* feat(shield-swap): regenerate wire layer from deployed shield_swap.aleo

* feat(shield-swap): Q128.128 tick math + U256 helpers

* feat(shield-swap): raw-amount swap resolution, X128 limits, freezelist proof defaults

* feat(shield-swap): X128 slot price, new default program for derivations

* feat(shield-swap): new-stack write verbs — U256 prices, proofs, withdrawal address

* feat(shield-swap): automatic router dispatch for wrapped assets

* feat(shield-swap): mirror new-stack verbs and routing in async client

* feat(shield-swap): cut API layer over to the staging Shield Swap API

* docs(shield-swap): re-point docs, fixtures, and helpers at the new stack

* test(shield-swap): devnode + live cutover coverage for the new stack

* fix(codegen): satisfy strict pyright on Array support

* fix(shield-swap): pin mcp extra below 2.0 pending API port

* test(shield-swap): pin router ABIs as drift guards with input-count parity

* fix(shield-swap): adapt auth to staging session model

- /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

* fix(shield-swap): accept referral codes in redeem_code

* feat(shield-swap): pasted invites are referral codes; access codes self-register

* fix(shield-swap): cookie session outranks bearer; onboarding idempotent 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

* chore(shield-swap): rehearsal takes pasted referral codes, trades held pools

* fix(shield-swap): mint hints walk the on-chain tick list

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).

* chore: bump aleo-sdk, aleo-contract-abi-generator, shield-swap-sdk to 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.

* chore: bump versions to 0.3.1

* docs: voice.md docstrings for the undocumented public surface (#65)

* 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): DEX correctness, owned-position views, 0.4.0 (#67)

* 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.

* fix(shield-swap): authentication is the whole gate; referral codes optional

The DEX API no longer requires an invite: a fresh key has has_access
true right after /auth/verify, and POST /access/redeem is marked a
compatibility endpoint. Referral codes are the only codes that exist and
are optional attribution.

- onboarding: the 'redeem' stage becomes 'referral' — skipped without a
  code or once the account has a referrer; never raises
- onboard(referral_code=) replaces onboard(invite_code=)
- drop NotRedeemedError, redeem_access_code, and the 403 'invite' mapping
- add referral_status() and my_referral_code() to both API clients
- agent tools: setup_account takes optional referral_code; redeem_invite
  becomes redeem_referral_code
- OpenAPI spec + models regenerated from the live testnet API
- docs, skill, rehearsal, and live tests rewritten for the new model;
  fix a live test that still expected a body JWT

* feat(shield-swap): sync with deployed contracts, 2026-09 API, and veil 0.9.0 (#69)

Deployed bytecode (both networks) gained claim_swap_output_no_refund,
swap_execution_headers/hops, pool_creators; the swap router gained two
no-refund claim variants. Testnet additionally has rebalance_position and
shield_swap_rebalance_router.aleo (14 entrypoints). The DEX API gained
rebalance-state, route topology, referral reporting, richer pool
analytics, and machine-readable error codes.

Contract
- claims dispatch to the no-refund entrypoints when amount_remaining == 0
  (sync + async); the refund proof slot is dropped with it
- get_swap_execution: header + per-hop fills with lp_fee derived
  (fee_paid - protocol_fee); get_pool_creator
- rebalance: pure planner (rebalance.py, exact-target or budget sizing
  with the 8-step clamp), plan_rebalance / rebalance_position on the
  client (route table for all 14 entrypoints, slot rule shared by all,
  dying-tick hint correction, 20-block deadline), async plan_rebalance
- position_math: liquidity_for_amount / liquidity_for_amounts, checked
  against veil's q128 oracle vectors (now a fixture)
- pins: ABIs + bindings regenerated from testnet; rebalance router pinned
  as a drift guard with a derived input-count parity test; fixtures are
  the deployed testnet bytecode; devnode harness deploys the router and
  runs zero-budget and funded rebalances

API
- 18 new endpoint wrappers on both clients (pool/stats/trades/ticks,
  session swaps/positions/unclaimed, fee tiers, tick spacings, protocol
  state, route topology, rebalance state, token list/revoke, referral
  reporting); get_route(pool_key=)
- DexApiError.code/.ref from the error envelope
- _build recurses into nested models; a field the API dropped reads None

Tooling
- agent tools: get_swap_execution, plan_rebalance, rebalance_position
- AGENTS.md regenerated (budget 26k -> 32k), README

Devnode deployments overpay the base fee by 10%: the bundled snarkVM's
deployment_cost undershoots the devnode's consensus by ~2.5%.

* feat(shield-swap): follow the 2026-09 DEX API route retirement; wrap the session, compliance, depth, and referral surface

The API retired sixteen routes on 2026-09-08 (veil #144, amm-v3-monorepo#574);
the committed OpenAPI spec was a week stale and seven ApiClient wrappers 404'd
live on both networks. Regenerate the spec and models and mirror veil:

- Drop access_status, my_referral_codes, get_tick_spacings, get_swaps,
  get_swap, get_position, get_public_balances from both clients. The access
  flag and the session liveness probe now ride on referral_status(); swap and
  position detail are chain reads; fee tiers carry their tick spacing.
- Public balances are chain reads: ShieldSwap.get_public_balances(programs,
  address=) reads each token program's `balances` mapping (plain-address key,
  uN literal, absent = 0). get_balances() takes public from the AMM token
  program and private records from the underlying program, as veil does.
- Wrap the routes that were missing: cookie-session management (get_session,
  refresh_session adopting the rotated CSRF, list_sessions, revoke_session,
  logout, logout_all, get_ws_ticket), the compliance reads, batch pool stats,
  liquidity distribution, and referral issuance (settings, codes, generate)
  with a new _put helper. /auth/logout needs x-shield-session-id and
  x-shield-wallet-address binding headers whenever the refresh cookie rides.
- position_math: mul_div, liquidity_for_amount0/1 are public, documented.

Also lands the live write-tier lessons from 2026-09-03: blinded-identity
reservation gallops past counters already used on chain and verifies each one
(find_unused_counter, Journal.skip_counters_through); quotes used for
amount_out_min are pinned to the traded pool; the live conftest provisions
scanner credentials on the provider; new live suites for the API surface,
balances, traders, and the full liquidity journey. Swap lifecycle tests size
from the largest single record, not the summed balance.

Verified: 336 unit tests; live read tier (api 13/16 with 3 environmental
skips, reads, balances, traders, drift) and the funded testnet write tier
(agent onboarding, swap and liquidity lifecycles, 13/13) all pass.

* fix(shield-swap): review fixes — async gallop, per-program balance isolation, typed dict/enum fields, journal catch-up; live suites for async, agent tools, referral posts

Medium-effort review of the branch surfaced eight defects; all fixed with
regression tests.

- The async client's blinded-identity probe was still a fixed 64-counter
  scan that raised for any account with more history. Both clients now run
  one galloping search (a generator drives find_unused_counter and
  find_unused_counter_async), read `used_blinded_addresses` through the
  node's mapping endpoint instead of downloading the program per swap, and
  agree on what "used" means via _core.mapping_flag_set. Async swap() takes
  identity=.
- get_public_balances skips (and warns about) a program the network lacks
  or a non-ARC-20 value instead of failing every token; reads go through
  aleo.network.get_program_mapping_value. get_balances gained
  include_private=False and runs the record scan before the public reads,
  so status()'s public-only fallback repeats nothing and builds no shape of
  its own.
- _coerce builds dict[str, Model] values and Enum fields, so
  live_compatibility.observed_programs[...] is a LiveProgramObservation and
  .status a LiveCompatibilityStatus (unknown enum values stay strings).
- _reserve_identities: a used counter triggers one gallop from counter+1 and
  a single counters_skipped event; a journal behind the chain no longer burns
  counters one reserve-probe-append at a time or logs swap_failed for
  non-failures.
- swap_many's scanner retry no longer sleeps after its last attempt.
- Onboarding reclaims this profile's oldest same-name stale API token before
  falling back to the session at the five-token cap; the server literal the
  match relies on is documented.
- logout() forgets the local cookie session even when it already expired.
- The agent get_pools tool read `fee` off the API pool document, which only
  has fee_percent (basis points); it now emits fee_bps.

New live suites: the async clients (API, chain reads cross-checked against
the sync client, record reads), the agent tool dispatcher, the referral
reporting posts (which take the code this account redeemed, not its own),
set_token, and a blinded-identity-vs-chain check. Referral swap-claim tests
skip when the account never redeemed a code.

Verified: 347 unit tests; live read tier 54/57 (3 environmental skips);
opt-in live API 8/8; funded testnet write tier 13/13 on the fixed code.

* build: snarkvm 4.9.1 from crates.io; leo master c82f149e for sdk-abi

sdk: snarkvm moves from the git tag v4.8.1 to the crates.io release 4.9.1
(191 upstream commits: ConsensusVersion V18 with credits record translation
and a new deployment-cost formula, V19 with per-transaction deployment
limits). No FFI API changes were needed.

The Process bindings no longer hardcode ConsensusVersion::V17. Both live
networks are past their V18 heights and deployment_cost changed at V18, so
the literal would have underpriced deployments. execution_cost,
deployment_cost, verify_execution and verify_fee now resolve the version
from the network's activation table via CONSENSUS_VERSION(height), taking
an optional block_height= (default: the newest scheduled version); the
type stubs and the proving test cover it.

sdk-abi: the four leo crates move to leo master c82f149e (2026-09-09; no
release tag pins snarkvm 4.9.1 yet, v4.0.x still pin 4.6.0), and snarkvm to
crates.io 4.9.1 with leo's feature set so Process<N>/Program<N> stay the
types leo-disassembler expects. That leo fixes a leo-abi bug that emitted
record field modes swapped: the test expectation is corrected, and
shield-swap's regenerated ABI now reports every record owner as Private,
matching the deployed bytecode. Nothing in Python reads record field modes.

Verified on the new builds: sdk 884 unit + 4 proving (1 skipped: the venv
interpreter's urllib has no CA bundle) + 2 devnode; sdk-abi 8; shield-swap
347 unit, live read tier 54/57 (3 environmental skips), opt-in live API
8/8, devnode lifecycle 12/12, funded testnet write tier 13/13.

* chore: bump aleo-sdk, aleo-contract-abi-generator, shield-swap-sdk to 0.5.0

Breaking for shield-swap-sdk: the 2026-09 DEX API route retirement removed
seven ApiClient wrappers and moved balances to chain reads. Lockstep bump
for the other two, matching prior releases; both now build on snarkvm 4.9.1.

* fix: fee estimates use the chain head's consensus version; safer token reclaim; batch stats chunking; bulk counter reservation

Second review of the branch; six of its eight findings addressed.

- sdk facade: a transaction is judged by the rules at its inclusion height,
  so _authorize_fee (sync and async) passes network.get_latest_height() to
  execution_cost. The FFI's "newest scheduled version" is documented as the
  no-node fallback it is, not a default to rely on.
- lifecycle: the durable-token name derives from the address, so another
  machine onboarded with the same key holds the same name and a revoke hits
  every holder at once. Reclamation now touches only a same-name token idle
  for TOKEN_IDLE_SECONDS (24h, by last_used_at); a token in recent use is
  left alone.
- lifecycle: at the token cap the credentials stage records the hit and
  treats it as settled for CAP_RETRY_SECONDS, so repeated onboard() calls
  stay no-ops instead of re-minting, re-listing and re-registering the
  scanner every time. Profile.forget_credentials clears the marker once a
  token is minted.
- api: get_pool_stats_batch dedups keys and chunks requests to the route's
  100-key cap (POOL_STATS_BATCH_MAX) on both clients.
- client: _reserve_identities reserves the batch in one journal event,
  derives each identity once and skips re-probing a gallop-proved counter.
- docs: the AGENTS.md counters paragraph no longer tells agents to feed raw
  journal.reserve_counters() values to a swap; two stale comments and a
  dead get_position reference fixed.

test_agent_lifecycle_live accepts a mint only once its position is readable
on chain — a mint built on a just-spent record confirms as rejected without
raising — and retires a dropped attempt from the journal.

Verified: sdk 884 unit + proving; shield-swap 351 unit; live read tier
54/57 (3 environmental skips); opt-in live API 8/8; funded testnet write
tier 13/13.

* fix(stubs): declare Deployment in the network type stubs

The deploy/deployment_cost entries added with the snarkvm 4.9.1 bump
referenced a Deployment type the stubs never declared, so CI's strict
pyright failed with two undefined-name errors. Mirror the Rust pyclass.

* fix: address Copilot review — position selection by token id, consumer URL origin, array length on decode, README host

- Write verbs that name a position by token id now select THAT PositionNFT
  record: find_position_plaintext takes position_token_id and
  rebalance_position passes it through, so an account with several positions
  in one pool cannot pair a plan with another position's NFT (a guaranteed
  revert after the proof is paid for). The agent tool needs no change.
- provision_provable_credentials normalizes the endpoint with jwt_origin:
  a node base such as https://api.provable.com/v2/testnet no longer produces
  the nonexistent /v2/testnet/consumers.
- codegen: decoding enforces the ABI's fixed array length via a new
  runtime.dec_array, mirroring fmt_array on the encode side; a [field; 16]
  with 15 siblings is rejected in both directions. shield-swap bindings
  regenerated.
- README: the API host is per network (api.testnet.swap.shield.fi /
  api.swap.shield.fi), not the retired staging host.

* fix(facade): consult the chain head for fee estimates only on the hosted API

f85c298 made _authorize_fee pass network.get_latest_height() to
execution_cost so estimates use the consensus version in force at the
inclusion height. That is right on the hosted Provable API, whose activation
schedule is the table compiled into the bindings — and wrong everywhere
else: a devnode (or any custom node) runs its own schedule, so mapping its
height through the SDK's testnet table priced a devnode at height ~40 under
V1 and drained its account ("Fee verification failed: insufficient
balance", 3 of 12 devnode lifecycle tests).

Off the hosted API the facade now passes None, and the bindings fall back to
the newest scheduled version — what such a node runs once past its test
heights, and the behaviour before f85c298. Same gate in the async facade.
Devnode lifecycle back to 12/12; unit test pins both branches.

(cherry picked from commit bef85641ad0e2f9c010bc8622ec43c57ce1d88de)

* test(facade): hosted-host cases use api.provable.com on this branch

The edge host is recognised as a hosted-API host only from the
edge-default-endpoint change; here the hosted case is the legacy origin.

* feat: edge.provable.com/api is the default hosted API; no credentials required (#70)

The hosted Provable API now has an open edge at https://edge.provable.com/api:
reads, delegated proving and the hosted record scanner all work with no API
key, consumer id or JWT. It becomes DEFAULT_HOST. The credentialed legacy
origin https://api.provable.com (LEGACY_HOST) keeps working unchanged with
api_key + consumer_id.

The edge keeps its services under an /api path prefix, which jwt_origin
(scheme + host only) would drop. A new _client_common.service_root(url) keeps
the prefix and strips a legacy /v2[/{network}] suffix; both network clients
and facade.provider.scanner_base derive {root}/v2/{network}, {root}/prove and
{root}/scanner from it. requires_credentials(url) is True only for the legacy
host; is_provable_host recognises both.

shield-swap: Profile.DEFAULT_ENDPOINT, the MCP server's ALEO_ENDPOINT default
and the live-test ENDPOINT default move to the edge. The onboarding
credentials stage provisions Provable consumers only when the profile's
endpoint requires them; on the edge it just mints the durable DEX token. The
live conftest hands out no DPS credentials on the edge.

Tests default to the edge and run credential-free there; credential-gated SDK
e2e tests skip only when the endpoint actually needs credentials and none are
set. Unit tests pin service_root, requires_credentials and the edge URL
derivation; the legacy-host derivation tests are unchanged and still pass.

Verified on the edge with ALEO_E2E_API_KEY/CONSUMER_ID unset: sdk 892 unit +
proving; sdk live e2e 27/27 on testnet and mainnet (delegated transfer, hosted
scanner, private roundtrip); shield-swap 355 unit, live read tier 54/57
(3 environmental skips), funded testnet write tier 13/13.

* test(e2e): live read sweep for both network clients

Every read method of AleoNetworkClient and AsyncAleoNetworkClient against
the hosted API default (the edge, ALEO_E2E_ENDPOINT overrides), no
credentials, with the async answers asserted equal to the sync ones.

Two hosted-API gaps surface as skips carrying the status, identical on the
edge and the legacy host: /memoryPool/transactions is not served (404) and
/statePaths answers 502. The tests pass automatically if either appears.

* test(facade): the hosted-host cases of the fee-height test include the edge

The edge is a hosted-API host from this branch on, so the chain head is
consulted there too; devnode and custom nodes still get None.

* fix: address Copilot review — credentials never leave for the edge, async claim registers both legs, deadline parity, stub mirror, plan amounts as strings

- Open edge: ambient legacy api_key/consumer_id no longer trigger a JWT mint
  at /api/jwts (which does not exist there) — both network clients gate
  _ensure_jwt on requires_credentials(root), and RecordsModule forwards
  credentials to the scanner only for the credentialed host.
- Async claim registers each leg's token program (wrapper for wrapped legs,
  the ARC-20 itself for plain ones) before authorization, as the sync client
  does; the core dispatches into both dynamically.
- AsyncShieldSwap.swap defaults deadline_offset_blocks to 10,000 like the
  sync client (100 was ~5 min and could expire during delegated proving);
  the parity test now compares shared parameter defaults, not just names.
- sdk/python/aleo/__init__.pyi mirrors the extension stubs: block_height on
  the four Process methods, deploy, deployment_cost and the Deployment class
  (the dual-stub convention).
- shield-swap-sdk requires aleo-sdk>=0.5.0 — _generated.py imports the
  codegen runtime helpers introduced there.
- plan_rebalance / rebalance_position report u128 amounts as strings, as the
  tool descriptions promise; ticks and ids stay numeric.
- README: the async client mirrors the read, swap and claim surface; the
  liquidity verbs and journal-bound helpers are sync-only for now.
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.

2 participants