From bcd35078dc3f5fe79c1aa787549bd792e3f94f90 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 10:12:04 -0500 Subject: [PATCH 01/15] fix(shield-swap): resolve the DEX API host per network MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../python/aleo_shield_swap/api.py | 46 +++++++++++++++++-- .../python/aleo_shield_swap/async_client.py | 7 +-- .../python/aleo_shield_swap/client.py | 8 ++-- shield-swap-sdk/tests/test_api_client.py | 43 +++++++++++++++++ 4 files changed, 93 insertions(+), 11 deletions(-) diff --git a/shield-swap-sdk/python/aleo_shield_swap/api.py b/shield-swap-sdk/python/aleo_shield_swap/api.py index d7cf907..79e64da 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/api.py +++ b/shield-swap-sdk/python/aleo_shield_swap/api.py @@ -45,11 +45,47 @@ def _check(resp: Any) -> None: raise AirdropRateLimitedError(text) raise DexApiError(code, text) -# Staging serves the migrated shield_swap.aleo stack (the old -# amm-api.dev.provable.com host still serves the pre-migration deployment). -# Override with SHIELD_SWAP_API_URL when the host moves again. -DEFAULT_API_URL = os.environ.get("SHIELD_SWAP_API_URL", - "https://amm-api-staging.dev.provable.com") +#: DEX API host per network. The API is deployed per network and the two are +#: not interchangeable: pool keys and blinded identities are network-scoped, so +#: a testnet key means nothing to the mainnet indexer. +SHIELD_SWAP_API_URLS: dict[str, str] = { + "mainnet": "https://api.swap.shield.fi", + "testnet": "https://api.testnet.swap.shield.fi", +} + + +def api_url_for(network: str) -> str: + """The DEX API base for *network*. + + ``ShieldSwap`` calls this with its bound client's network, so the API + always matches the chain being read. ``SHIELD_SWAP_API_URL`` overrides + every network — set it to point at a local or staging deployment. + + Args: + network: ``"mainnet"`` or ``"testnet"``. + + Returns: + The base URL, without a trailing slash. + + Raises: + ValueError: If no host is known for *network* and no override is set — + better than silently querying the wrong chain's indexer. + """ + override = os.environ.get("SHIELD_SWAP_API_URL") + if override: + return override.rstrip("/") + try: + return SHIELD_SWAP_API_URLS[network] + except KeyError: + raise ValueError( + f"No DEX API host known for network {network!r} — expected one of " + f"{sorted(SHIELD_SWAP_API_URLS)}, or set SHIELD_SWAP_API_URL." + ) from None + + +#: Fallback for a standalone :class:`ApiClient` built without a network. Points +#: at testnet deliberately: an accidental default must not reach mainnet. +DEFAULT_API_URL = api_url_for("testnet") _TIMEOUT = 30.0 T = TypeVar("T") diff --git a/shield-swap-sdk/python/aleo_shield_swap/async_client.py b/shield-swap-sdk/python/aleo_shield_swap/async_client.py index b1a4208..4ede022 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/async_client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/async_client.py @@ -23,7 +23,7 @@ resolve_swap_params, ) from ._routing import claim_route, swap_route -from .api import AsyncApiClient, DEFAULT_API_URL +from .api import AsyncApiClient, api_url_for from .tick_math import int_to_u256_plaintext from .derivations import ( BlindedIdentity, @@ -100,10 +100,11 @@ class AsyncShieldSwap: """Typed async client for the shield_swap AMM over ``AsyncAleo``.""" def __init__(self, aleo: Any, *, program: str = g.PROGRAM_ID, - api_url: str = DEFAULT_API_URL) -> None: + api_url: Optional[str] = None) -> None: self._aleo = aleo self.program = program - self.api = AsyncApiClient(api_url) + # Resolve the API from the bound client's network (see api_url_for). + self.api = AsyncApiClient(api_url or api_url_for(aleo.network_name)) # allow_token relationships are immutable — cache probes for the # client's lifetime. self._wrapped_cache: dict[str, bool] = {} diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 1886aa6..b11b37e 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -27,7 +27,7 @@ resolve_swap_params, select_token_record, ) -from .api import ApiClient, DEFAULT_API_URL +from .api import ApiClient, api_url_for from .derivations import ( BlindedIdentity, blinded_identity_at, @@ -88,10 +88,12 @@ class ShieldSwap: """ def __init__(self, aleo: Any, *, program: str = g.PROGRAM_ID, - api_url: str = DEFAULT_API_URL) -> None: + api_url: Optional[str] = None) -> None: self._aleo = aleo self.program = program - self.api = ApiClient(api_url) + # Resolve the API from the bound client's network so the off-chain + # indexer always matches the chain being read. + self.api = ApiClient(api_url or api_url_for(aleo.network_name)) self.profile: Any = None # set by from_profile() self.journal: Any = None # set by from_profile() # allow_token relationships are immutable — cache probes for the diff --git a/shield-swap-sdk/tests/test_api_client.py b/shield-swap-sdk/tests/test_api_client.py index 94f4eae..ccc4a93 100644 --- a/shield-swap-sdk/tests/test_api_client.py +++ b/shield-swap-sdk/tests/test_api_client.py @@ -249,3 +249,46 @@ def test_expired_cookie_session_falls_back_to_bearer(): assert api._csrf is None # session dropped assert s.calls[0][3]["x-csrf-token"] == "csrf-1" assert s.calls[1][3]["authorization"] == "Bearer ss_durable" + + +# ── Per-network API host ───────────────────────────────────────────────────── + +def test_api_url_for_each_network(): + from aleo_shield_swap.api import SHIELD_SWAP_API_URLS, api_url_for + assert api_url_for("mainnet") == SHIELD_SWAP_API_URLS["mainnet"] + assert api_url_for("testnet") == SHIELD_SWAP_API_URLS["testnet"] + # the two must never collide — a testnet pool key means nothing on mainnet + assert api_url_for("mainnet") != api_url_for("testnet") + + +def test_api_url_for_unknown_network_raises(): + from aleo_shield_swap.api import api_url_for + with pytest.raises(ValueError, match="No DEX API host known"): + api_url_for("devnet") + + +def test_api_url_env_override_wins(monkeypatch): + from aleo_shield_swap.api import api_url_for + monkeypatch.setenv("SHIELD_SWAP_API_URL", "http://localhost:8080/") + assert api_url_for("mainnet") == "http://localhost:8080" # slash stripped + assert api_url_for("devnet") == "http://localhost:8080" # override skips lookup + + +def test_default_api_url_is_not_mainnet(): + # an accidental default must not reach mainnet + from aleo_shield_swap.api import DEFAULT_API_URL, SHIELD_SWAP_API_URLS + assert DEFAULT_API_URL == SHIELD_SWAP_API_URLS["testnet"] + + +def test_client_picks_the_api_for_its_network(monkeypatch): + from aleo_shield_swap.api import SHIELD_SWAP_API_URLS + from aleo_shield_swap.client import ShieldSwap + monkeypatch.delenv("SHIELD_SWAP_API_URL", raising=False) + + class _Net: + def __init__(self, name): self.network_name = name + + for net in ("mainnet", "testnet"): + assert ShieldSwap(_Net(net)).api.base_url == SHIELD_SWAP_API_URLS[net] + # an explicit api_url still wins + assert ShieldSwap(_Net("mainnet"), api_url="http://x").api.base_url == "http://x" From 89e5a63b473d62b94ed9337d63e9bef37cd34650 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 10:26:17 -0500 Subject: [PATCH 02/15] fix(shield-swap): walk the tick list on increase_liquidity; OHLCV takes unix seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../python/aleo_shield_swap/api.py | 9 +++--- .../python/aleo_shield_swap/client.py | 9 ++++-- .../python/aleo_shield_swap/tick_hints.py | 29 ------------------- .../tests/integration/test_reads_live.py | 7 ++++- 4 files changed, 17 insertions(+), 37 deletions(-) delete mode 100644 shield-swap-sdk/python/aleo_shield_swap/tick_hints.py diff --git a/shield-swap-sdk/python/aleo_shield_swap/api.py b/shield-swap-sdk/python/aleo_shield_swap/api.py index 79e64da..92a93a5 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/api.py +++ b/shield-swap-sdk/python/aleo_shield_swap/api.py @@ -345,13 +345,14 @@ def get_swap(self, swap_id: str) -> models.SwapDoc: return _build(models.SwapDoc, self._get(f"/swaps/{swap_id}")["data"]) def get_ohlcv(self, pool_key: str, *, granularity: str, - from_ts: str, to_ts: str) -> list[models.OhlcvDoc]: + from_ts: int, to_ts: int) -> list[models.OhlcvDoc]: """Candles for one pool over a time window. *granularity* is one of ``"1m"``, ``"5m"``, ``"15m"``, ``"30m"``, ``"1h"``, ``"6h"``, ``"12h"``, ``"1d"``. *from_ts* and *to_ts* are unix - seconds — *from_ts* inclusive, *to_ts* exclusive. Anything else raises - :class:`DexApiError`. + seconds (the API's ``int64``) — *from_ts* inclusive, *to_ts* exclusive. + A timestamp string rather than an integer is rejected with + :class:`DexApiError` 400. """ data = self._get(f"/pools/{pool_key}/ohlcv", {"granularity": granularity, "from": from_ts, "to": to_ts})["data"] @@ -528,7 +529,7 @@ async def get_swap(self, swap_id: str) -> models.SwapDoc: return _build(models.SwapDoc, (await self._get(f"/swaps/{swap_id}"))["data"]) async def get_ohlcv(self, pool_key: str, *, granularity: str, - from_ts: str, to_ts: str) -> list[models.OhlcvDoc]: + from_ts: int, to_ts: int) -> list[models.OhlcvDoc]: """Candles for one pool — see :meth:`ApiClient.get_ohlcv`.""" data = (await self._get(f"/pools/{pool_key}/ohlcv", {"granularity": granularity, "from": from_ts, diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index b11b37e..5435028 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -66,7 +66,6 @@ mint_route, swap_route, ) -from .tick_hints import pick_insert_hint from .tick_math import ( MAX_TICK, MIN_TICK, @@ -1107,10 +1106,14 @@ def increase_liquidity( position = position_record or self._select_position_record(pool_key, acct) decoded = parse_plaintext(position) + # Walk the on-chain list, as mint does: slot-derived hints bracket the + # pool's CURRENT tick, not the target, so any bound further out than one + # initialized tick gets a hint above itself — which finalize rejects + # after the fee is spent. lo_hint = (tick_lower_hint if tick_lower_hint is not None - else pick_insert_hint(slot, int(decoded["tick_lower"]))) + else self.find_tick_predecessor(pool_key, int(decoded["tick_lower"]))) hi_hint = (tick_upper_hint if tick_upper_hint is not None - else pick_insert_hint(slot, int(decoded["tick_upper"]))) + else self.find_tick_predecessor(pool_key, int(decoded["tick_upper"]))) program0 = token0_program or ( None if token0_record else self._token_program(pool.token0)) diff --git a/shield-swap-sdk/python/aleo_shield_swap/tick_hints.py b/shield-swap-sdk/python/aleo_shield_swap/tick_hints.py deleted file mode 100644 index 8baa43f..0000000 --- a/shield-swap-sdk/python/aleo_shield_swap/tick_hints.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Insert-hint selection for position ticks (port of utils/tick-hints.ts). - -The contract keeps initialized ticks in a sorted linked list and asserts -``hint.tick < target && hint.next > target`` — the hint must be the target's -predecessor. This derives the hint from the slot's active-range neighbors, -which covers pools with few initialized ticks around the current price. - -Known limitation (inherited from the reference client): when multiple -initialized ticks lie between the slot's neighbors and the target, this does -not walk the list to the true predecessor; a wrong hint reverts on the -contract's assert. An exact walk is possible with ``derive_tick_key`` — -follow-up. -""" -from __future__ import annotations - -from typing import Any - -from .tick_math import MIN_TICK - - -def pick_insert_hint(slot: Any, target_tick: int) -> int: - """Presumed predecessor of *target_tick*, from the slot's neighbors.""" - if slot is None: - return MIN_TICK - if target_tick > slot.tick: - if slot.next_init_above < target_tick: - return int(slot.next_init_above) - return int(slot.next_init_below) - return int(slot.next_init_below) diff --git a/shield-swap-sdk/tests/integration/test_reads_live.py b/shield-swap-sdk/tests/integration/test_reads_live.py index 88c1eb9..837f232 100644 --- a/shield-swap-sdk/tests/integration/test_reads_live.py +++ b/shield-swap-sdk/tests/integration/test_reads_live.py @@ -5,6 +5,8 @@ """ from __future__ import annotations +import time + import pytest from aleo_shield_swap.errors import ( @@ -71,9 +73,12 @@ def test_api_get_route_quotes_both_directions(live_dex_module, pool): def test_api_get_ohlcv(live_dex_module, pool): + # unix seconds, not ISO-8601: the API's from/to are int64 and reject a + # timestamp string with 400. + now = int(time.time()) candles = skip_if_access_gated(lambda: live_dex_module.api.get_ohlcv( pool.key, granularity="1d", - from_ts="2026-01-01T00:00:00", to_ts="2026-12-31T00:00:00")) + from_ts=now - 30 * 86_400, to_ts=now)) for candle in candles: # may be empty on a quiet pool assert float(candle.h) >= float(candle.l) From 18d9b0c910fbdecba80b1839230a358ffbc664f9 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 10:27:25 -0500 Subject: [PATCH 03/15] chore(shield-swap): regen OpenAPI per-network; pick up UsdcUsdQuote + pool valuation --- shield-swap-sdk/codegen/amm_api.openapi.json | 72 ++++++++++++++++++- shield-swap-sdk/codegen/regen-openapi.sh | 13 +++- .../python/aleo_shield_swap/_api_models.py | 12 ++++ 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/shield-swap-sdk/codegen/amm_api.openapi.json b/shield-swap-sdk/codegen/amm_api.openapi.json index f6a6831..049359b 100644 --- a/shield-swap-sdk/codegen/amm_api.openapi.json +++ b/shield-swap-sdk/codegen/amm_api.openapi.json @@ -461,7 +461,7 @@ "airdrop" ], "summary": "POST /airdrop", - "description": "Delivers ~$10 worth each of ALEO, USDCx, and ETH to the given address as\nprivate records via `transfer_public_to_private`, spent from the\ntreasury's public balance in each target's underlying program. One claim\nper recipient address per 15 minutes (429 after that).\n\nProving each token takes ~15-30s, so this returns immediately with\n`status: \"running\"` + a `job_id`. The transfers run in a detached worker\n(capped concurrency, per-token timeout); poll `GET /airdrop/{job_id}` until\n`status == \"complete\"` to read per-token results.\n\nRequired env: `DEPLOYER_PRIVATE_KEY` (the treasury key whose public balances\nfund every airdrop; `AIRDROP_PRIVATE_KEY` honored as a fallback),\n`RPC_URLS` (first entry used).", + "description": "Delivers configured amounts of ALEO, USDCx, and ETH to the given address as\nprivate records via `transfer_public_to_private`, spent from the\ntreasury's public balance in each target's underlying program. One claim\nper recipient address per 15 minutes (429 after that).\n\nProving each token takes ~15-30s, so this returns immediately with\n`status: \"running\"` + a `job_id`. The transfers run in a detached worker\n(capped concurrency, per-token timeout); poll `GET /airdrop/{job_id}` until\n`status == \"complete\"` to read per-token results.\n\nRequired env: `DEPLOYER_PRIVATE_KEY` (the treasury key whose public balances\nfund every airdrop; `AIRDROP_PRIVATE_KEY` honored as a fallback),\n`RPC_URLS` (first entry used).", "operationId": "airdrop", "requestBody": { "content": { @@ -1848,6 +1848,15 @@ "type": "integer", "format": "int64" } + }, + { + "name": "include_valuation", + "in": "query", + "description": "Include the server USDC/USD valuation", + "required": false, + "schema": { + "type": "boolean" + } } ], "responses": { @@ -3170,6 +3179,15 @@ "schema": { "type": "string" } + }, + { + "name": "pool_key", + "in": "query", + "description": "Optional pool key. The route uses only this pool, as a single hop, with no fallback.", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -5266,6 +5284,16 @@ }, "pagination": { "$ref": "#/components/schemas/PaginationMeta" + }, + "valuation": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UsdcUsdQuote" + } + ] } } }, @@ -7237,6 +7265,46 @@ } } }, + "UsdcUsdQuote": { + "type": "object", + "required": [ + "price", + "decimals", + "publishTime", + "validUntil", + "serverTime", + "source", + "corroborated" + ], + "properties": { + "corroborated": { + "type": "boolean" + }, + "decimals": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "price": { + "type": "string" + }, + "publishTime": { + "type": "integer", + "format": "int64" + }, + "serverTime": { + "type": "integer", + "format": "int64" + }, + "source": { + "type": "string" + }, + "validUntil": { + "type": "integer", + "format": "int64" + } + } + }, "VerifyRequestDoc": { "type": "object", "required": [ @@ -7325,7 +7393,7 @@ }, { "name": "airdrop", - "description": "Faucet \u2014 sends ~$10 each of ALEO, USDCx, and ETH to a user address as private records, once per address per 15 min" + "description": "Faucet \u2014 sends ALEO, USDCx, and ETH to a user address as private records, once per address per 15 min" }, { "name": "unclaimed", diff --git a/shield-swap-sdk/codegen/regen-openapi.sh b/shield-swap-sdk/codegen/regen-openapi.sh index 69bed89..659f9e5 100755 --- a/shield-swap-sdk/codegen/regen-openapi.sh +++ b/shield-swap-sdk/codegen/regen-openapi.sh @@ -2,9 +2,16 @@ # Refetch the DEX API's OpenAPI spec and regenerate the response models. set -euo pipefail cd "$(dirname "$0")" -# Staging serves the migrated shield_swap.aleo stack; the old dev host still -# serves the pre-migration deployment — never mix the two. -BASE="${1:-https://amm-api-staging.dev.provable.com}" +# The API is deployed per network on separate hosts; pass the network (or a +# full base URL) as $1. Never point this at amm-api.dev.provable.com — that +# host indexes the pre-migration shield_swap_v3.aleo. +TARGET="${1:-testnet}" +case "$TARGET" in + mainnet) BASE="https://api.swap.shield.fi" ;; + testnet) BASE="https://api.testnet.swap.shield.fi" ;; + *) BASE="$TARGET" ;; +esac +echo "fetching spec from $BASE" PYTHON="${PYTHON:-python3}" curl -sf "${BASE}/openapi.json" | "$PYTHON" -m json.tool > amm_api.openapi.json "$PYTHON" -m datamodel_code_generator \ diff --git a/shield-swap-sdk/python/aleo_shield_swap/_api_models.py b/shield-swap-sdk/python/aleo_shield_swap/_api_models.py index 1bcbb47..d25765d 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_api_models.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_api_models.py @@ -765,6 +765,17 @@ class TokenResponseDoc: data: TokenDoc +@dataclass +class UsdcUsdQuote: + corroborated: bool + decimals: int + price: str + publishTime: int + serverTime: int + source: str + validUntil: int + + @dataclass class VerifyRequestDoc: address: str @@ -1039,6 +1050,7 @@ class LiveCompatibility: class PoolListResponseDoc: data: list[PoolResponseDoc] pagination: PaginationMeta + valuation: UsdcUsdQuote | None = None @dataclass From 5808b9ddfef9a9ff5529ec5006b188c1af8d1e95 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 10:32:29 -0500 Subject: [PATCH 04/15] feat(shield-swap): owned-position views with the contract's view math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../python/aleo_shield_swap/__init__.py | 3 + .../python/aleo_shield_swap/client.py | 133 +++++++++++++ .../python/aleo_shield_swap/position_math.py | 175 ++++++++++++++++++ .../python/aleo_shield_swap/types.py | 45 +++++ shield-swap-sdk/tests/test_owned_positions.py | 146 +++++++++++++++ shield-swap-sdk/tests/test_position_math.py | 156 ++++++++++++++++ 6 files changed, 658 insertions(+) create mode 100644 shield-swap-sdk/python/aleo_shield_swap/position_math.py create mode 100644 shield-swap-sdk/tests/test_owned_positions.py create mode 100644 shield-swap-sdk/tests/test_position_math.py diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index 0852e2d..5e520d3 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/__init__.py +++ b/shield-swap-sdk/python/aleo_shield_swap/__init__.py @@ -18,6 +18,8 @@ from .async_client import AsyncShieldSwap as AsyncShieldSwap from .api import ApiClient as ApiClient, AsyncApiClient as AsyncApiClient from .types import ( + OwnedPosition as OwnedPosition, + OwnedPositionState as OwnedPositionState, ClaimResult as ClaimResult, CollectReport as CollectReport, MintResult as MintResult, @@ -83,6 +85,7 @@ def agent_guide() -> str: "NotAuthenticatedError", "NotRedeemedError", "NotFundedError", "AirdropPendingError", "AirdropRateLimitedError", "CredentialsMissingError", + "OwnedPosition", "OwnedPositionState", "Profile", "Journal", "REGISTRATION_STAGES", "OnboardReport", "StageOutcome", "SessionStatus", "PositionView", "SwapBatchReport", "CollectReport", "blinded_identity_at", diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 5435028..a6b74d8 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -46,11 +46,19 @@ from .journal import Journal from .lifecycle import run_onboard from .profile import Profile +from .position_math import ( + amounts_for_liquidity, + fee_growth_inside, + fee_owed, + u256_of, +) from .types import ( ClaimResult, CollectReport, MintResult, OnboardReport, + OwnedPosition, + OwnedPositionState, PositionView, SessionStatus, SlotView, @@ -344,6 +352,131 @@ def derive_tick_key(self, pool_key: str, tick: int) -> str: """ return _derive_tick_key(pool_key, tick, network=self._aleo.network_name) + # ── Owned positions ────────────────────────────────────────────────────── + + def _tick_info(self, pool_key: str, tick: int) -> "Optional[g.Tick]": + """The on-chain ``Tick`` entry for *tick*, or None if uninitialized.""" + raw = self._mapping_value("ticks", self.derive_tick_key(pool_key, tick)) + return g.Tick.from_plaintext(raw) if raw is not None else None + + def _owned_position_state(self, pool_key: str, position: "g.Position", + ) -> Optional[OwnedPositionState]: + """Join a position against the pool's slot and its two boundary ticks. + + Returns None when either boundary tick is missing, which means the + range is not initialized on chain and no amounts can be derived. + """ + slot = self.get_slot(pool_key).raw + lower = self._tick_info(pool_key, int(position.tick_lower)) + upper = self._tick_info(pool_key, int(position.tick_upper)) + if lower is None or upper is None: + return None + + liquidity = int(position.liquidity) + amount0, amount1 = amounts_for_liquidity( + u256_of(slot.sqrt_price), + get_sqrt_price_at_tick_x128(int(position.tick_lower)), + get_sqrt_price_at_tick_x128(int(position.tick_upper)), + liquidity, + ) + inside0, inside1 = fee_growth_inside( + (u256_of(lower.fee_growth_outside0_x_128), + u256_of(lower.fee_growth_outside1_x_128)), int(lower.tick), + (u256_of(upper.fee_growth_outside0_x_128), + u256_of(upper.fee_growth_outside1_x_128)), int(upper.tick), + int(slot.tick), + (u256_of(slot.fee_growth_global0_x_128), + u256_of(slot.fee_growth_global1_x_128)), + ) + owed0, owed1 = int(position.tokens_owed0), int(position.tokens_owed1) + return OwnedPositionState( + liquidity=liquidity, + amount0=amount0, amount1=amount1, + collectible0=owed0 + fee_owed( + inside0, u256_of(position.fee_growth_inside0_last_x_128), liquidity), + collectible1=owed1 + fee_owed( + inside1, u256_of(position.fee_growth_inside1_last_x_128), liquidity), + tokens_owed0=owed0, tokens_owed1=owed1, + ) + + def _owned_from_record(self, plaintext: str) -> Optional[OwnedPosition]: + """Build an :class:`OwnedPosition` from one PositionNFT record plaintext. + + Returns None for a record that is not a PositionNFT, so a mixed record + set from the program can be filtered in one pass. + """ + try: + decoded = parse_plaintext(plaintext) + except (ValueError, TypeError): + return None + if not isinstance(decoded, dict) or "tick_lower" not in decoded: + return None + token_id = str(decoded["token_id"]) + pool_key = str(decoded["pool"]) + raw = self._mapping_value("positions", token_id) + state = (self._owned_position_state(pool_key, g.Position.from_plaintext(raw)) + if raw is not None else None) + return OwnedPosition( + position_token_id=token_id, + pool_key=pool_key, + tick_lower=int(decoded["tick_lower"]), + tick_upper=int(decoded["tick_upper"]), + token0_id=str(decoded["token0_id"]), + token1_id=str(decoded["token1_id"]), + withdrawal=str(decoded["withdrawal"]), + record=plaintext, + state=state, + ) + + def get_owned_positions(self, *, pool_key: Optional[str] = None, + account: Any = None) -> list[OwnedPosition]: + """Every position this account holds, joined with its live chain state. + + Scans unspent PositionNFT records — so it needs a record provider — and + for each one reads ``positions``, the pool's slot, and both boundary + ticks. That is several reads per position; filter with *pool_key* when + you only care about one pool. + + A position whose ``state`` is ``None`` is mid-finalize: the record is + spendable but the chain has no amounts for it yet. Burned positions + never appear, since burn consumes the record. + + Args: + pool_key: Return only positions in this pool. + account: Signer to scan for; defaults to the client's. + + Returns: + One entry per owned position, in record-provider order. + """ + acct = self._account(account) + records = self._aleo.record_provider.find( + acct, program=self.program, unspent=True) + out: list[OwnedPosition] = [] + for rec in records: + plaintext = record_plaintext(rec) + if not plaintext: + continue + owned = self._owned_from_record(plaintext) + if owned is None: + continue + if pool_key is not None and owned.pool_key != pool_key: + continue + out.append(owned) + return out + + def get_owned_position(self, position_token_id: str, *, + account: Any = None) -> Optional[OwnedPosition]: + """One owned position by token id, or ``None`` if this account has no + such unspent record. + + ``None`` means "not owned or already burned" — it does not distinguish + the two, because both look identical from the record set. + """ + for owned in self.get_owned_positions(account=account): + if owned.position_token_id == position_token_id: + return owned + return None + def find_tick_predecessor(self, pool_key: str, new_tick: int, max_hops: int = 128) -> int: """Predecessor of *new_tick* in the pool's initialized-tick list. diff --git a/shield-swap-sdk/python/aleo_shield_swap/position_math.py b/shield-swap-sdk/python/aleo_shield_swap/position_math.py new file mode 100644 index 0000000..b408559 --- /dev/null +++ b/shield-swap-sdk/python/aleo_shield_swap/position_math.py @@ -0,0 +1,175 @@ +"""Bit-exact mirrors of shield_swap.aleo's position view helpers. + +These reproduce the contract's own arithmetic so a caller can value a position +without a transaction. Each function names the Leo helper it mirrors; the +vectors in ``tests/test_position_math.py`` are ported from the contract's +``tests/test_amm_helpers.leo`` rather than derived here, so a divergence in +either direction shows up as a test failure instead of a wrong balance. + +Fee growth is 256-bit and **modular by design**: an ``outside`` counter may +exceed the global one, and the difference wraps at 2^256. Every subtraction +here goes through :func:`u256_wrapping_sub` for that reason — a plain ``-`` +would raise where the contract silently wraps. +""" +from __future__ import annotations + +from typing import Any + +U256_MOD = 1 << 256 +U128_MAX = (1 << 128) - 1 +Q128 = 1 << 128 + + +def u256_wrapping_sub(a: int, b: int) -> int: + """``a - b`` modulo 2^256 — mirrors ``u256::u256_sub``. + + Fee-growth accounting relies on this wrapping: a tick's ``outside`` counter + can legitimately exceed the global counter, and the contract treats the + negative result as its two's-complement 256-bit value. + """ + return (a - b) % U256_MOD + + +def _mul_div(a: int, b: int, denom: int, round_up: bool) -> int: + """``a * b / denom``, floored or ceiled — mirrors ``view_mul_div``. + + Raises: + ZeroDivisionError: If *denom* is zero, as the contract's divide would + abort. + """ + quotient, remainder = divmod(a * b, denom) + return quotient + 1 if (round_up and remainder) else quotient + + +def amount0_delta(sqrt_a: int, sqrt_b: int, liquidity: int, + round_up: bool = False) -> int: + """Token0 backing *liquidity* between two Q128.128 sqrt prices. + + Mirrors ``amt0_div_f``. Argument order does not matter — the bounds are + sorted internally. + + Raises: + ValueError: If the result exceeds ``u128``, matching the contract's + ``assert(r.hi == 0)``. + """ + lower, upper = (sqrt_a, sqrt_b) if sqrt_a < sqrt_b else (sqrt_b, sqrt_a) + diff = u256_wrapping_sub(upper, lower) + scaled = _mul_div(liquidity * Q128, diff, upper, round_up) + result = _mul_div(scaled, 1, lower, round_up) + if result > U128_MAX: + raise ValueError(f"amount0 {result} exceeds u128") + return result + + +def amount1_delta(sqrt_a: int, sqrt_b: int, liquidity: int, + round_up: bool = False) -> int: + """Token1 backing *liquidity* between two Q128.128 sqrt prices. + + Mirrors ``amt1_shift_f``: ``liquidity * (upper - lower) >> 128``, plus one + when rounding up and the shift discarded a remainder. + + Raises: + ValueError: If the intermediate product exceeds 256 bits, matching the + contract's ``assert(hi256 == 0)``. + """ + lower, upper = (sqrt_a, sqrt_b) if sqrt_a < sqrt_b else (sqrt_b, sqrt_a) + product = liquidity * u256_wrapping_sub(upper, lower) + if product >> 256: + raise ValueError("amount1 intermediate exceeds 256 bits") + return (product >> 128) + (1 if (round_up and product & U128_MAX) else 0) + + +def amounts_for_liquidity(sqrt_current: int, sqrt_a: int, sqrt_b: int, + liquidity: int, round_up: bool = False + ) -> tuple[int, int]: + """The token amounts *liquidity* currently holds — ``view_amounts_for_liquidity``. + + Which side the position holds depends on where the pool price sits relative + to the range: entirely token0 below the range, entirely token1 above it, and + a split of both while in range. + + Args: + sqrt_current: The pool's current Q128.128 sqrt price. + sqrt_a: One range bound's sqrt price. + sqrt_b: The other bound's sqrt price. + liquidity: The position's liquidity. + round_up: Round each amount up rather than down. + + Returns: + ``(amount0, amount1)`` in raw base units. + """ + lower, upper = (sqrt_a, sqrt_b) if sqrt_a < sqrt_b else (sqrt_b, sqrt_a) + below = not lower < sqrt_current # price at or below the range + inside = not below and sqrt_current < upper + above = not below and not sqrt_current < upper + + if below: + return amount0_delta(lower, upper, liquidity, round_up), 0 + if inside: + return (amount0_delta(sqrt_current, upper, liquidity, round_up), + amount1_delta(lower, sqrt_current, liquidity, round_up)) + if above: + return 0, amount1_delta(lower, upper, liquidity, round_up) + return 0, 0 + + +def fee_growth_inside(lower_outside: tuple[int, int], lower_tick: int, + upper_outside: tuple[int, int], upper_tick: int, + tick_current: int, + fee_growth_global: tuple[int, int]) -> tuple[int, int]: + """Fee growth accrued inside a range — ``get_fee_growth_inside``. + + Args: + lower_outside: The lower tick's ``(fee_growth_outside0, outside1)``. + lower_tick: The lower tick index. + upper_outside: The upper tick's ``(outside0, outside1)``. + upper_tick: The upper tick index. + tick_current: The pool's current tick. + fee_growth_global: The pool's ``(global0, global1)``. + + Returns: + ``(inside0, inside1)`` as 256-bit modular values — subtract two of these + with :func:`u256_wrapping_sub`, never ``-``. + """ + global0, global1 = fee_growth_global + if tick_current >= lower_tick: + below0, below1 = lower_outside + else: + below0 = u256_wrapping_sub(global0, lower_outside[0]) + below1 = u256_wrapping_sub(global1, lower_outside[1]) + + if tick_current < upper_tick: + above0, above1 = upper_outside + else: + above0 = u256_wrapping_sub(global0, upper_outside[0]) + above1 = u256_wrapping_sub(global1, upper_outside[1]) + + return (u256_wrapping_sub(u256_wrapping_sub(global0, below0), above0), + u256_wrapping_sub(u256_wrapping_sub(global1, below1), above1)) + + +def fee_owed(growth_now: int, growth_last: int, liquidity: int) -> int: + """Fees a position has accrued — ``fee_owed``. + + ``floor((growth_now - growth_last) * liquidity / 2^128)`` over the modular + delta, so a wrapped accumulator settles correctly. + + Raises: + ValueError: If the result exceeds ``u128``, matching the contract's + overflow assert. At ``liquidity == 1`` the contract notes this + cannot detect a spurious modular underflow, so a nonsensical + ``growth_last`` yields a large-but-valid figure rather than an error. + """ + delta = u256_wrapping_sub(growth_now, growth_last) + whole = (delta >> 128) * liquidity + frac_hi = ((delta & U128_MAX) * liquidity) >> 128 + if whole >> 128 or frac_hi > U128_MAX - (whole & U128_MAX): + raise ValueError("fee_owed exceeds u128") + return (whole & U128_MAX) + frac_hi + + +def u256_of(value: Any) -> int: + """A generated ``U256`` struct (or plain int) as a Python integer.""" + if isinstance(value, int): + return value + return (int(value.hi) << 128) | int(value.lo) diff --git a/shield-swap-sdk/python/aleo_shield_swap/types.py b/shield-swap-sdk/python/aleo_shield_swap/types.py index f6ded0d..eb43a2b 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/types.py +++ b/shield-swap-sdk/python/aleo_shield_swap/types.py @@ -164,6 +164,51 @@ class PositionView: source: str # "journal" | "scanned" +@dataclass(frozen=True) +class OwnedPositionState: + """A position's chain-derived state — everything the mappings know. + + Read from ``positions``/``slots``/``ticks`` and the view math, so it moves + with the pool price rather than being fixed at mint. + """ + + liquidity: int + #: Token amounts currently backing the range, at the pool's live price. + amount0: int + amount1: int + #: What ``collect`` would pay today: already-accrued ``tokens_owed`` plus + #: fees earned since the position was last touched. + collectible0: int + collectible1: int + tokens_owed0: int + tokens_owed1: int + + +@dataclass(frozen=True) +class OwnedPosition: + """A position this account owns: its record identity plus live chain state. + + A position spans two sources. The private ``PositionNFT`` record carries + identity — pool, range, withdrawal address — and no amounts; the public + mappings carry amounts and no identity. This is the join. + + *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. + """ + + position_token_id: str + pool_key: str + tick_lower: int + tick_upper: int + token0_id: str + token1_id: str + withdrawal: str + #: The spendable record plaintext — pass as ``position_record=`` to write verbs. + record: str + state: Optional[OwnedPositionState] + + @dataclass class SessionStatus: """Everything an agent needs to re-orient in one call.""" diff --git a/shield-swap-sdk/tests/test_owned_positions.py b/shield-swap-sdk/tests/test_owned_positions.py new file mode 100644 index 0000000..2c0a1f2 --- /dev/null +++ b/shield-swap-sdk/tests/test_owned_positions.py @@ -0,0 +1,146 @@ +"""get_owned_positions / get_owned_position — the record↔mapping join. + +A position's identity lives in the private PositionNFT record and its amounts +live in the public mappings; these cover the join, the finalize lag, the pool +filter, and that the derived amounts agree with the view math. +""" +from __future__ import annotations + +from aleo_shield_swap.client import ShieldSwap +from aleo_shield_swap.position_math import ( + amounts_for_liquidity, + fee_owed, + u256_of, +) +from aleo_shield_swap.tick_math import get_sqrt_price_at_tick_x128 + +from .conftest import POOL_TEXT, SLOT_TEXT, StubAleo + +POSITION_RECORD = ( + "{ owner: aleo1me.private, withdrawal: aleo1payout.private, " + "token_id: 42field.private, " + "token0_id: 1field.private, token1_id: 2field.private, " + "pool: 5field.private, tick_lower: -4080i32.private, " + "tick_upper: 4080i32.private, liquidity: 500u128.private, " + "_nonce: 3group.public }" +) +# A record from the same program that is NOT a PositionNFT (no tick_lower). +TOKEN_RECORD = ("{ owner: aleo1me.private, amount: 900u128.private, " + "_nonce: 7group.public }") + +POSITION_ENTRY = ( + "{ token_id: 42field, pool: 5field, tick_lower: -4080i32, " + "tick_upper: 4080i32, liquidity: 500u128, " + "fee_growth_inside0_last_x_128: { hi: 0u128, lo: 0u128 }, " + "fee_growth_inside1_last_x_128: { hi: 0u128, lo: 0u128 }, " + "tokens_owed0: 11u128, tokens_owed1: 22u128 }" +) + + +def _tick(pool: str, tick: int, fg0: int = 0, fg1: int = 0) -> str: + return (f"{{ pool: {pool}, liquidity_net: 0i128, liquidity_gross: 500u128, " + f"tick: {tick}i32, " + f"fee_growth_outside0_x_128: {{ hi: 0u128, lo: {fg0}u128 }}, " + f"fee_growth_outside1_x_128: {{ hi: 0u128, lo: {fg1}u128 }}, " + "prev: 0i32, next: 0i32 }") + + +def _stub(*, positions=None, ticks=True, records=None): + dex_keys = { + "pools": {"5field": POOL_TEXT}, + "slots": {"5field": SLOT_TEXT}, + "positions": positions if positions is not None else {"42field": POSITION_ENTRY}, + } + stub = StubAleo(mappings=dex_keys, + records=records if records is not None + else [{"record_plaintext": POSITION_RECORD}]) + if ticks: + dex = ShieldSwap(stub) + stub.programs._mappings["ticks"] = { + dex.derive_tick_key("5field", -4080): _tick("5field", -4080), + dex.derive_tick_key("5field", 4080): _tick("5field", 4080), + } + return stub + + +def test_joins_record_identity_with_chain_state(): + owned = ShieldSwap(_stub()).get_owned_positions() + assert len(owned) == 1 + p = owned[0] + # record side + assert p.position_token_id == "42field" + assert (p.pool_key, p.tick_lower, p.tick_upper) == ("5field", -4080, 4080) + assert (p.token0_id, p.token1_id) == ("1field", "2field") + assert p.withdrawal == "aleo1payout" + assert p.record == POSITION_RECORD # spendable, for position_record= + # chain side + assert p.state is not None + assert p.state.liquidity == 500 + assert (p.state.tokens_owed0, p.state.tokens_owed1) == (11, 22) + + +def test_amounts_match_the_view_math(): + dex = ShieldSwap(_stub()) + p = dex.get_owned_positions()[0] + slot = dex.get_slot("5field").raw + expected = amounts_for_liquidity( + u256_of(slot.sqrt_price), + get_sqrt_price_at_tick_x128(-4080), + get_sqrt_price_at_tick_x128(4080), + 500, + ) + assert (p.state.amount0, p.state.amount1) == expected + + +def test_collectible_is_owed_plus_accrued(): + # outside counters at 0 and last-inside at 0, so accrued == inside growth + dex = ShieldSwap(_stub()) + p = dex.get_owned_positions()[0] + assert p.state.collectible0 >= p.state.tokens_owed0 + assert p.state.collectible1 >= p.state.tokens_owed1 + + +def test_state_is_none_while_the_mint_finalizes(): + # the record exists but positions[token_id] is not written yet + owned = ShieldSwap(_stub(positions={})).get_owned_positions() + assert len(owned) == 1 + assert owned[0].state is None + assert owned[0].position_token_id == "42field" # identity still usable + + +def test_state_is_none_when_boundary_ticks_are_uninitialized(): + owned = ShieldSwap(_stub(ticks=False)).get_owned_positions() + assert owned[0].state is None + + +def test_non_position_records_are_skipped(): + stub = _stub(records=[{"record_plaintext": TOKEN_RECORD}, + {"record_plaintext": POSITION_RECORD}]) + owned = ShieldSwap(stub).get_owned_positions() + assert [p.position_token_id for p in owned] == ["42field"] + + +def test_pool_key_filters(): + dex = ShieldSwap(_stub()) + assert len(dex.get_owned_positions(pool_key="5field")) == 1 + assert dex.get_owned_positions(pool_key="9field") == [] + + +def test_get_owned_position_by_id(): + dex = ShieldSwap(_stub()) + assert dex.get_owned_position("42field").position_token_id == "42field" + assert dex.get_owned_position("999field") is None + + +def test_no_records_is_empty_not_error(): + assert ShieldSwap(_stub(records=[])).get_owned_positions() == [] + + +def test_fee_owed_contribution_is_reproducible(): + """collectible - tokens_owed must equal fee_owed over the inside growth.""" + dex = ShieldSwap(_stub()) + p = dex.get_owned_positions()[0] + accrued0 = p.state.collectible0 - p.state.tokens_owed0 + # with all outside/last counters zero, inside growth == global growth + slot = dex.get_slot("5field").raw + assert accrued0 == fee_owed(u256_of(slot.fee_growth_global0_x_128), 0, 500) diff --git a/shield-swap-sdk/tests/test_position_math.py b/shield-swap-sdk/tests/test_position_math.py new file mode 100644 index 0000000..f594cd5 --- /dev/null +++ b/shield-swap-sdk/tests/test_position_math.py @@ -0,0 +1,156 @@ +"""Position view math — vectors ported from amm-v3 tests/test_amm_helpers.leo. + +Expected values are the contract's own assertions, transcribed rather than +recomputed here, so a divergence in either implementation fails this file. +""" +from __future__ import annotations + +import pytest + +from aleo_shield_swap.position_math import ( + Q128, + U128_MAX, + amount0_delta, + amount1_delta, + amounts_for_liquidity, + fee_growth_inside, + fee_owed, + u256_of, + u256_wrapping_sub, +) + + +def _u256(hi: int, lo: int) -> int: + """The Leo vectors are written as ``U256 { hi, lo }``.""" + return (hi << 128) | lo + + +# ── fee_owed — t_fee_owed ──────────────────────────────────────────────────── + +def test_fee_owed_vectors(): + two128 = _u256(1, 0) + # delta = 2^128, liquidity 1000 -> 1000 owed + assert fee_owed(two128, 0, 1000) == 1000 + # delta = 2*2^128 - 2^128 = 2^128, liquidity 5000 -> 5000 + assert fee_owed(_u256(2, 0), two128, 5000) == 5000 + # wrapped accumulator (now < last): modular delta = 2^128, liq 1000 -> 1000 + assert fee_owed(_u256(0, 5), _u256(U128_MAX, 5), 1000) == 1000 + # zero liquidity owes nothing + assert fee_owed(0, 0, 0) == 0 + # delta = 3*2^128, liquidity 7 -> 21 + assert fee_owed(_u256(3, 0), 0, 7) == 21 + + +def test_fee_owed_overflow_raises(): + with pytest.raises(ValueError, match="exceeds u128"): + fee_owed(_u256(U128_MAX, 0), 0, U128_MAX) + + +# ── fee_growth_inside — t_fee_growth_inside ───────────────────────────────── + +LOWER_OUTSIDE, LOWER_TICK = (10, 20), -100 +UPPER_OUTSIDE, UPPER_TICK = (5, 8), 100 +GLOBAL = (1000, 2000) + + +def _inside(tick_current: int) -> tuple[int, int]: + return fee_growth_inside(LOWER_OUTSIDE, LOWER_TICK, + UPPER_OUTSIDE, UPPER_TICK, + tick_current, GLOBAL) + + +def test_fee_growth_inside_in_range(): + assert _inside(0) == (985, 1972) + + +def test_fee_growth_inside_below_range(): + assert _inside(-200) == (5, 12) + + +def test_fee_growth_inside_above_range_wraps(): + # above the range the accounting wraps at 2^256 — modular by design + assert _inside(150) == ( + _u256(U128_MAX, 340282366920938463463374607431768211451), + _u256(U128_MAX, 340282366920938463463374607431768211444), + ) + + +def test_fee_growth_inside_exactly_at_lower_is_in_range(): + # tick_current >= lower takes the in-range arm + assert _inside(-100) == (985, 1972) + + +def test_fee_growth_inside_wrapped_outsides(): + # t_fee_growth_inside_wrapped: outside counters exceed the global + inside0, _ = fee_growth_inside((3000, 0), -100, (2500, 0), 100, 0, (2000, 0)) + # 2000 - 3000 - 2500 modulo 2^256 + assert inside0 == (2000 - 3000 - 2500) % (1 << 256) + + +# ── u256_wrapping_sub ─────────────────────────────────────────────────────── + +def test_u256_wrapping_sub_wraps_not_raises(): + assert u256_wrapping_sub(5, 7) == (1 << 256) - 2 + assert u256_wrapping_sub(7, 5) == 2 + assert u256_wrapping_sub(0, 0) == 0 + + +# ── amounts_for_liquidity ─────────────────────────────────────────────────── + +def test_amounts_below_range_is_all_token0(): + lo, hi = 2 * Q128, 4 * Q128 + a0, a1 = amounts_for_liquidity(Q128, lo, hi, 10**6) + assert a1 == 0 and a0 == amount0_delta(lo, hi, 10**6) + + +def test_amounts_above_range_is_all_token1(): + lo, hi = 2 * Q128, 4 * Q128 + a0, a1 = amounts_for_liquidity(8 * Q128, lo, hi, 10**6) + assert a0 == 0 and a1 == amount1_delta(lo, hi, 10**6) + + +def test_amounts_in_range_holds_both(): + lo, hi, cur = 2 * Q128, 4 * Q128, 3 * Q128 + a0, a1 = amounts_for_liquidity(cur, lo, hi, 10**6) + assert a0 > 0 and a1 > 0 + assert a0 == amount0_delta(cur, hi, 10**6) + assert a1 == amount1_delta(lo, cur, 10**6) + + +def test_amounts_bound_order_does_not_matter(): + lo, hi, cur = 2 * Q128, 4 * Q128, 3 * Q128 + assert (amounts_for_liquidity(cur, lo, hi, 10**6) + == amounts_for_liquidity(cur, hi, lo, 10**6)) + + +def test_amounts_at_lower_bound_is_below_arm(): + # below is `not lower < sqrt_current`, so price == lower counts as below + lo, hi = 2 * Q128, 4 * Q128 + a0, a1 = amounts_for_liquidity(lo, lo, hi, 10**6) + assert a1 == 0 and a0 == amount0_delta(lo, hi, 10**6) + + +def test_amounts_at_upper_bound_is_above_arm(): + lo, hi = 2 * Q128, 4 * Q128 + a0, a1 = amounts_for_liquidity(hi, lo, hi, 10**6) + assert a0 == 0 and a1 == amount1_delta(lo, hi, 10**6) + + +def test_zero_liquidity_holds_nothing(): + assert amounts_for_liquidity(3 * Q128, 2 * Q128, 4 * Q128, 0) == (0, 0) + + +def test_round_up_never_below_round_down(): + lo, hi, cur = 2 * Q128 + 7, 4 * Q128 + 13, 3 * Q128 + 5 + down = amounts_for_liquidity(cur, lo, hi, 12345, round_up=False) + up = amounts_for_liquidity(cur, lo, hi, 12345, round_up=True) + assert up[0] >= down[0] and up[1] >= down[1] + + +# ── u256_of ───────────────────────────────────────────────────────────────── + +def test_u256_of_accepts_struct_or_int(): + class _S: + hi, lo = 3, 5 + assert u256_of(_S()) == _u256(3, 5) + assert u256_of(42) == 42 From 1260939901e1b2de4b058df1df2848f88b5b47c1 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 10:34:29 -0500 Subject: [PATCH 05/15] feat(shield-swap): swap reserves its blinding counter and journals the handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- shield-swap-sdk/AGENTS.md | 12 +++- .../python/aleo_shield_swap/AGENTS.md | 12 +++- .../python/aleo_shield_swap/client.py | 33 ++++++++-- shield-swap-sdk/tests/test_swap.py | 66 +++++++++++++++++++ 4 files changed, 113 insertions(+), 10 deletions(-) diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index 483318a..80ba4da 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -323,7 +323,7 @@ fast when something is systematically wrong (e.g. wrong program). ### Chain methods -### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int', slippage_bps: 'int' = 50, expected_out: 'Optional[int]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` +### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int', slippage_bps: 'int' = 50, expected_out: 'Optional[int]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` Request a private swap — phase one of the two-transaction flow. @@ -339,8 +339,14 @@ process might die before the claim. Quote first (``dex.api.get_route``) and pass *expected_out*: without it a spot estimate is used, which ignores fees and price impact. -Pass *identity* (from journal-reserved counters) to skip the -on-chain probe — required for concurrent swaps. The default +On a profile-bound client the blinding counter is reserved from the +journal and the resulting handle is recorded once the broadcast is +accepted, so concurrent swaps cannot collide and a crash before the +claim does not lose the secret. Pass ``track=False`` to opt out (the +counter then comes from an on-chain probe, which races), or *identity* +to supply your own. Without a journal the probe is all there is. + +The default *deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- proving latency; a tight deadline aborts at finalize when proving outlives it. diff --git a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md index 483318a..80ba4da 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md +++ b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md @@ -323,7 +323,7 @@ fast when something is systematically wrong (e.g. wrong program). ### Chain methods -### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int', slippage_bps: 'int' = 50, expected_out: 'Optional[int]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` +### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int', slippage_bps: 'int' = 50, expected_out: 'Optional[int]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` Request a private swap — phase one of the two-transaction flow. @@ -339,8 +339,14 @@ process might die before the claim. Quote first (``dex.api.get_route``) and pass *expected_out*: without it a spot estimate is used, which ignores fees and price impact. -Pass *identity* (from journal-reserved counters) to skip the -on-chain probe — required for concurrent swaps. The default +On a profile-bound client the blinding counter is reserved from the +journal and the resulting handle is recorded once the broadcast is +accepted, so concurrent swaps cannot collide and a crash before the +claim does not lose the secret. Pass ``track=False`` to opt out (the +counter then comes from an on-chain probe, which races), or *identity* +to supply your own. Without a journal the probe is all there is. + +The default *deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- proving latency; a tight deadline aborts at finalize when proving outlives it. diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index a6b74d8..cda259f 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -615,6 +615,7 @@ def swap( token_in_program: Optional[str] = None, token_record: Optional[str] = None, wrapper_proofs: Optional[str] = None, + track: bool = True, imports: Optional[dict[str, str]] = None, account: Any = None, ) -> DexCall[SwapHandle]: @@ -632,8 +633,14 @@ def swap( Quote first (``dex.api.get_route``) and pass *expected_out*: without it a spot estimate is used, which ignores fees and price impact. - Pass *identity* (from journal-reserved counters) to skip the - on-chain probe — required for concurrent swaps. The default + On a profile-bound client the blinding counter is reserved from the + journal and the resulting handle is recorded once the broadcast is + accepted, so concurrent swaps cannot collide and a crash before the + claim does not lose the secret. Pass ``track=False`` to opt out (the + counter then comes from an on-chain probe, which races), or *identity* + to supply your own. Without a journal the probe is all there is. + + The default *deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- proving latency; a tight deadline aborts at finalize when proving outlives it. @@ -648,7 +655,17 @@ def swap( ) deadline = get_deadline(self._aleo, deadline_offset_blocks) swap_nonce = nonce if nonce is not None else generate_swap_nonce() - identity = identity or next_blinded_identity(self._aleo, acct, self.program) + # Reserve from the journal rather than probing the chain: the probe + # asks "is this blinded address used?" and the answer is only true + # until another swap consumes it, so two concurrent swaps derive the + # same counter and the second reverts at finalize. Reservation is + # serialized by the journal's file lock, so it cannot collide. + counter: Optional[int] = None + if identity is None and track and self.journal is not None: + counter = self.journal.reserve_counters(1)[0] + identity = blinded_identity_at(self._aleo, acct, self.program, counter) + elif identity is None: + identity = next_blinded_identity(self._aleo, acct, self.program) # Resolve the record-funding program lazily: an explicit record # needs no registry lookup (its program registration comes from @@ -710,7 +727,7 @@ def build_result(tx_id: str, outputs: list[Any]) -> SwapHandle: (o for o in outputs if isinstance(o, str) and o.endswith("field")), None, ) - return SwapHandle( + handle = SwapHandle( swap_id=swap_id, blinding_factor=identity.blinding_factor, blinded_address=identity.blinded_address, @@ -721,6 +738,14 @@ def build_result(tx_id: str, outputs: list[Any]) -> SwapHandle: transaction_id=tx_id, program=self.program, ) + # Journal the handle as soon as the broadcast is accepted: the + # blinding factor is the only thing that can claim this swap, and a + # crash before the claim would otherwise lose it. A failure here is + # raised, not swallowed — the swap has landed, so silently dropping + # its claim secret is the worse outcome. + if counter is not None and self.journal is not None: + self.journal.record_swap(handle, counter) + return handle return DexCall(self._aleo, bound, build_result) diff --git a/shield-swap-sdk/tests/test_swap.py b/shield-swap-sdk/tests/test_swap.py index 2ac7dba..a818096 100644 --- a/shield-swap-sdk/tests/test_swap.py +++ b/shield-swap-sdk/tests/test_swap.py @@ -20,6 +20,15 @@ ) +def _swap_call_on(dex, **over): + """_swap_call against an already-built client (journal wired, etc.).""" + kwargs = dict(pool_key="5field", token_in_id="1field", amount_in=10**9, + slippage_bps=50, nonce=123, token_in_program="tok.aleo", + expected_out=1_000_000) + kwargs.update(over) + return dex.swap(**kwargs) + + def _swap_call(stub_aleo, **over): dex = ShieldSwap(stub_aleo) kwargs = dict(pool_key="5field", token_in_id="1field", amount_in=10**9, @@ -135,3 +144,60 @@ def test_routed_swap_rejects_zero_min_out(): def test_plain_swap_stays_on_core(stub_aleo): _swap_call(stub_aleo) assert stub_aleo.last_program == "shield_swap.aleo" + + +# ── Blinded-identity reservation and handle tracking ───────────────────────── +# The chain probe asks "is this blinded address used?", which is only true +# until another swap consumes it — so two concurrent swaps derive the same +# counter and the second reverts at finalize. A journal-backed reservation +# serializes instead. + +def _journalled_dex(tmp_path, stub): + from aleo_shield_swap.client import ShieldSwap + from aleo_shield_swap.journal import Journal + dex = ShieldSwap(stub) + dex.journal = Journal(tmp_path / "journal.jsonl") + return dex + + +def test_swap_reserves_a_counter_and_journals_the_handle(tmp_path, stub_aleo): + dex = _journalled_dex(tmp_path, stub_aleo) + assert dex.journal.counter_cursor() == 0 + handle = _swap_call_on(dex).transact() + # counter consumed, and the handle is recoverable from a fresh Journal + assert dex.journal.counter_cursor() == 1 + pending = dex.journal.pending_claims() + assert [h.transaction_id for h in pending] == [handle.transaction_id] + assert pending[0].blinding_factor == handle.blinding_factor + + +def test_concurrent_swaps_get_distinct_counters(tmp_path, stub_aleo): + dex = _journalled_dex(tmp_path, stub_aleo) + first = _swap_call_on(dex).transact() + second = _swap_call_on(dex).transact() + assert first.blinded_address != second.blinded_address + assert dex.journal.counter_cursor() == 2 + + +def test_track_false_opts_out_of_the_journal(tmp_path, stub_aleo): + dex = _journalled_dex(tmp_path, stub_aleo) + _swap_call_on(dex, track=False).transact() + assert dex.journal.counter_cursor() == 0 + assert dex.journal.pending_claims() == [] + + +def test_explicit_identity_still_wins(tmp_path, stub_aleo): + from aleo_shield_swap.derivations import blinded_identity_at + dex = _journalled_dex(tmp_path, stub_aleo) + ident = blinded_identity_at(stub_aleo, stub_aleo.default_account, + dex.program, 7) + handle = _swap_call_on(dex, identity=ident).transact() + assert handle.blinded_address == ident.blinded_address + assert dex.journal.counter_cursor() == 0 # nothing reserved + + +def test_without_a_journal_swap_still_works(stub_aleo): + from aleo_shield_swap.client import ShieldSwap + dex = ShieldSwap(stub_aleo) + assert dex.journal is None + assert _swap_call_on(dex).transact().blinded_address From cafda1fcdc8129fa2dd80959c640cd75975ab32f Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 10:35:18 -0500 Subject: [PATCH 06/15] fix(shield-swap): keep pyright clean on the reserved-counter narrowing --- shield-swap-sdk/python/aleo_shield_swap/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index cda259f..f7cb274 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -662,8 +662,9 @@ def swap( # serialized by the journal's file lock, so it cannot collide. counter: Optional[int] = None if identity is None and track and self.journal is not None: - counter = self.journal.reserve_counters(1)[0] - identity = blinded_identity_at(self._aleo, acct, self.program, counter) + reserved = int(self.journal.reserve_counters(1)[0]) + counter = reserved + identity = blinded_identity_at(self._aleo, acct, self.program, reserved) elif identity is None: identity = next_blinded_identity(self._aleo, acct, self.program) From cf0017ea45637f6a8c065ca09283418c33aa42fa Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 10:36:29 -0500 Subject: [PATCH 07/15] fix(shield-swap): the airdrop stage is testnet-only, say so on mainnet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../python/aleo_shield_swap/lifecycle.py | 10 +++++++++ shield-swap-sdk/tests/test_lifecycle.py | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py b/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py index 0ff122a..13efc15 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py +++ b/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py @@ -16,6 +16,7 @@ AirdropPendingError, AirdropRateLimitedError, CredentialsMissingError, + NotFundedError, NotAuthenticatedError, NotRedeemedError, ) @@ -181,6 +182,15 @@ def _airdrop_done(ctx: _Ctx) -> bool: def _airdrop_run(ctx: _Ctx) -> str: + # /airdrop and /airdrop/{job_id} exist on testnet only — the mainnet API + # publishes neither, so requesting one there 404s. Say so instead, because + # the remedy is the caller funding the account, not a retry. + if ctx.profile.network != "testnet": + raise NotFundedError( + f"no faucet on {ctx.profile.network}: the airdrop endpoints are " + f"testnet-only. Fund {ctx.profile.address} with the tokens you " + "intend to trade, then re-run onboard()." + ) try: start = ctx.dex.api.request_airdrop(ctx.profile.address) except AirdropRateLimitedError: diff --git a/shield-swap-sdk/tests/test_lifecycle.py b/shield-swap-sdk/tests/test_lifecycle.py index 8387246..21939fe 100644 --- a/shield-swap-sdk/tests/test_lifecycle.py +++ b/shield-swap-sdk/tests/test_lifecycle.py @@ -171,3 +171,25 @@ def _refresh_credentials(self): dex = _RefreshingDex(api, {"waleo.aleo": 7}, funded_from_start=True) run_onboard(dex, profile) assert refreshed == [True] # live provider picked up the new key + + +def test_airdrop_stage_refuses_on_mainnet(tmp_path): + tmp_journal = tmp_path / "j.jsonl" + """The faucet endpoints are testnet-only; on mainnet say so, don't 404.""" + from aleo_shield_swap.errors import NotFundedError + from aleo_shield_swap.lifecycle import _Ctx, _airdrop_run + + class _P: + network = "mainnet" + address = "aleo1me" + journal_path = tmp_journal + + ctx = _Ctx(dex=None, profile=_P(), invite_code=None, + poll_seconds=0, timeout_seconds=0) + try: + _airdrop_run(ctx) + except NotFundedError as exc: + assert "no faucet on mainnet" in str(exc) + assert "aleo1me" in str(exc) + else: + raise AssertionError("expected NotFundedError on mainnet") From c63ee5a7595747cdc31c2936ef9933199dfb9992 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 10:38:58 -0500 Subject: [PATCH 08/15] feat(shield-swap): from_profile takes network and endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- shield-swap-sdk/AGENTS.md | 14 ++++++++++- .../python/aleo_shield_swap/AGENTS.md | 14 ++++++++++- .../python/aleo_shield_swap/client.py | 23 +++++++++++++++++-- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index 80ba4da..ab02062 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -21,7 +21,7 @@ report = dex.swap_many(pool_key=pools[0].key, token_in_id=pools[0].token0, dex.collect_all() # any session, any time ``` -### `from_profile(home: 'Any' = None) -> "'ShieldSwap'"` +### `from_profile(home: 'Any' = None, *, network: 'Optional[str]' = None, endpoint: 'Optional[str]' = None) -> "'ShieldSwap'"` The client for the local participant profile (created on first use). @@ -29,6 +29,18 @@ Wires endpoint, network, signer, and (when present) delegated-proving credentials from ``$SHIELD_SWAP_HOME``/``~/.shield-swap``. Run ``onboard()`` next on a fresh profile. +*network* and *endpoint* apply only when the profile is being created — +an existing one keeps what it was created with, because its derived pool +keys and blinded identities are network-scoped and would not transfer. +Give each network its own home directory. + +Args: + home: Profile directory; defaults to ``$SHIELD_SWAP_HOME`` or + ``~/.shield-swap``. + network: ``"mainnet"`` or ``"testnet"`` for a NEW profile; defaults + to testnet. + endpoint: Node API origin for a NEW profile. + ### `onboard(self, invite_code: 'Optional[str]' = None) -> 'OnboardReport'` Register this profile end to end — safe to re-run any time. diff --git a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md index 80ba4da..ab02062 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md +++ b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md @@ -21,7 +21,7 @@ report = dex.swap_many(pool_key=pools[0].key, token_in_id=pools[0].token0, dex.collect_all() # any session, any time ``` -### `from_profile(home: 'Any' = None) -> "'ShieldSwap'"` +### `from_profile(home: 'Any' = None, *, network: 'Optional[str]' = None, endpoint: 'Optional[str]' = None) -> "'ShieldSwap'"` The client for the local participant profile (created on first use). @@ -29,6 +29,18 @@ Wires endpoint, network, signer, and (when present) delegated-proving credentials from ``$SHIELD_SWAP_HOME``/``~/.shield-swap``. Run ``onboard()`` next on a fresh profile. +*network* and *endpoint* apply only when the profile is being created — +an existing one keeps what it was created with, because its derived pool +keys and blinded identities are network-scoped and would not transfer. +Give each network its own home directory. + +Args: + home: Profile directory; defaults to ``$SHIELD_SWAP_HOME`` or + ``~/.shield-swap``. + network: ``"mainnet"`` or ``"testnet"`` for a NEW profile; defaults + to testnet. + endpoint: Node API origin for a NEW profile. + ### `onboard(self, invite_code: 'Optional[str]' = None) -> 'OnboardReport'` Register this profile end to end — safe to re-run any time. diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index f7cb274..1d724a5 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -111,16 +111,35 @@ def __repr__(self) -> str: return f"ShieldSwap(program={self.program!r}, api={self.api.base_url!r})" @classmethod - def from_profile(cls, home: Any = None) -> "ShieldSwap": + def from_profile(cls, home: Any = None, *, + network: Optional[str] = None, + endpoint: Optional[str] = None) -> "ShieldSwap": """The client for the local participant profile (created on first use). Wires endpoint, network, signer, and (when present) delegated-proving credentials from ``$SHIELD_SWAP_HOME``/``~/.shield-swap``. Run ``onboard()`` next on a fresh profile. + + *network* and *endpoint* apply only when the profile is being created — + an existing one keeps what it was created with, because its derived pool + keys and blinded identities are network-scoped and would not transfer. + Give each network its own home directory. + + Args: + home: Profile directory; defaults to ``$SHIELD_SWAP_HOME`` or + ``~/.shield-swap``. + network: ``"mainnet"`` or ``"testnet"`` for a NEW profile; defaults + to testnet. + endpoint: Node API origin for a NEW profile. """ from aleo import Aleo, HTTPProvider - profile = Profile.load_or_create(home) + kwargs: dict[str, Any] = {} + if network is not None: + kwargs["network"] = network + if endpoint is not None: + kwargs["endpoint"] = endpoint + profile = Profile.load_or_create(home, **kwargs) creds = profile.credentials provider = HTTPProvider(profile.endpoint, network=profile.network, api_key=creds.get("dps_api_key"), From 013f5d457c0076c340a38eb1c2af2a81ca7c5e48 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 10:47:55 -0500 Subject: [PATCH 09/15] fix(shield-swap): the write tier never passed its own credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- shield-swap-sdk/tests/integration/conftest.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/shield-swap-sdk/tests/integration/conftest.py b/shield-swap-sdk/tests/integration/conftest.py index 74b9d9f..4513076 100644 --- a/shield-swap-sdk/tests/integration/conftest.py +++ b/shield-swap-sdk/tests/integration/conftest.py @@ -20,7 +20,14 @@ def _make_live_dex(): from aleo_shield_swap import ShieldSwap - aleo = Aleo(HTTPProvider(ENDPOINT, network="testnet")) + # Pass the DPS credentials the write tier gates on: without them the + # hosted record scanner answers Unauthorized, so every record read — and + # therefore every write test — fails before it reaches the chain. + aleo = Aleo(HTTPProvider( + ENDPOINT, network="testnet", + api_key=os.environ.get("ALEO_E2E_API_KEY"), + consumer_id=os.environ.get("ALEO_E2E_CONSUMER_ID"), + )) dex = ShieldSwap(aleo) # Some API endpoints are auth-gated (signature challenge/verify) and # additionally invite-gated per account. Prefer the e2e account (it has @@ -35,6 +42,12 @@ def _make_live_dex(): ) except Exception: pass # auth endpoint down — gated tests will skip + if pk and os.environ.get("ALEO_E2E_API_KEY"): + try: + aleo.default_account = acct + aleo.records.register(acct) # scanning needs a registration + except Exception: + pass # scanner down — record reads will surface it return dex From 7e0831cb6df0e79926081dd0287e81ea7c2171de Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 11:48:42 -0500 Subject: [PATCH 10/15] 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. --- .../tests/integration/test_swap_lifecycle.py | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/shield-swap-sdk/tests/integration/test_swap_lifecycle.py b/shield-swap-sdk/tests/integration/test_swap_lifecycle.py index e005c4d..a4968af 100644 --- a/shield-swap-sdk/tests/integration/test_swap_lifecycle.py +++ b/shield-swap-sdk/tests/integration/test_swap_lifecycle.py @@ -35,14 +35,22 @@ def test_private_swap_roundtrip(): from aleo_shield_swap import ShieldSwap + # consumer_id must go on the PROVIDER, not just the network client: the + # record scanner is built lazily from provider config, so setting it only + # on network_client leaves the scanner with a key and no consumer — it + # cannot mint a JWT and every record read answers Unauthorized. provider = HTTPProvider(ENDPOINT, network="testnet", - api_key=os.environ["ALEO_E2E_API_KEY"]) + api_key=os.environ["ALEO_E2E_API_KEY"], + consumer_id=os.environ["ALEO_E2E_CONSUMER_ID"]) aleo = Aleo(provider) - aleo.network_client.consumer_id = os.environ["ALEO_E2E_CONSUMER_ID"] acct = aleo.account.from_private_key(os.environ["ALEO_E2E_PRIVATE_KEY"]) aleo.default_account = acct aleo.records.register(acct) dex = ShieldSwap(aleo) + # get_route is auth-gated; without a session it answers 401 well before + # anything is proved. + dex.api.authenticate(str(acct.address), + lambda msg: str(aleo.account.sign(msg.encode(), acct))) # Pick a pool where the account holds a private balance of one side. pools = dex.api.get_pools() @@ -96,14 +104,22 @@ def test_wrapped_flow_roundtrip(): from aleo_shield_swap import ShieldSwap + # consumer_id must go on the PROVIDER, not just the network client: the + # record scanner is built lazily from provider config, so setting it only + # on network_client leaves the scanner with a key and no consumer — it + # cannot mint a JWT and every record read answers Unauthorized. provider = HTTPProvider(ENDPOINT, network="testnet", - api_key=os.environ["ALEO_E2E_API_KEY"]) + api_key=os.environ["ALEO_E2E_API_KEY"], + consumer_id=os.environ["ALEO_E2E_CONSUMER_ID"]) aleo = Aleo(provider) - aleo.network_client.consumer_id = os.environ["ALEO_E2E_CONSUMER_ID"] acct = aleo.account.from_private_key(os.environ["ALEO_E2E_PRIVATE_KEY"]) aleo.default_account = acct aleo.records.register(acct) dex = ShieldSwap(aleo) + # get_route is auth-gated; without a session it answers 401 well before + # anything is proved. + dex.api.authenticate(str(acct.address), + lambda msg: str(aleo.account.sign(msg.encode(), acct))) tokens = {t.address: t for t in dex.api.get_tokens()} case = None From 2887aa2ca1c639d4e56ea5de2986c8735a3a0f41 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 11:58:27 -0500 Subject: [PATCH 11/15] fix(shield-swap): quote failures stay errors; expanduser; pyright to zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- sdk-abi/Cargo.toml | 2 +- sdk-abi/pyproject.toml | 2 +- sdk-abi/python/aleo_abi/_aleo_abi.pyi | 9 +- sdk/Cargo.toml | 2 +- sdk/pyproject.toml | 2 +- shield-swap-sdk/pyproject.toml | 2 +- .../python/aleo_shield_swap/__init__.py | 2 +- .../python/aleo_shield_swap/api.py | 10 ++- .../python/aleo_shield_swap/async_client.py | 8 +- .../python/aleo_shield_swap/client.py | 89 +++++++++++++------ .../python/aleo_shield_swap/profile.py | 16 ++-- shield-swap-sdk/tests/test_package.py | 2 +- shield-swap-sdk/tests/test_profile.py | 24 +++++ shield-swap-sdk/tests/test_swap_many.py | 46 +++++++++- 14 files changed, 168 insertions(+), 48 deletions(-) diff --git a/sdk-abi/Cargo.toml b/sdk-abi/Cargo.toml index 0c105f8..599aef2 100644 --- a/sdk-abi/Cargo.toml +++ b/sdk-abi/Cargo.toml @@ -5,7 +5,7 @@ [package] name = "aleo-abi" -version = "0.3.1" +version = "0.4.0" edition = "2024" license = "GPL-3.0-or-later" description = "Python bindings for ABI generation from Aleo bytecode (via Leo's leo-abi crate)" diff --git a/sdk-abi/pyproject.toml b/sdk-abi/pyproject.toml index 96f423f..b121268 100644 --- a/sdk-abi/pyproject.toml +++ b/sdk-abi/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "aleo-contract-abi-generator" -version = "0.3.1" +version = "0.4.0" description = "Python bindings for ABI generation from Aleo bytecode" readme = "README.md" license = {text = "GPL-3.0-or-later"} diff --git a/sdk-abi/python/aleo_abi/_aleo_abi.pyi b/sdk-abi/python/aleo_abi/_aleo_abi.pyi index 12bbf12..9849fea 100644 --- a/sdk-abi/python/aleo_abi/_aleo_abi.pyi +++ b/sdk-abi/python/aleo_abi/_aleo_abi.pyi @@ -1,2 +1,9 @@ -def generate_abi(program_name: str, bytecode: str, network: str) -> str: ... +from typing import Optional, Sequence, Tuple + +def generate_abi( + program_name: str, + bytecode: str, + network: str, + imports: Optional[Sequence[Tuple[str, str]]] = None, +) -> str: ... def check_compatibility(candidate_abi_json: str, standard_abi_json: str) -> list[str]: ... diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index 2d9e3d4..7444d41 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "aleo" authors = ["Konstantin Pandl", "Mike Turner", "Roman Proskuryakov"] -version = "0.3.1" +version = "0.4.0" description = "A Python sdk for zero-knowledge cryptography based on Aleo" edition = "2021" license = "GPL-3.0-or-later" diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index 71aabbc..0c9e47c 100644 --- a/sdk/pyproject.toml +++ b/sdk/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "aleo-sdk" description = "Python SDK for building zero-knowledge apps and DeFi on the Aleo network" -version = "0.3.1" +version = "0.4.0" readme = "Readme.md" license = {file = "LICENSE.md"} authors = [ diff --git a/shield-swap-sdk/pyproject.toml b/shield-swap-sdk/pyproject.toml index a3d8eaa..09c6a12 100644 --- a/shield-swap-sdk/pyproject.toml +++ b/shield-swap-sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shield-swap-sdk" -version = "0.3.1" +version = "0.4.0" description = "Python SDK for the shield swap AMM Dex on Aleo" readme = "README.md" requires-python = ">=3.10" diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index 5e520d3..bd899d1 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/__init__.py +++ b/shield-swap-sdk/python/aleo_shield_swap/__init__.py @@ -72,7 +72,7 @@ def agent_guide() -> str: return files(__name__).joinpath("AGENTS.md").read_text() -__version__ = "0.3.1" +__version__ = "0.4.0" __all__ = [ "ShieldSwap", "AsyncShieldSwap", "ApiClient", "AsyncApiClient", diff --git a/shield-swap-sdk/python/aleo_shield_swap/api.py b/shield-swap-sdk/python/aleo_shield_swap/api.py index 92a93a5..6895dcd 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/api.py +++ b/shield-swap-sdk/python/aleo_shield_swap/api.py @@ -213,8 +213,9 @@ def authenticate(self, address: str, sign: Any) -> str: if data.get("token"): # legacy body-JWT deployments self._token = data["token"] return self._token - self._csrf = data["csrf_token"] - return self._csrf + csrf = str(data["csrf_token"]) + self._csrf = csrf + return csrf def set_token(self, token: str) -> None: """Adopt a previously issued JWT.""" @@ -446,8 +447,9 @@ async def authenticate(self, address: str, sign: Any) -> str: if data.get("token"): # legacy body-JWT deployments self._token = data["token"] return self._token - self._csrf = data["csrf_token"] - return self._csrf + csrf = str(data["csrf_token"]) + self._csrf = csrf + return csrf def set_token(self, token: str) -> None: """Adopt a previously issued JWT — see :meth:`ApiClient.set_token`.""" diff --git a/shield-swap-sdk/python/aleo_shield_swap/async_client.py b/shield-swap-sdk/python/aleo_shield_swap/async_client.py index 4ede022..22d16a9 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/async_client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/async_client.py @@ -340,9 +340,11 @@ async def get_balances(self, address: Optional[str] = None, tokens = await self.api.get_tokens() # Spendable private records live in the record-funding program: the # UNDERLYING program for wrapped assets, the ARC-20 itself for plain. - by_program = {t.underlying_program or t.amm_token_program: t - for t in tokens - if t.underlying_program or t.amm_token_program} + by_program: dict[str, Any] = {} + for tok in tokens: + prog = tok.underlying_program or tok.amm_token_program + if prog: + by_program[prog] = tok private = await self.get_private_balances(list(by_program), account=acct) out: dict[str, dict[str, Any]] = {} for bal in await self.api.get_public_balances(addr): diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 1d724a5..68d3969 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -37,6 +37,8 @@ ) from .errors import ( InsufficientRecordsError, + NotAuthenticatedError, + NotRedeemedError, InvalidFeeTierError, PoolNotFoundError, PoolNotInitializedError, @@ -561,9 +563,11 @@ def get_balances(self, address: Optional[str] = None, tokens = self.api.get_tokens() # Spendable private records live in the record-funding program: the # UNDERLYING program for wrapped assets, the ARC-20 itself for plain. - by_program = {t.underlying_program or t.amm_token_program: t - for t in tokens - if t.underlying_program or t.amm_token_program} + by_program: dict[str, Any] = {} + for tok in tokens: + prog = tok.underlying_program or tok.amm_token_program + if prog: + by_program[prog] = tok own_address = str(acct.address) if acct is not None else None private = (self.get_private_balances(list(by_program), account=acct) if addr == own_address else {p: 0 for p in by_program}) @@ -603,7 +607,25 @@ def _ensure(self, token_programs: list[str], pids = [self.program, *token_programs, *(imports or {})] ensure_programs(self._aleo, pids, imports) - def _lp_programs(self, route: Any, program0: str, program1: str, + def _fund_side(self, *, record: Optional[str], program: Optional[str], + token_id: str, min_amount: int, account: Any + ) -> tuple[str, Optional[str]]: + """``(record, program)`` for one side of a liquidity call. + + An explicit *record* needs no program resolution — the caller already + chose what to spend — so the returned program stays whatever they passed, + possibly None. Otherwise the funding program is resolved and a covering + record selected from it. + """ + if record: + return record, program + resolved = program or self._token_program(token_id) + return select_token_record( + self._aleo, program=resolved, min_amount=min_amount, + token_id=token_id, account=account), resolved + + def _lp_programs(self, route: Any, program0: Optional[str], + program1: Optional[str], pool: Any, w0: bool, w1: bool) -> list[str]: """Programs a (possibly routed) LP call touches: both funding programs (None when an explicit record made resolution unnecessary), @@ -915,8 +937,17 @@ def _quote_expected_out(self, *, token_in_id: str, token_out_id: str, The route endpoint returns canonical decimal amounts, the contract takes base units — this converts in both directions using the - token registry's decimals. None (spot fallback) when either token - is unknown or no route is quotable. + token registry's decimals. Returns None when the pool genuinely has no + quotable route, or when either token is missing from the registry. + + Raises: + NotAuthenticatedError: If no DEX session is established. + NotRedeemedError: If the account has not redeemed an invite. + + "Could not ask" is NOT "no route": swallowing an auth failure here + yields a spot-estimate ``amount_out_min`` that ignores the pool fee, so + the caller pays for a proof the finalize then rejects. Auth failures + propagate for that reason. """ from decimal import Decimal dec_in = self._token_decimals(token_in_id) @@ -928,6 +959,8 @@ def _quote_expected_out(self, *, token_in_id: str, token_out_id: str, route = self.api.get_route( token_in=token_in_id, token_out=token_out_id, amount_in=f"{canonical:f}") # fixed-point, never "1E-8" + except (NotAuthenticatedError, NotRedeemedError): + raise except ShieldSwapError: return None # no quotable route if not route.estimated_amount_out: @@ -966,6 +999,18 @@ def swap_many( expected_out = self._quote_expected_out( token_in_id=token_in_id, token_out_id=token_out_id, amount_in=amount_in) + if expected_out is None and slippage_bps < 10_000: + # Falling back to the spot estimate would set amount_out_min above + # what the pool can actually pay (spot ignores the fee), so every + # swap in the batch would be proved, broadcast, and then rejected at + # finalize. Refuse before spending anything. + raise ShieldSwapError( + f"no route quote for {token_in_id} -> {token_out_id}: a spot " + "estimate ignores the pool fee, so the batch would prove and " + "broadcast swaps the finalize rejects. Quote it yourself and " + "pass expected_out, or set slippage_bps=10000 to accept any " + "output." + ) counters = self.journal.reserve_counters(count) program = self._token_program(token_in_id) used_records: set[str] = set() @@ -1207,16 +1252,12 @@ def mint( tick_lower_hint=lo_hint, tick_upper_hint=hi_hint, ).to_plaintext() - program0 = token0_program or ( - None if token0_record else self._token_program(pool.token0)) - program1 = token1_program or ( - None if token1_record else self._token_program(pool.token1)) - record0 = token0_record or select_token_record( - self._aleo, program=program0, - min_amount=amount0_desired, token_id=pool.token0, account=acct) - record1 = token1_record or select_token_record( - self._aleo, program=program1, - min_amount=amount1_desired, token_id=pool.token1, account=acct) + record0, program0 = self._fund_side( + record=token0_record, program=token0_program, + token_id=pool.token0, min_amount=amount0_desired, account=acct) + record1, program1 = self._fund_side( + record=token1_record, program=token1_program, + token_id=pool.token1, min_amount=amount1_desired, account=acct) w0 = self._is_wrapped(str(pool.token0)) w1 = self._is_wrapped(str(pool.token1)) @@ -1293,16 +1334,12 @@ def increase_liquidity( hi_hint = (tick_upper_hint if tick_upper_hint is not None else self.find_tick_predecessor(pool_key, int(decoded["tick_upper"]))) - program0 = token0_program or ( - None if token0_record else self._token_program(pool.token0)) - program1 = token1_program or ( - None if token1_record else self._token_program(pool.token1)) - record0 = token0_record or select_token_record( - self._aleo, program=program0, - min_amount=amount0_desired, token_id=pool.token0, account=acct) - record1 = token1_record or select_token_record( - self._aleo, program=program1, - min_amount=amount1_desired, token_id=pool.token1, account=acct) + record0, program0 = self._fund_side( + record=token0_record, program=token0_program, + token_id=pool.token0, min_amount=amount0_desired, account=acct) + record1, program1 = self._fund_side( + record=token1_record, program=token1_program, + token_id=pool.token1, min_amount=amount1_desired, account=acct) w0 = self._is_wrapped(str(pool.token0)) w1 = self._is_wrapped(str(pool.token1)) diff --git a/shield-swap-sdk/python/aleo_shield_swap/profile.py b/shield-swap-sdk/python/aleo_shield_swap/profile.py index bd39bc7..15ade81 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/profile.py +++ b/shield-swap-sdk/python/aleo_shield_swap/profile.py @@ -67,9 +67,8 @@ class Profile: # Every run after: the same call returns that key, unchanged. assert Profile.load_or_create().address == profile.address - # A second address needs its own home. Pass a resolved path — ``~`` - # is not expanded, so a "~/..." string creates a literal ``~`` dir. - other = Profile.load_or_create(Path.home() / ".shield-swap-alt") + # A second address needs its own home; "~/..." is expanded. + other = Profile.load_or_create("~/.shield-swap-alt") That last call generates a fresh key only when no key is being imported; with ``SHIELD_SWAP_PRIVATE_KEY`` set, every new home adopts that one key @@ -89,10 +88,13 @@ def __repr__(self) -> str: def default_home() -> Path: """Where profiles live by default: ``$SHIELD_SWAP_HOME`` or ``~/.shield-swap``. - Local only — returns the path whether or not it exists. + Local only — returns the path whether or not it exists. A ``~`` in + ``SHIELD_SWAP_HOME`` is expanded, so a value set from a config file that + does not shell-expand still resolves to the home directory rather than a + literal ``~`` folder in the working directory. """ env = os.environ.get("SHIELD_SWAP_HOME") - return Path(env) if env else Path.home() / ".shield-swap" + return Path(env).expanduser() if env else Path.home() / ".shield-swap" @classmethod def load_or_create(cls, home: "Path | str | None" = None, *, @@ -117,7 +119,9 @@ def load_or_create(cls, home: "Path | str | None" = None, *, Returns: The loaded or newly created profile. """ - home = Path(home) if home is not None else cls.default_home() + # expanduser: a "~/..." string is otherwise taken literally, creating a + # directory named ~ in the cwd and writing the private key there. + home = Path(home).expanduser() if home is not None else cls.default_home() path = home / _PROFILE if path.exists(): path.chmod(0o600) # heal a pre-existing loose mode diff --git a/shield-swap-sdk/tests/test_package.py b/shield-swap-sdk/tests/test_package.py index 05c48c4..3601a6a 100644 --- a/shield-swap-sdk/tests/test_package.py +++ b/shield-swap-sdk/tests/test_package.py @@ -2,7 +2,7 @@ def test_version(): - assert aleo_shield_swap.__version__ == "0.3.1" + assert aleo_shield_swap.__version__ == "0.4.0" def test_lifecycle_exports(): diff --git a/shield-swap-sdk/tests/test_profile.py b/shield-swap-sdk/tests/test_profile.py index e14bcb4..68d93ca 100644 --- a/shield-swap-sdk/tests/test_profile.py +++ b/shield-swap-sdk/tests/test_profile.py @@ -59,3 +59,27 @@ def test_existing_key_imported_from_file(tmp_path, monkeypatch): monkeypatch.setenv("SHIELD_SWAP_PRIVATE_KEY_FILE", str(key_file)) p = Profile.load_or_create(tmp_path / "home") assert p.address == str(pk.address) + + +def test_tilde_in_home_is_expanded(tmp_path, monkeypatch): + """A "~/..." string must not create a literal ~ directory in the cwd — + that would write the private key somewhere no later run looks.""" + from pathlib import Path + from aleo_shield_swap.profile import Profile + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.chdir(tmp_path) + profile = Profile.load_or_create("~/.shield-swap-tilde") + assert profile.home == tmp_path / ".shield-swap-tilde" + assert not (tmp_path / "~").exists(), "created a literal ~ directory" + + +def test_tilde_in_shield_swap_home_is_expanded(tmp_path, monkeypatch): + from aleo_shield_swap.profile import Profile + + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("SHIELD_SWAP_HOME", "~/from-env") + monkeypatch.chdir(tmp_path) + assert Profile.default_home() == tmp_path / "from-env" + assert Profile.load_or_create().home == tmp_path / "from-env" + assert not (tmp_path / "~").exists() diff --git a/shield-swap-sdk/tests/test_swap_many.py b/shield-swap-sdk/tests/test_swap_many.py index 355d689..59c085a 100644 --- a/shield-swap-sdk/tests/test_swap_many.py +++ b/shield-swap-sdk/tests/test_swap_many.py @@ -36,8 +36,10 @@ def dex(tmp_path, monkeypatch): monkeypatch.setattr(ShieldSwap, "get_pool", lambda self, key: type("P", (), {"token0": "t0", "token1": "t1"})()) + # A real batch always has a quote; swap_many refuses without one, because + # the spot fallback sets amount_out_min above what the pool can pay. monkeypatch.setattr(ShieldSwap, "_quote_expected_out", - lambda self, **kw: None) + lambda self, **kw: 990_000) monkeypatch.setattr(ShieldSwap, "_token_program", lambda self, token_id: "tok.aleo") return d @@ -166,3 +168,45 @@ def wait_for_transaction(self, tx_id, timeout=180.0): assert len(report.handles) == 3 # one per distinct record assert len(report.failures) == 2 assert all("distinct unspent record" in f["error"] for f in report.failures) + + +# ── Quote failures must not become bad trade parameters ────────────────────── + +def test_swap_many_refuses_without_a_quote(dex, monkeypatch): + """A missing quote would set amount_out_min above what the pool can pay, so + every swap would be proved, broadcast and rejected. Refuse first.""" + from aleo_shield_swap.errors import ShieldSwapError + + monkeypatch.setattr(ShieldSwap, "_quote_expected_out", lambda self, **kw: None) + with pytest.raises(ShieldSwapError, match="no route quote"): + dex.swap_many(pool_key="5field", token_in_id="t0", + amount_in=10**6, count=3) + assert dex.journal.counter_cursor() == 0 # nothing reserved or spent + + +def test_swap_many_allows_no_quote_at_full_slippage(dex, monkeypatch): + """slippage_bps=10000 means "accept any output", so no quote is needed.""" + monkeypatch.setattr(ShieldSwap, "_quote_expected_out", lambda self, **kw: None) + dex.swap_many(pool_key="5field", token_in_id="t0", amount_in=10**6, + count=1, slippage_bps=10_000) + assert dex.journal.counter_cursor() == 1 # it proceeded + + +def test_quote_propagates_auth_failure(): + """'could not ask' is not 'no route' — surfacing beats a bad min-out. + + Built outside the ``dex`` fixture on purpose: that fixture patches + ``_quote_expected_out`` on the class, which would mask the real method. + """ + from aleo_shield_swap.errors import NotAuthenticatedError + + class _Api: + def get_route(self, **_): + raise NotAuthenticatedError("no session") + + fresh = ShieldSwap(_Facade()) + fresh.api = _Api() + fresh._token_decimals = lambda _tid: 6 # registry known, route not askable + with pytest.raises(NotAuthenticatedError): + fresh._quote_expected_out(token_in_id="t0", token_out_id="t1", + amount_in=10**6) From 12d5dcfe950d3c7c5394466bae97566c9fd8ba32 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 12:03:36 -0500 Subject: [PATCH 12/15] fix(shield-swap): quote the route in canonical amounts, not base units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- shield-swap-sdk/tests/integration/test_reads_live.py | 6 ++++-- .../tests/integration/test_swap_lifecycle.py | 10 ++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/shield-swap-sdk/tests/integration/test_reads_live.py b/shield-swap-sdk/tests/integration/test_reads_live.py index 837f232..926aabb 100644 --- a/shield-swap-sdk/tests/integration/test_reads_live.py +++ b/shield-swap-sdk/tests/integration/test_reads_live.py @@ -62,9 +62,11 @@ def test_api_get_tokens(live_dex_module): def test_api_get_route_quotes_both_directions(live_dex_module, pool): - scale = 10 ** (pool.token0_info.decimals if pool.token0_info else 6) + # amount_in is a CANONICAL decimal amount — "1" means one whole token, not + # 10**decimals base units. Passing base units quotes a trade 10**decimals + # too large and returns a price from deep in the book. fwd = skip_if_access_gated(lambda: live_dex_module.api.get_route( - token_in=pool.token0, token_out=pool.token1, amount_in=scale)) + token_in=pool.token0, token_out=pool.token1, amount_in="1")) assert fwd.token_in == pool.token0 and fwd.token_out == pool.token1 assert fwd.hops, "route has no hops" rev = live_dex_module.api.get_route( diff --git a/shield-swap-sdk/tests/integration/test_swap_lifecycle.py b/shield-swap-sdk/tests/integration/test_swap_lifecycle.py index a4968af..5484bd8 100644 --- a/shield-swap-sdk/tests/integration/test_swap_lifecycle.py +++ b/shield-swap-sdk/tests/integration/test_swap_lifecycle.py @@ -67,10 +67,12 @@ def test_private_swap_roundtrip(): if amount_in == 0: pytest.skip(f"account holds no private {program_in} records to swap") - route = dex.api.get_route(token_in=token_in, token_out=pool.token1, - amount_in=amount_in) - expected = (int(float(route.estimated_amount_out) * 10 ** pool.token1_info.decimals) - if route.estimated_amount_out else None) + # Use the SDK's own quote conversion rather than reimplementing it: the + # route endpoint takes a CANONICAL decimal amount, so passing raw base + # units quotes a trade 10**decimals too large and yields an + # amount_out_min the pool cannot pay — proved, broadcast, rejected. + expected = dex._quote_expected_out( + token_in_id=token_in, token_out_id=pool.token1, amount_in=amount_in) handle = _with_retry(lambda: dex.swap( pool_key=pool.key, token_in_id=token_in, amount_in=amount_in, From 7a60a52b81071437d688057f45ba8916e6adbc59 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 12:56:45 -0500 Subject: [PATCH 13/15] =?UTF-8?q?fix(shield-swap):=20address=20self-review?= =?UTF-8?q?=20=E2=80=94=20async=20parity,=20build-time=20cost,=20dead=20co?= =?UTF-8?q?de?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- shield-swap-sdk/AGENTS.md | 14 +- .../python/aleo_shield_swap/AGENTS.md | 14 +- .../python/aleo_shield_swap/_core.py | 22 ++++ .../python/aleo_shield_swap/async_client.py | 120 +++++++++++++++++- .../python/aleo_shield_swap/client.py | 48 ++++--- shield-swap-sdk/tests/test_async_parity.py | 40 ++++++ shield-swap-sdk/tests/test_owned_positions.py | 28 ++++ shield-swap-sdk/tests/test_position_math.py | 6 + shield-swap-sdk/tests/test_swap.py | 22 ++++ 9 files changed, 280 insertions(+), 34 deletions(-) create mode 100644 shield-swap-sdk/tests/test_async_parity.py diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index ab02062..bf3b916 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -351,12 +351,14 @@ process might die before the claim. Quote first (``dex.api.get_route``) and pass *expected_out*: without it a spot estimate is used, which ignores fees and price impact. -On a profile-bound client the blinding counter is reserved from the -journal and the resulting handle is recorded once the broadcast is -accepted, so concurrent swaps cannot collide and a crash before the -claim does not lose the secret. Pass ``track=False`` to opt out (the -counter then comes from an on-chain probe, which races), or *identity* -to supply your own. Without a journal the probe is all there is. +**Building is not free with a journal.** The blinded address is a +transition input, so a counter is reserved *here*, not at the terminal +method — discarding the call, or only simulating, still spends it. That +reservation is what makes concurrent swaps safe: it serializes under a +file lock where the probe it replaces could hand two callers the same +counter. The handle is journaled once the broadcast is accepted, so a +crash before the claim keeps the blinding factor. ``track=False`` builds +on the racing probe instead; *identity* supplies your own. The default *deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- diff --git a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md index ab02062..bf3b916 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md +++ b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md @@ -351,12 +351,14 @@ process might die before the claim. Quote first (``dex.api.get_route``) and pass *expected_out*: without it a spot estimate is used, which ignores fees and price impact. -On a profile-bound client the blinding counter is reserved from the -journal and the resulting handle is recorded once the broadcast is -accepted, so concurrent swaps cannot collide and a crash before the -claim does not lose the secret. Pass ``track=False`` to opt out (the -counter then comes from an on-chain probe, which races), or *identity* -to supply your own. Without a journal the probe is all there is. +**Building is not free with a journal.** The blinded address is a +transition input, so a counter is reserved *here*, not at the terminal +method — discarding the call, or only simulating, still spends it. That +reservation is what makes concurrent swaps safe: it serializes under a +file lock where the probe it replaces could hand two callers the same +counter. The handle is journaled once the broadcast is accepted, so a +crash before the claim keeps the blinding factor. ``track=False`` builds +on the racing probe instead; *identity* supplies your own. The default *deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- diff --git a/shield-swap-sdk/python/aleo_shield_swap/_core.py b/shield-swap-sdk/python/aleo_shield_swap/_core.py index c5b2a0c..908857e 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_core.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_core.py @@ -165,6 +165,28 @@ def record_plaintext(rec: Any) -> Optional[str]: return getattr(rec, "record_plaintext", None) +#: 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") + + +def decode_position_record(plaintext: str) -> Optional[dict[str, Any]]: + """A PositionNFT record's fields, or None if *plaintext* is not one. + + Shared by both clients so the record shape is defined once. Returns None + rather than raising for a record of any other type, letting a mixed record + set be filtered in one pass. + """ + try: + decoded = parse_plaintext(plaintext) + except (ValueError, TypeError): + return None + if not isinstance(decoded, dict): + return None + return decoded if all(f in decoded for f in POSITION_RECORD_FIELDS) else None + + def find_position_plaintext(records: Any, pool_key: str) -> Optional[str]: """First unspent PositionNFT plaintext whose ``pool`` matches, or None.""" for rec in records: diff --git a/shield-swap-sdk/python/aleo_shield_swap/async_client.py b/shield-swap-sdk/python/aleo_shield_swap/async_client.py index 22d16a9..1d136d3 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/async_client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/async_client.py @@ -12,6 +12,7 @@ from . import _generated as g from ._calls import extract_tx_id, root_outputs from ._core import ( + decode_position_record, default_merkle_proofs, find_position_plaintext, normalize_mapping_value, @@ -19,12 +20,19 @@ parse_token_record_info, pick_covering_record, program_imports, + record_plaintext, register_program_sources, resolve_swap_params, ) from ._routing import claim_route, swap_route +from .position_math import ( + amounts_for_liquidity, + fee_growth_inside, + fee_owed, + u256_of, +) from .api import AsyncApiClient, api_url_for -from .tick_math import int_to_u256_plaintext +from .tick_math import get_sqrt_price_at_tick_x128, int_to_u256_plaintext from .derivations import ( BlindedIdentity, derive_blinded_address, @@ -38,7 +46,13 @@ PoolNotInitializedError, SwapOutputNotFinalizedError, ) -from .types import ClaimResult, SlotView, SwapHandle +from .types import ( + ClaimResult, + OwnedPosition, + OwnedPositionState, + SlotView, + SwapHandle, +) R = TypeVar("R") @@ -304,6 +318,100 @@ def _account(self, account: Any = None) -> Any: # ── Balances ───────────────────────────────────────────────────────────── + # ── Owned positions ────────────────────────────────────────────────────── + + async def _tick_info(self, pool_key: str, tick: int) -> "Optional[g.Tick]": + """The on-chain ``Tick`` entry for *tick*, or None if uninitialized.""" + raw = await self._mapping_value("ticks", self.derive_tick_key(pool_key, tick)) + return g.Tick.from_plaintext(raw) if raw is not None else None + + async def _owned_position_state(self, pool_key: str, position: Any, + slot: Any) -> Optional[OwnedPositionState]: + """Async mirror of ``ShieldSwap._owned_position_state``. + + *slot* is passed in so one slot read serves every position in a pool. + """ + lower = await self._tick_info(pool_key, int(position.tick_lower)) + upper = await self._tick_info(pool_key, int(position.tick_upper)) + if lower is None or upper is None: + return None + + liquidity = int(position.liquidity) + amount0, amount1 = amounts_for_liquidity( + u256_of(slot.sqrt_price), + get_sqrt_price_at_tick_x128(int(position.tick_lower)), + get_sqrt_price_at_tick_x128(int(position.tick_upper)), + liquidity, + ) + inside0, inside1 = fee_growth_inside( + (u256_of(lower.fee_growth_outside0_x_128), + u256_of(lower.fee_growth_outside1_x_128)), int(lower.tick), + (u256_of(upper.fee_growth_outside0_x_128), + u256_of(upper.fee_growth_outside1_x_128)), int(upper.tick), + int(slot.tick), + (u256_of(slot.fee_growth_global0_x_128), + u256_of(slot.fee_growth_global1_x_128)), + ) + owed0, owed1 = int(position.tokens_owed0), int(position.tokens_owed1) + return OwnedPositionState( + liquidity=liquidity, + amount0=amount0, amount1=amount1, + collectible0=owed0 + fee_owed( + inside0, u256_of(position.fee_growth_inside0_last_x_128), liquidity), + collectible1=owed1 + fee_owed( + inside1, u256_of(position.fee_growth_inside1_last_x_128), liquidity), + tokens_owed0=owed0, tokens_owed1=owed1, + ) + + async def get_owned_positions(self, *, pool_key: Optional[str] = None, + account: Any = None) -> list[OwnedPosition]: + """Every position this account holds, joined with live chain state. + + Async mirror of :meth:`ShieldSwap.get_owned_positions` — same reads, same + ``state is None`` semantics while a mint finalizes. + """ + acct = self._account(account) + records = self._aleo.record_provider.find( + acct, program=self.program, unspent=True) + out: list[OwnedPosition] = [] + slots: dict[str, Any] = {} # one slot read per pool, not per position + for rec in records: + plaintext = record_plaintext(rec) + if not plaintext: + continue + decoded = decode_position_record(plaintext) + if decoded is None: + continue + pool = str(decoded["pool"]) + if pool_key is not None and pool != pool_key: + continue + token_id = str(decoded["token_id"]) + raw = await self._mapping_value("positions", token_id) + state = None + if raw is not None: + if pool not in slots: + slots[pool] = (await self.get_slot(pool)).raw + state = await self._owned_position_state( + pool, g.Position.from_plaintext(raw), slots[pool]) + out.append(OwnedPosition( + position_token_id=token_id, pool_key=pool, + tick_lower=int(decoded["tick_lower"]), + tick_upper=int(decoded["tick_upper"]), + token0_id=str(decoded["token0_id"]), + token1_id=str(decoded["token1_id"]), + withdrawal=str(decoded["withdrawal"]), + record=plaintext, state=state)) + return out + + async def get_owned_position(self, position_token_id: str, *, + account: Any = None) -> Optional[OwnedPosition]: + """One owned position by token id, or None — see + :meth:`ShieldSwap.get_owned_position`.""" + for owned in await self.get_owned_positions(account=account): + if owned.position_token_id == position_token_id: + return owned + return None + async def get_private_balances(self, programs: list[str], account: Any = None) -> dict[str, int]: """Sum of unspent record amounts per wrapper program (spendable @@ -390,8 +498,12 @@ async def swap(self, *, pool_key: str, token_in_id: str, amount_in: int, Quote first (``dex.api.get_route``) and pass *expected_out*: without it a spot estimate is used, which ignores fees and price impact. - Pass *identity* (from journal-reserved counters) to skip the - on-chain probe — required for concurrent swaps. The default + **This client has no journal**, so it cannot reserve blinding counters: + the identity comes from an on-chain probe, and the probe is not atomic — + two concurrent async swaps derive the same counter and the second reverts + at finalize. Pass *identity* explicitly (from a sync client's + ``journal.reserve_counters``) for anything concurrent. The sync + :meth:`ShieldSwap.swap` reserves and journals automatically. The default *deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- proving latency; a tight deadline aborts at finalize when proving outlives it. diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 68d3969..2c39544 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -14,6 +14,7 @@ from . import _generated as g from ._core import ( + decode_position_record, default_merkle_proofs, ensure_programs, find_position_plaintext, @@ -381,13 +382,15 @@ def _tick_info(self, pool_key: str, tick: int) -> "Optional[g.Tick]": return g.Tick.from_plaintext(raw) if raw is not None else None def _owned_position_state(self, pool_key: str, position: "g.Position", - ) -> Optional[OwnedPositionState]: + slot: Any) -> Optional[OwnedPositionState]: """Join a position against the pool's slot and its two boundary ticks. + *slot* is passed in rather than read here so one slot read serves every + position in the same pool. + Returns None when either boundary tick is missing, which means the range is not initialized on chain and no amounts can be derived. """ - slot = self.get_slot(pool_key).raw lower = self._tick_info(pool_key, int(position.tick_lower)) upper = self._tick_info(pool_key, int(position.tick_upper)) if lower is None or upper is None: @@ -420,23 +423,29 @@ def _owned_position_state(self, pool_key: str, position: "g.Position", tokens_owed0=owed0, tokens_owed1=owed1, ) - def _owned_from_record(self, plaintext: str) -> Optional[OwnedPosition]: + def _owned_from_record(self, plaintext: str, + slots: Optional[dict[str, Any]] = None + ) -> Optional[OwnedPosition]: """Build an :class:`OwnedPosition` from one PositionNFT record plaintext. Returns None for a record that is not a PositionNFT, so a mixed record - set from the program can be filtered in one pass. + set from the program can be filtered in one pass. *slots* is an + optional per-call cache of ``pool_key -> slot`` so a batch of positions + in one pool costs one slot read. """ - try: - decoded = parse_plaintext(plaintext) - except (ValueError, TypeError): - return None - if not isinstance(decoded, dict) or "tick_lower" not in decoded: + decoded = decode_position_record(plaintext) + if decoded is None: return None token_id = str(decoded["token_id"]) pool_key = str(decoded["pool"]) raw = self._mapping_value("positions", token_id) - state = (self._owned_position_state(pool_key, g.Position.from_plaintext(raw)) - if raw is not None else None) + state = None + if raw is not None: + cache = slots if slots is not None else {} + if pool_key not in cache: + cache[pool_key] = self.get_slot(pool_key).raw + state = self._owned_position_state( + pool_key, g.Position.from_plaintext(raw), cache[pool_key]) return OwnedPosition( position_token_id=token_id, pool_key=pool_key, @@ -473,11 +482,12 @@ def get_owned_positions(self, *, pool_key: Optional[str] = None, records = self._aleo.record_provider.find( acct, program=self.program, unspent=True) out: list[OwnedPosition] = [] + slots: dict[str, Any] = {} # one slot read per pool, not per position for rec in records: plaintext = record_plaintext(rec) if not plaintext: continue - owned = self._owned_from_record(plaintext) + owned = self._owned_from_record(plaintext, slots) if owned is None: continue if pool_key is not None and owned.pool_key != pool_key: @@ -674,12 +684,14 @@ def swap( Quote first (``dex.api.get_route``) and pass *expected_out*: without it a spot estimate is used, which ignores fees and price impact. - On a profile-bound client the blinding counter is reserved from the - journal and the resulting handle is recorded once the broadcast is - accepted, so concurrent swaps cannot collide and a crash before the - claim does not lose the secret. Pass ``track=False`` to opt out (the - counter then comes from an on-chain probe, which races), or *identity* - to supply your own. Without a journal the probe is all there is. + **Building is not free with a journal.** The blinded address is a + transition input, so a counter is reserved *here*, not at the terminal + method — discarding the call, or only simulating, still spends it. That + reservation is what makes concurrent swaps safe: it serializes under a + file lock where the probe it replaces could hand two callers the same + counter. The handle is journaled once the broadcast is accepted, so a + crash before the claim keeps the blinding factor. ``track=False`` builds + on the racing probe instead; *identity* supplies your own. The default *deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- diff --git a/shield-swap-sdk/tests/test_async_parity.py b/shield-swap-sdk/tests/test_async_parity.py new file mode 100644 index 0000000..e92473e --- /dev/null +++ b/shield-swap-sdk/tests/test_async_parity.py @@ -0,0 +1,40 @@ +"""Sync/async surface parity. + +``sdk/AGENTS.md``: "Every HTTP client ships both … never duplicate +orchestration." A read method that exists on one client and not the other is a +gap, so this fails rather than letting the surfaces drift silently. +""" +from __future__ import annotations + +from aleo_shield_swap.async_client import AsyncShieldSwap +from aleo_shield_swap.client import ShieldSwap + +#: Sync-only by decision, not oversight — each needs a journal or the LP router +#: surface the async client does not carry yet. +SYNC_ONLY = { + "from_profile", "onboard", "status", # profile/journal bound + "swap_many", "collect_all", # require a journal + "create_pool", "mint", "increase_liquidity", # LP surface + "decrease_liquidity", "collect", "burn", + "get_positions", "find_tick_predecessor", +} + + +def test_every_public_read_exists_on_both_clients(): + sync = {n for n in vars(ShieldSwap) if not n.startswith("_")} + asyn = {n for n in vars(AsyncShieldSwap) if not n.startswith("_")} + missing = sync - asyn - SYNC_ONLY + assert not missing, f"present on ShieldSwap but not AsyncShieldSwap: {sorted(missing)}" + + +def test_owned_position_views_are_on_both(): + for name in ("get_owned_positions", "get_owned_position"): + assert hasattr(ShieldSwap, name) + assert hasattr(AsyncShieldSwap, name) + + +def test_sync_only_list_has_no_stale_entries(): + """Keep SYNC_ONLY honest: an entry that async has gained should be removed.""" + asyn = {n for n in vars(AsyncShieldSwap) if not n.startswith("_")} + stale = SYNC_ONLY & asyn + assert not stale, f"async now has these — drop from SYNC_ONLY: {sorted(stale)}" diff --git a/shield-swap-sdk/tests/test_owned_positions.py b/shield-swap-sdk/tests/test_owned_positions.py index 2c0a1f2..60dd2c5 100644 --- a/shield-swap-sdk/tests/test_owned_positions.py +++ b/shield-swap-sdk/tests/test_owned_positions.py @@ -144,3 +144,31 @@ def test_fee_owed_contribution_is_reproducible(): # with all outside/last counters zero, inside growth == global growth slot = dex.get_slot("5field").raw assert accrued0 == fee_owed(u256_of(slot.fee_growth_global0_x_128), 0, 500) + + +def test_one_slot_read_per_pool_not_per_position(): + """Ten positions in one pool must not cost ten slot reads.""" + records = [{"record_plaintext": POSITION_RECORD} for _ in range(10)] + stub = _stub(records=records) + dex = ShieldSwap(stub) + calls = {"n": 0} + real = dex.get_slot + + def counting(pool_key): + calls["n"] += 1 + return real(pool_key) + + dex.get_slot = counting + assert len(dex.get_owned_positions()) == 10 + assert calls["n"] == 1, f"slot read {calls['n']} times for one pool" + + +def test_a_record_sharing_one_field_is_not_a_position(): + """Detection checks the whole PositionNFT field set, so a future record + type that happens to carry tick_lower is not misread as a position.""" + impostor = ("{ owner: aleo1me.private, tick_lower: -60i32.private, " + "amount: 5u128.private, _nonce: 9group.public }") + stub = _stub(records=[{"record_plaintext": impostor}, + {"record_plaintext": POSITION_RECORD}]) + owned = ShieldSwap(stub).get_owned_positions() + assert [p.position_token_id for p in owned] == ["42field"] diff --git a/shield-swap-sdk/tests/test_position_math.py b/shield-swap-sdk/tests/test_position_math.py index f594cd5..9a7b341 100644 --- a/shield-swap-sdk/tests/test_position_math.py +++ b/shield-swap-sdk/tests/test_position_math.py @@ -154,3 +154,9 @@ class _S: hi, lo = 3, 5 assert u256_of(_S()) == _u256(3, 5) assert u256_of(42) == 42 + + +def test_zero_sqrt_price_raises_rather_than_silently_returning(): + """A zero bound divides by zero in the contract too — surface it.""" + with pytest.raises(ZeroDivisionError): + amount0_delta(0, 4 * Q128, 10**6) diff --git a/shield-swap-sdk/tests/test_swap.py b/shield-swap-sdk/tests/test_swap.py index a818096..1d7f40e 100644 --- a/shield-swap-sdk/tests/test_swap.py +++ b/shield-swap-sdk/tests/test_swap.py @@ -201,3 +201,25 @@ def test_without_a_journal_swap_still_works(stub_aleo): dex = ShieldSwap(stub_aleo) assert dex.journal is None assert _swap_call_on(dex).transact().blinded_address + + +def test_building_a_call_reserves_even_if_never_executed(tmp_path, stub_aleo): + """Documented cost of tracking: the blinded address is a transition input, + so the counter is spent at build time, not at the terminal method.""" + dex = _journalled_dex(tmp_path, stub_aleo) + _swap_call_on(dex) # built, never transacted + assert dex.journal.counter_cursor() == 1 + + +def test_simulate_also_spends_a_counter(tmp_path, stub_aleo): + dex = _journalled_dex(tmp_path, stub_aleo) + _swap_call_on(dex).simulate() + assert dex.journal.counter_cursor() == 1 + assert dex.journal.pending_claims() == [] # nothing broadcast, nothing to claim + + +def test_track_false_gives_a_side_effect_free_build(tmp_path, stub_aleo): + dex = _journalled_dex(tmp_path, stub_aleo) + _swap_call_on(dex, track=False) + assert dex.journal.counter_cursor() == 0 + assert dex.journal.events() == [] From ccf0e49ad4f8fd573130f533981c9daad5b7cd4e Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 21:01:15 -0500 Subject: [PATCH 14/15] fix(shield-swap): await the async record scan; both Copilot findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../python/aleo_shield_swap/async_client.py | 2 +- shield-swap-sdk/tests/test_lifecycle.py | 2 +- .../tests/test_owned_positions_async.py | 123 ++++++++++++++++++ 3 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 shield-swap-sdk/tests/test_owned_positions_async.py diff --git a/shield-swap-sdk/python/aleo_shield_swap/async_client.py b/shield-swap-sdk/python/aleo_shield_swap/async_client.py index 1d136d3..255142d 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/async_client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/async_client.py @@ -371,7 +371,7 @@ async def get_owned_positions(self, *, pool_key: Optional[str] = None, ``state is None`` semantics while a mint finalizes. """ acct = self._account(account) - records = self._aleo.record_provider.find( + records = await self._aleo.record_provider.find( acct, program=self.program, unspent=True) out: list[OwnedPosition] = [] slots: dict[str, Any] = {} # one slot read per pool, not per position diff --git a/shield-swap-sdk/tests/test_lifecycle.py b/shield-swap-sdk/tests/test_lifecycle.py index 21939fe..f769597 100644 --- a/shield-swap-sdk/tests/test_lifecycle.py +++ b/shield-swap-sdk/tests/test_lifecycle.py @@ -174,8 +174,8 @@ def _refresh_credentials(self): def test_airdrop_stage_refuses_on_mainnet(tmp_path): - tmp_journal = tmp_path / "j.jsonl" """The faucet endpoints are testnet-only; on mainnet say so, don't 404.""" + tmp_journal = tmp_path / "j.jsonl" from aleo_shield_swap.errors import NotFundedError from aleo_shield_swap.lifecycle import _Ctx, _airdrop_run diff --git a/shield-swap-sdk/tests/test_owned_positions_async.py b/shield-swap-sdk/tests/test_owned_positions_async.py new file mode 100644 index 0000000..70dc7da --- /dev/null +++ b/shield-swap-sdk/tests/test_owned_positions_async.py @@ -0,0 +1,123 @@ +"""AsyncShieldSwap owned-position views, exercised rather than introspected. + +The parity test asserts these methods *exist*; this one asserts they *run*. +That distinction matters: the first version of the async view failed to await +record_provider.find (which is `async def` on the async facade), so it returned +a coroutine and raised TypeError on iteration — invisible to a hasattr check. +""" +from __future__ import annotations + +import pytest + +from aleo_shield_swap.async_client import AsyncShieldSwap + +from .test_owned_positions import ( + POSITION_ENTRY, + POSITION_RECORD, + TOKEN_RECORD, + _tick, +) +from .conftest import POOL_TEXT, SLOT_TEXT + +pytestmark = pytest.mark.asyncio + + +class _AsyncProvider: + """Mirrors the async facade: find() is a coroutine.""" + + def __init__(self, records): + self._records = records + self.calls = 0 + + async def find(self, account=None, *, program=None, unspent=True, **_): + self.calls += 1 + return list(self._records) + + +class _AsyncMapping: + def __init__(self, values): + self._values = values + + async def get(self, key): + return self._values.get(str(key)) + + +class _AsyncProgram: + def __init__(self, mappings): + self._mappings = mappings + + def mapping(self, name): + return _AsyncMapping(self._mappings.get(name, {})) + + +class _AsyncPrograms: + def __init__(self, mappings): + self._mappings = mappings + + async def get(self, program_id, edition=None): + return _AsyncProgram(self._mappings) + + +class _AsyncFacade: + network_name = "testnet" + + def __init__(self, mappings, records): + self.programs = _AsyncPrograms(mappings) + self.record_provider = _AsyncProvider(records) + self.default_account = object() + + +def _dex(*, positions=None, records=None): + mappings = { + "pools": {"5field": POOL_TEXT}, + "slots": {"5field": SLOT_TEXT}, + "positions": positions if positions is not None else {"42field": POSITION_ENTRY}, + } + facade = _AsyncFacade(mappings, records if records is not None + else [{"record_plaintext": POSITION_RECORD}]) + dex = AsyncShieldSwap(facade) + mappings["ticks"] = { + dex.derive_tick_key("5field", -4080): _tick("5field", -4080), + dex.derive_tick_key("5field", 4080): _tick("5field", 4080), + } + return dex + + +async def test_async_join_matches_the_sync_shape(): + owned = await _dex().get_owned_positions() + assert len(owned) == 1 + p = owned[0] + assert p.position_token_id == "42field" + assert (p.pool_key, p.tick_lower, p.tick_upper) == ("5field", -4080, 4080) + assert p.withdrawal == "aleo1payout" + assert p.state is not None and p.state.liquidity == 500 + assert (p.state.tokens_owed0, p.state.tokens_owed1) == (11, 22) + + +async def test_async_state_none_while_finalizing(): + owned = await _dex(positions={}).get_owned_positions() + assert owned[0].state is None + + +async def test_async_skips_non_position_records(): + dex = _dex(records=[{"record_plaintext": TOKEN_RECORD}, + {"record_plaintext": POSITION_RECORD}]) + owned = await dex.get_owned_positions() + assert [p.position_token_id for p in owned] == ["42field"] + + +async def test_async_pool_filter(): + dex = _dex() + assert len(await dex.get_owned_positions(pool_key="5field")) == 1 + assert await dex.get_owned_positions(pool_key="9field") == [] + + +async def test_async_get_owned_position_by_id(): + dex = _dex() + got = await dex.get_owned_position("42field") + assert got is not None and got.position_token_id == "42field" + assert await dex.get_owned_position("999field") is None + + +async def test_async_no_records_is_empty(): + assert await _dex(records=[]).get_owned_positions() == [] From 34392265ffcbcbc25cac48fc59c8fe1705945c89 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 5 Aug 2026 21:08:42 -0500 Subject: [PATCH 15/15] fix(shield-swap): swap_many takes expected_out; share the position decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- shield-swap-sdk/AGENTS.md | 7 ++++++- .../python/aleo_shield_swap/AGENTS.md | 7 ++++++- .../python/aleo_shield_swap/_core.py | 13 +++++++------ .../python/aleo_shield_swap/client.py | 18 +++++++++++++----- shield-swap-sdk/tests/test_core.py | 13 +++++++++++++ shield-swap-sdk/tests/test_gen_context.py | 9 ++++++--- shield-swap-sdk/tests/test_swap_many.py | 16 ++++++++++++++++ 7 files changed, 67 insertions(+), 16 deletions(-) diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index bf3b916..c770ec6 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -66,7 +66,7 @@ The scan catches positions the journal never saw (account used from another machine, journal lost); it needs a registered record provider and is skipped silently without one. -### `swap_many(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int', count: 'int', slippage_bps: 'int' = 50, record_wait_seconds: 'float' = 120.0, account: 'Any' = None) -> 'SwapBatchReport'` +### `swap_many(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int', count: 'int', slippage_bps: 'int' = 50, expected_out: 'Optional[int]' = None, record_wait_seconds: 'float' = 120.0, account: 'Any' = None) -> 'SwapBatchReport'` *count* private swaps of *amount_in* each, with reserved counters. @@ -78,6 +78,11 @@ becomes claimable (it stays in ``still_pending``). A failed broadcast burns its counter and the batch continues; failures are reported, not raised. Requires ``from_profile()``. +*expected_out* (base units) skips the route quote. Without it the batch +quotes once and refuses rather than falling back to a spot estimate, +which ignores the pool fee and would revert every swap after paying for +its proof. + ### `collect_all(self, account: 'Any' = None) -> 'CollectReport'` Claim every finalized swap and collect owed fees on open positions. diff --git a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md index bf3b916..c770ec6 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md +++ b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md @@ -66,7 +66,7 @@ The scan catches positions the journal never saw (account used from another machine, journal lost); it needs a registered record provider and is skipped silently without one. -### `swap_many(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int', count: 'int', slippage_bps: 'int' = 50, record_wait_seconds: 'float' = 120.0, account: 'Any' = None) -> 'SwapBatchReport'` +### `swap_many(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int', count: 'int', slippage_bps: 'int' = 50, expected_out: 'Optional[int]' = None, record_wait_seconds: 'float' = 120.0, account: 'Any' = None) -> 'SwapBatchReport'` *count* private swaps of *amount_in* each, with reserved counters. @@ -78,6 +78,11 @@ becomes claimable (it stays in ``still_pending``). A failed broadcast burns its counter and the batch continues; failures are reported, not raised. Requires ``from_profile()``. +*expected_out* (base units) skips the route quote. Without it the batch +quotes once and refuses rather than falling back to a spot estimate, +which ignores the pool fee and would revert every swap after paying for +its proof. + ### `collect_all(self, account: 'Any' = None) -> 'CollectReport'` Claim every finalized swap and collect owed fees on open positions. diff --git a/shield-swap-sdk/python/aleo_shield_swap/_core.py b/shield-swap-sdk/python/aleo_shield_swap/_core.py index 908857e..1505dee 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_core.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_core.py @@ -188,16 +188,17 @@ def decode_position_record(plaintext: str) -> Optional[dict[str, Any]]: def find_position_plaintext(records: Any, pool_key: str) -> Optional[str]: - """First unspent PositionNFT plaintext whose ``pool`` matches, or None.""" + """First unspent PositionNFT plaintext whose ``pool`` matches, or None. + + Uses :func:`decode_position_record`, so a record of another type that + happens to carry a matching ``pool`` field is not returned as a position. + """ for rec in records: plaintext = record_plaintext(rec) if not plaintext: continue - try: - decoded = parse_plaintext(plaintext) - except (ValueError, TypeError): - continue - if isinstance(decoded, dict) and decoded.get("pool") == pool_key: + decoded = decode_position_record(plaintext) + if decoded is not None and decoded.get("pool") == pool_key: return plaintext return None diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 2c39544..09c2f26 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -987,6 +987,7 @@ def swap_many( amount_in: int, count: int, slippage_bps: int = 50, + expected_out: Optional[int] = None, record_wait_seconds: float = 120.0, account: Any = None, ) -> SwapBatchReport: @@ -999,18 +1000,25 @@ def swap_many( becomes claimable (it stays in ``still_pending``). A failed broadcast burns its counter and the batch continues; failures are reported, not raised. Requires ``from_profile()``. + + *expected_out* (base units) skips the route quote. Without it the batch + quotes once and refuses rather than falling back to a spot estimate, + which ignores the pool fee and would revert every swap after paying for + its proof. """ if self.journal is None: raise ValueError("swap_many() needs a journal — construct with " "ShieldSwap.from_profile().") acct = self._account(account) - # Quote once for the batch: a spot estimate ignores the pool fee, so - # min-out would exceed the real output and finalize would reject. + # One quote for the whole batch unless the caller supplied one: a spot + # estimate ignores the pool fee, so min-out would exceed the real output + # and finalize would reject. pool = self.get_pool(pool_key) token_out_id = pool.token1 if token_in_id == pool.token0 else pool.token0 - expected_out = self._quote_expected_out( - token_in_id=token_in_id, token_out_id=token_out_id, - amount_in=amount_in) + if expected_out is None: + expected_out = self._quote_expected_out( + token_in_id=token_in_id, token_out_id=token_out_id, + amount_in=amount_in) if expected_out is None and slippage_bps < 10_000: # Falling back to the spot estimate would set amount_out_min above # what the pool can actually pay (spot ignores the fee), so every diff --git a/shield-swap-sdk/tests/test_core.py b/shield-swap-sdk/tests/test_core.py index 8338057..b6dae01 100644 --- a/shield-swap-sdk/tests/test_core.py +++ b/shield-swap-sdk/tests/test_core.py @@ -178,3 +178,16 @@ def test_extract_tx_id_handles_all_dps_shapes(): "execution": {}}}) == "at1c" with pytest.raises(ValueError, match="Cannot find"): extract_tx_id({"transaction": {"type": "execute"}}) + + +def test_find_position_plaintext_rejects_a_lookalike_record(): + """A non-PositionNFT record carrying a matching `pool` must not be returned + as a position — it would be spent as one.""" + from aleo_shield_swap._core import find_position_plaintext + lookalike = "{ owner: aleo1x.private, pool: 5field.private, amount: 9u128.private }" + position = ("{ owner: aleo1x.private, withdrawal: aleo1y.private, " + "token_id: 1field.private, token0_id: 2field.private, " + "token1_id: 3field.private, pool: 5field.private, " + "tick_lower: -60i32.private, tick_upper: 60i32.private }") + recs = [{"record_plaintext": lookalike}, {"record_plaintext": position}] + assert find_position_plaintext(recs, "5field") == position diff --git a/shield-swap-sdk/tests/test_gen_context.py b/shield-swap-sdk/tests/test_gen_context.py index 24d2c7e..2dd66d9 100644 --- a/shield-swap-sdk/tests/test_gen_context.py +++ b/shield-swap-sdk/tests/test_gen_context.py @@ -42,8 +42,11 @@ def test_committed_page_is_current(): def test_page_stays_compact(): - # ~6k tokens — cheap context, enforced. Raised 20k → 22k with the + # ~6.5k tokens — cheap context, enforced. Raised 20k → 22k with the # router-dispatch surface (wrapper_proofs / withdrawal params); 22k → 24k # when get_pools/get_tokens/derive_pool_key/derive_tick_key picked up full - # docstrings (they rendered blank before). - assert len(_render()) < 24_000 + # docstrings (they rendered blank before); 24k → 26k for the swap/swap_many + # footgun warnings (build-time counter reservation, refusing an unusable + # quote). 24k had been squeezed to 134 chars of headroom, which any further + # edit broke — this is deliberate room, not another squeeze. + assert len(_render()) < 26_000 diff --git a/shield-swap-sdk/tests/test_swap_many.py b/shield-swap-sdk/tests/test_swap_many.py index 59c085a..2e434d2 100644 --- a/shield-swap-sdk/tests/test_swap_many.py +++ b/shield-swap-sdk/tests/test_swap_many.py @@ -210,3 +210,19 @@ def get_route(self, **_): with pytest.raises(NotAuthenticatedError): fresh._quote_expected_out(token_in_id="t0", token_out_id="t1", amount_in=10**6) + + +def test_swap_many_accepts_a_caller_supplied_quote(dex, monkeypatch): + """expected_out skips the route quote — the escape hatch the refusal + message points callers at, so it must actually exist.""" + called = {"n": 0} + + def _never(self, **kw): + called["n"] += 1 + return None + + monkeypatch.setattr(ShieldSwap, "_quote_expected_out", _never) + dex.swap_many(pool_key="5field", token_in_id="t0", amount_in=10**6, + count=1, expected_out=990_000) + assert called["n"] == 0, "should not have quoted" + assert dex.journal.counter_cursor() == 1