diff --git a/sdk-abi/Cargo.toml b/sdk-abi/Cargo.toml index 0c105f87..599aef29 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 96f423ff..b1212689 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 12bbf120..9849feac 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 2d9e3d4a..7444d415 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 71aabbce..0c9e47cd 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/AGENTS.md b/shield-swap-sdk/AGENTS.md index 483318ae..c770ec68 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. @@ -54,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. @@ -66,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. @@ -323,7 +340,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 +356,16 @@ 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 +**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- proving latency; a tight deadline aborts at finalize when proving outlives it. diff --git a/shield-swap-sdk/codegen/amm_api.openapi.json b/shield-swap-sdk/codegen/amm_api.openapi.json index f6a68313..049359b4 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 69bed893..659f9e53 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/pyproject.toml b/shield-swap-sdk/pyproject.toml index a3d8eaaa..09c6a122 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/AGENTS.md b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md index 483318ae..c770ec68 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. @@ -54,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. @@ -66,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. @@ -323,7 +340,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 +356,16 @@ 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 +**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- proving latency; a tight deadline aborts at finalize when proving outlives it. diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index 0852e2d3..bd899d17 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, @@ -70,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", @@ -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/_api_models.py b/shield-swap-sdk/python/aleo_shield_swap/_api_models.py index 1bcbb471..d25765de 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 diff --git a/shield-swap-sdk/python/aleo_shield_swap/_core.py b/shield-swap-sdk/python/aleo_shield_swap/_core.py index c5b2a0c0..1505deea 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_core.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_core.py @@ -165,17 +165,40 @@ 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.""" + """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/api.py b/shield-swap-sdk/python/aleo_shield_swap/api.py index d7cf9075..6895dcd8 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") @@ -177,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.""" @@ -309,13 +346,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"] @@ -409,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`.""" @@ -492,7 +531,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/async_client.py b/shield-swap-sdk/python/aleo_shield_swap/async_client.py index b1a4208b..255142d5 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 .api import AsyncApiClient, DEFAULT_API_URL -from .tick_math import int_to_u256_plaintext +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 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") @@ -100,10 +114,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] = {} @@ -303,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 = 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 + 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 @@ -339,9 +448,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): @@ -387,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 1886aa6a..09c2f26e 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, @@ -27,7 +28,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, @@ -37,6 +38,8 @@ ) from .errors import ( InsufficientRecordsError, + NotAuthenticatedError, + NotRedeemedError, InvalidFeeTierError, PoolNotFoundError, PoolNotInitializedError, @@ -46,11 +49,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, @@ -66,7 +77,6 @@ mint_route, swap_route, ) -from .tick_hints import pick_insert_hint from .tick_math import ( MAX_TICK, MIN_TICK, @@ -88,10 +98,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 @@ -102,16 +114,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"), @@ -343,6 +374,140 @@ 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", + 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. + """ + 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, + 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. *slots* is an + optional per-call cache of ``pool_key -> slot`` so a batch of positions + in one pool costs one slot read. + """ + 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 = 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, + 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] = [] + 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, slots) + 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. @@ -408,9 +573,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}) @@ -450,7 +617,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), @@ -481,6 +666,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]: @@ -498,8 +684,16 @@ 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 + **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- proving latency; a tight deadline aborts at finalize when proving outlives it. @@ -514,7 +708,18 @@ 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: + 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) # Resolve the record-funding program lazily: an explicit record # needs no registry lookup (its program registration comes from @@ -576,7 +781,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, @@ -587,6 +792,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) @@ -736,8 +949,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) @@ -749,6 +971,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: @@ -763,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: @@ -775,18 +1000,37 @@ 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 + # 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() @@ -1028,16 +1272,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)) @@ -1105,21 +1345,21 @@ 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"]))) - - 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) + else self.find_tick_predecessor(pool_key, int(decoded["tick_upper"]))) + + 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/lifecycle.py b/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py index 0ff122a1..13efc150 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/python/aleo_shield_swap/position_math.py b/shield-swap-sdk/python/aleo_shield_swap/position_math.py new file mode 100644 index 00000000..b4085595 --- /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/profile.py b/shield-swap-sdk/python/aleo_shield_swap/profile.py index bd39bc71..15ade811 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/python/aleo_shield_swap/tick_hints.py b/shield-swap-sdk/python/aleo_shield_swap/tick_hints.py deleted file mode 100644 index 8baa43f4..00000000 --- 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/python/aleo_shield_swap/types.py b/shield-swap-sdk/python/aleo_shield_swap/types.py index f6ded0d8..eb43a2b0 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/integration/conftest.py b/shield-swap-sdk/tests/integration/conftest.py index 74b9d9fe..4513076b 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 diff --git a/shield-swap-sdk/tests/integration/test_reads_live.py b/shield-swap-sdk/tests/integration/test_reads_live.py index 88c1eb97..926aabb1 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 ( @@ -60,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( @@ -71,9 +75,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) diff --git a/shield-swap-sdk/tests/integration/test_swap_lifecycle.py b/shield-swap-sdk/tests/integration/test_swap_lifecycle.py index e005c4d7..5484bd8a 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() @@ -59,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, @@ -96,14 +106,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 diff --git a/shield-swap-sdk/tests/test_api_client.py b/shield-swap-sdk/tests/test_api_client.py index 94f4eae8..ccc4a935 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" 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 00000000..e92473e8 --- /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_core.py b/shield-swap-sdk/tests/test_core.py index 8338057b..b6dae012 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 24d2c7e1..2dd66d91 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_lifecycle.py b/shield-swap-sdk/tests/test_lifecycle.py index 8387246a..f7695976 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): + """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 + + 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") 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 00000000..60dd2c53 --- /dev/null +++ b/shield-swap-sdk/tests/test_owned_positions.py @@ -0,0 +1,174 @@ +"""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) + + +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_owned_positions_async.py b/shield-swap-sdk/tests/test_owned_positions_async.py new file mode 100644 index 00000000..70dc7da7 --- /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() == [] diff --git a/shield-swap-sdk/tests/test_package.py b/shield-swap-sdk/tests/test_package.py index 05c48c45..3601a6ae 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_position_math.py b/shield-swap-sdk/tests/test_position_math.py new file mode 100644 index 00000000..9a7b341a --- /dev/null +++ b/shield-swap-sdk/tests/test_position_math.py @@ -0,0 +1,162 @@ +"""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 + + +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_profile.py b/shield-swap-sdk/tests/test_profile.py index e14bcb4e..68d93cab 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.py b/shield-swap-sdk/tests/test_swap.py index 2ac7dba4..1d7f40eb 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,82 @@ 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 + + +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() == [] diff --git a/shield-swap-sdk/tests/test_swap_many.py b/shield-swap-sdk/tests/test_swap_many.py index 355d689b..2e434d2d 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,61 @@ 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) + + +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