From 6bd12ca278027890022aaa64dd0ccc974fc2d437 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 18:02:10 -0400 Subject: [PATCH 01/30] feat(shield-swap): lifecycle error taxonomy + central HTTP status mapping Includes the refreshed amm-api OpenAPI spec + regenerated models (airdrop is now private-record transfers, 429 rate-limited). --- shield-swap-sdk/codegen/amm_api.openapi.json | 31 ++++++--- .../python/aleo_shield_swap/_api_models.py | 2 +- .../python/aleo_shield_swap/api.py | 29 ++++++--- .../python/aleo_shield_swap/errors.py | 63 +++++++++++++++++++ shield-swap-sdk/tests/test_api_client.py | 20 ++++++ shield-swap-sdk/tests/test_errors.py | 18 ++++++ 6 files changed, 143 insertions(+), 20 deletions(-) diff --git a/shield-swap-sdk/codegen/amm_api.openapi.json b/shield-swap-sdk/codegen/amm_api.openapi.json index f65c1ae..b5039dd 100644 --- a/shield-swap-sdk/codegen/amm_api.openapi.json +++ b/shield-swap-sdk/codegen/amm_api.openapi.json @@ -356,7 +356,7 @@ "airdrop" ], "summary": "POST /airdrop", - "description": "Mints 1000 (\u00d710^decimals) of every token registered in the DB to the given\naddress by calling `::mint_public` on each. Because each\ntoken's admin is the treasury/deployer key, this never requires the treasury\nto hold a balance \u2014 it mints fresh supply.\n\nProving each token takes ~15-30s and the list can be long, so this returns\nimmediately with `status: \"running\"` + a `job_id`. The mints run in a\ndetached worker (capped concurrency, per-token timeout); poll\n`GET /airdrop/{job_id}` until `status == \"complete\"` to read per-token\nresults.\n\nRequired env: `DEPLOYER_PRIVATE_KEY` (the treasury/admin of every token;\n`AIRDROP_PRIVATE_KEY` honored as a fallback), `RPC_URLS` (first entry used).", + "description": "Delivers ~$10 worth each of wALEO, wUSDCx, and wETH to the given address as\nprivate records via `transfer_public_to_private`, spent from the\ntreasury's wrapped public balance in each program. One claim per\nrecipient 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 wrapped\nbalance funds every airdrop; `AIRDROP_PRIVATE_KEY` honored as a fallback),\n`RPC_URLS` (first entry used).", "operationId": "airdrop", "requestBody": { "content": { @@ -429,6 +429,16 @@ } } }, + "429": { + "description": "Airdrop already claimed for this address in the last 15 minutes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseDoc" + } + } + } + }, "500": { "description": "Misconfigured server (missing env vars)", "content": { @@ -3449,7 +3459,7 @@ }, "AirdropJob": { "type": "object", - "description": "Status of a background airdrop job. `status` is `\"running\"` until every token\nhas resolved, then `\"complete\"`. `results` grows as each per-token mint lands,\nso a poller sees live progress (`results.len()` of `total`).", + "description": "Status of a background airdrop job. `status` is `\"running\"` until every token\nhas resolved, then `\"complete\"`. `results` grows as each per-token transfer\nlands, so a poller sees live progress (`results.len()` of `total`).", "required": [ "status", "total", @@ -3490,14 +3500,13 @@ "properties": { "address": { "type": "string", - "description": "Aleo address (`aleo1...`) to receive 1000 of each registered token." + "description": "Aleo address (`aleo1...`) to receive the airdrop." } } }, "AirdropResult": { "type": "object", "required": [ - "token_address", "symbol", "wrapper_program", "amount", @@ -3506,7 +3515,7 @@ "properties": { "amount": { "type": "string", - "description": "Base-unit amount (`1000 * 10^decimals`)." + "description": "Base-unit amount delivered." }, "error": { "type": [ @@ -3515,14 +3524,12 @@ ] }, "status": { - "type": "string" + "type": "string", + "description": "`\"accepted\"` | `\"rejected\"` (broadcast but aborted at finalize, e.g.\ninsufficient treasury balance) | `\"pending\"` (broadcast, not yet\nconfirmed) | `\"failed\"` (never broadcast \u2014 see `error`)." }, "symbol": { "type": "string" }, - "token_address": { - "type": "string" - }, "tx_id": { "type": [ "string", @@ -4639,6 +4646,7 @@ "amount0", "amount1", "executedAt", + "transactionHash", "pool" ], "properties": { @@ -4659,6 +4667,9 @@ }, "tradeType": { "$ref": "#/components/schemas/PoolTradeTypeDoc" + }, + "transactionHash": { + "type": "string" } } }, @@ -5918,7 +5929,7 @@ }, { "name": "airdrop", - "description": "Faucet \u2014 sends 1000 of every registered token to a user address" + "description": "Faucet \u2014 sends ~$10 each of wALEO, wUSDCx, and wETH to a user address as private records, once per address per 15 min" }, { "name": "unclaimed", 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 7ce95c3..40c8b5e 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_api_models.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_api_models.py @@ -79,7 +79,6 @@ class AirdropResult: amount: str status: str symbol: str - token_address: str wrapper_program: str error: str | None = None tx_id: str | None = None @@ -720,6 +719,7 @@ class PoolTradeDoc: id: str pool: str tradeType: PoolTradeTypeDoc + transactionHash: str @dataclass diff --git a/shield-swap-sdk/python/aleo_shield_swap/api.py b/shield-swap-sdk/python/aleo_shield_swap/api.py index 8358688..549fa33 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/api.py +++ b/shield-swap-sdk/python/aleo_shield_swap/api.py @@ -16,7 +16,22 @@ import requests from . import _api_models as models -from .errors import DexApiError +from .errors import ( + DexApiError, + NotAuthenticatedError, + NotRedeemedError, +) + + +def _check(status_code: int, text: str) -> None: + """Map DEX API failures to the lifecycle taxonomy; DexApiError otherwise.""" + if 200 <= status_code < 300: + return + if status_code == 401: + raise NotAuthenticatedError() + if status_code == 403 and "invite" in text.lower(): + raise NotRedeemedError() + raise DexApiError(status_code, text) DEFAULT_API_URL = "https://amm-api.dev.provable.com" _TIMEOUT = 30.0 @@ -78,15 +93,13 @@ def _headers(self) -> dict[str, str]: def _get(self, path: str, params: dict[str, Any] | None = None) -> Any: resp = self._session.get(f"{self.base_url}{path}", params=params, headers=self._headers(), timeout=_TIMEOUT) - if not 200 <= resp.status_code < 300: - raise DexApiError(resp.status_code, resp.text) + _check(resp.status_code, resp.text) return resp.json() def _post(self, path: str, body: dict[str, Any]) -> Any: resp = self._session.post(f"{self.base_url}{path}", json=body, headers=self._headers(), timeout=_TIMEOUT) - if not 200 <= resp.status_code < 300: - raise DexApiError(resp.status_code, resp.text) + _check(resp.status_code, resp.text) return resp.json() # ── Auth ─────────────────────────────────────────────────────────────── @@ -183,15 +196,13 @@ def _headers(self) -> dict[str, str]: async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any: resp = await self._client.get(f"{self.base_url}{path}", params=params, headers=self._headers()) - if not 200 <= resp.status_code < 300: - raise DexApiError(resp.status_code, resp.text) + _check(resp.status_code, resp.text) return resp.json() async def _post(self, path: str, body: dict[str, Any]) -> Any: resp = await self._client.post(f"{self.base_url}{path}", json=body, headers=self._headers()) - if not 200 <= resp.status_code < 300: - raise DexApiError(resp.status_code, resp.text) + _check(resp.status_code, resp.text) return resp.json() async def authenticate(self, address: str, sign: Any) -> str: diff --git a/shield-swap-sdk/python/aleo_shield_swap/errors.py b/shield-swap-sdk/python/aleo_shield_swap/errors.py index 24fa197..33aae27 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/errors.py +++ b/shield-swap-sdk/python/aleo_shield_swap/errors.py @@ -54,3 +54,66 @@ def __init__(self, status: int, body: str) -> None: super().__init__(f"DEX API error {status}: {body[:200]}") self.status = status self.body = body + + +class NotAuthenticatedError(ShieldSwapError): + """The DEX API rejected the request for lack of a valid JWT (401).""" + + def __init__(self) -> None: + super().__init__( + "Not authenticated with the DEX API — run dex.onboard() (or " + "api.authenticate(address, sign) for manual control)." + ) + + +class NotRedeemedError(ShieldSwapError): + """Authenticated, but the account has not redeemed an invite code (403).""" + + def __init__(self) -> None: + super().__init__( + "This account has not redeemed an invite code — run " + "dex.onboard(invite_code=...). Codes are distributed by the team." + ) + + +class NotFundedError(ShieldSwapError): + """The account holds none of the token required for this action.""" + + def __init__(self, detail: str = "") -> None: + super().__init__( + "This account holds no usable tokens — run dex.onboard() to " + "request the airdrop, or check dex.get_balances()." + + (f" ({detail})" if detail else "") + ) + + +class AirdropPendingError(ShieldSwapError): + """An airdrop job was accepted but its records have not landed yet.""" + + def __init__(self, job_id: "str | None" = None) -> None: + super().__init__( + "Airdrop requested but not landed yet — check dex.status() and " + "retry shortly." + ) + self.job_id = job_id + + +class AirdropRateLimitedError(ShieldSwapError): + """``POST /airdrop`` returned 429 — one claim per address per 15 minutes.""" + + def __init__(self) -> None: + super().__init__( + "Airdrop already claimed for this address in the last 15 minutes " + "— wait and retry, or proceed if dex.get_balances() shows funds." + ) + + +class CredentialsMissingError(ShieldSwapError): + """Delegated-proving/scanner credentials are not configured.""" + + def __init__(self) -> None: + super().__init__( + "No delegated-proving credentials — set ALEO_E2E_API_KEY and " + "ALEO_E2E_CONSUMER_ID in the environment (they are persisted to " + "the profile on next onboard())." + ) diff --git a/shield-swap-sdk/tests/test_api_client.py b/shield-swap-sdk/tests/test_api_client.py index ef9290c..8fb4645 100644 --- a/shield-swap-sdk/tests/test_api_client.py +++ b/shield-swap-sdk/tests/test_api_client.py @@ -88,3 +88,23 @@ def test_authenticate_stores_bearer_token(): client.get_public_balances("aleo1me") headers = s.calls[2][3] assert headers["authorization"] == "Bearer jwt-abc" + + +def test_401_maps_to_not_authenticated(): + from aleo_shield_swap.errors import NotAuthenticatedError + s = _Session([_Resp(401, {"error": "missing token"})]) + with pytest.raises(NotAuthenticatedError): + ApiClient(base_url="https://x", session=s)._get("/access/status") + + +def test_403_invite_maps_to_not_redeemed(): + from aleo_shield_swap.errors import NotRedeemedError + s = _Session([_Resp(403, {"error": "redeem an invite code to unlock access"})]) + with pytest.raises(NotRedeemedError): + ApiClient(base_url="https://x", session=s, token="t")._get("/route") + + +def test_other_403_stays_dex_api_error(): + s = _Session([_Resp(403, {"error": "forbidden for another reason"})]) + with pytest.raises(DexApiError): + ApiClient(base_url="https://x", session=s, token="t")._get("/route") diff --git a/shield-swap-sdk/tests/test_errors.py b/shield-swap-sdk/tests/test_errors.py index a04bb0f..d405dba 100644 --- a/shield-swap-sdk/tests/test_errors.py +++ b/shield-swap-sdk/tests/test_errors.py @@ -25,3 +25,21 @@ def test_messages(): assert e.swap_id == "42field" d = DexApiError(404, "not found") assert d.status == 404 and d.body == "not found" + + +def test_lifecycle_errors_teach_the_fix(): + from aleo_shield_swap.errors import ( + AirdropPendingError, AirdropRateLimitedError, CredentialsMissingError, + NotAuthenticatedError, NotFundedError, NotRedeemedError, ShieldSwapError, + ) + assert "dex.onboard(" in str(NotAuthenticatedError()) + assert "invite" in str(NotRedeemedError()) + assert "dex.onboard(" in str(NotRedeemedError()) + assert "airdrop" in str(NotFundedError()) + assert "dex.status()" in str(AirdropPendingError("job1")) + assert AirdropPendingError("job1").job_id == "job1" + assert "15 minutes" in str(AirdropRateLimitedError()) + assert "ALEO_E2E_API_KEY" in str(CredentialsMissingError()) + for cls in (NotAuthenticatedError, NotRedeemedError, NotFundedError, + AirdropRateLimitedError, CredentialsMissingError): + assert issubclass(cls, ShieldSwapError) From b4ccc20d43278ff312ca70a9fe14bf779bb173d5 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 18:04:30 -0400 Subject: [PATCH 02/30] feat(shield-swap): typed access/redeem/airdrop/api-token lifecycle endpoints --- .../python/aleo_shield_swap/api.py | 96 +++++++++++++++++++ shield-swap-sdk/tests/test_api_client.py | 95 ++++++++++++++++++ 2 files changed, 191 insertions(+) diff --git a/shield-swap-sdk/python/aleo_shield_swap/api.py b/shield-swap-sdk/python/aleo_shield_swap/api.py index 549fa33..8a0fce1 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/api.py +++ b/shield-swap-sdk/python/aleo_shield_swap/api.py @@ -17,6 +17,7 @@ from . import _api_models as models from .errors import ( + AirdropRateLimitedError, DexApiError, NotAuthenticatedError, NotRedeemedError, @@ -125,6 +126,59 @@ def set_token(self, token: str) -> None: """Adopt a previously issued JWT.""" self._token = token + # ── Lifecycle ────────────────────────────────────────────────────────── + # Registration/onboarding endpoints change over time — regen the spec + # (codegen/regen-openapi.sh) before touching these. + + def access_status(self) -> models.AccessStatusResponse: + """Whether this authenticated account has redeemed an invite code.""" + return _build(models.AccessStatusResponse, + self._get("/access/status")["data"]) + + def redeem_code(self, code: str) -> models.AccessRedeemResponse: + """Redeem an invite code; adopts the fresh token the API returns.""" + out = _build(models.AccessRedeemResponse, + self._post("/access/redeem", {"code": code})["data"]) + if out.token: + self._token = out.token + return out + + def request_airdrop(self, address: str) -> models.AirdropStartResult: + """Start the test-token airdrop job for *address* (private records). + + One claim per address per 15 minutes — raises + :class:`AirdropRateLimitedError` on 429. Poll the returned + ``job_id`` with :meth:`get_airdrop_job`. + """ + try: + return _build(models.AirdropStartResult, + self._post("/airdrop", {"address": address})["data"]) + except DexApiError as exc: + if exc.status == 429: + raise AirdropRateLimitedError() from exc + raise + + def get_airdrop_job(self, job_id: str) -> models.AirdropJob: + """Progress of an airdrop job — ``running`` until every transfer lands.""" + data = self._get(f"/airdrop/{job_id}")["data"] + job = _build(models.AirdropJob, data) + job.results = [_build(models.AirdropResult, r) for r in data.get("results", [])] + return job + + def create_api_token(self, name: str, + expires_in_days: "int | None" = None + ) -> models.ApiTokenCreatedResponse: + """Mint a long-lived DEX API token (the secret is returned ONCE). + + JWTs from :meth:`authenticate` expire in 24h; adopt the returned + ``.token`` via :meth:`set_token` (or persist it) for durable access. + """ + body: dict[str, Any] = {"name": name} + if expires_in_days is not None: + body["expires_in_days"] = expires_in_days + return _build(models.ApiTokenCreatedResponse, + self._post("/api-tokens", body)["data"]) + # ── Pools & tokens ───────────────────────────────────────────────────── def get_pools(self) -> list[PoolEntry]: @@ -217,6 +271,48 @@ async def authenticate(self, address: str, sign: Any) -> str: def set_token(self, token: str) -> None: self._token = token + # ── Lifecycle (async mirror of ApiClient) ────────────────────────────── + + async def access_status(self) -> models.AccessStatusResponse: + """Whether this authenticated account has redeemed an invite code.""" + return _build(models.AccessStatusResponse, + (await self._get("/access/status"))["data"]) + + async def redeem_code(self, code: str) -> models.AccessRedeemResponse: + """Redeem an invite code; adopts the fresh token the API returns.""" + out = _build(models.AccessRedeemResponse, + (await self._post("/access/redeem", {"code": code}))["data"]) + if out.token: + self._token = out.token + return out + + async def request_airdrop(self, address: str) -> models.AirdropStartResult: + """Start the airdrop job for *address* — see :meth:`ApiClient.request_airdrop`.""" + try: + return _build(models.AirdropStartResult, + (await self._post("/airdrop", {"address": address}))["data"]) + except DexApiError as exc: + if exc.status == 429: + raise AirdropRateLimitedError() from exc + raise + + async def get_airdrop_job(self, job_id: str) -> models.AirdropJob: + """Progress of an airdrop job — ``running`` until every transfer lands.""" + data = (await self._get(f"/airdrop/{job_id}"))["data"] + job = _build(models.AirdropJob, data) + job.results = [_build(models.AirdropResult, r) for r in data.get("results", [])] + return job + + async def create_api_token(self, name: str, + expires_in_days: "int | None" = None + ) -> models.ApiTokenCreatedResponse: + """Mint a long-lived DEX API token — see :meth:`ApiClient.create_api_token`.""" + body: dict[str, Any] = {"name": name} + if expires_in_days is not None: + body["expires_in_days"] = expires_in_days + return _build(models.ApiTokenCreatedResponse, + (await self._post("/api-tokens", body))["data"]) + async def get_pools(self) -> list[PoolEntry]: entries = (await self._get("/pools"))["data"] return [ diff --git a/shield-swap-sdk/tests/test_api_client.py b/shield-swap-sdk/tests/test_api_client.py index 8fb4645..00f8bed 100644 --- a/shield-swap-sdk/tests/test_api_client.py +++ b/shield-swap-sdk/tests/test_api_client.py @@ -108,3 +108,98 @@ def test_other_403_stays_dex_api_error(): s = _Session([_Resp(403, {"error": "forbidden for another reason"})]) with pytest.raises(DexApiError): ApiClient(base_url="https://x", session=s, token="t")._get("/route") + + +def _lifecycle_client(*resps, token="t"): + s = _Session(list(resps)) + return ApiClient(base_url="https://x", session=s, token=token), s + + +def test_access_status(): + api, s = _lifecycle_client(_Resp(200, {"data": {"has_access": True}})) + assert api.access_status().has_access is True + assert s.calls[0][:2] == ("GET", "https://x/access/status") + + +def test_redeem_code_adopts_new_token(): + api, s = _lifecycle_client(_Resp(200, {"data": {"code": "C", "status": "redeemed", + "token": "fresh-jwt"}})) + out = api.redeem_code("C") + assert out.status == "redeemed" + assert s.calls[0][2] == {"code": "C"} + assert api._token == "fresh-jwt" + + +def test_request_airdrop_and_poll(): + api, s = _lifecycle_client( + _Resp(200, {"data": {"job_id": "j1", "status": "running"}}), + _Resp(200, {"data": {"status": "complete", "total": 3, "results": [ + {"symbol": "wALEO", "wrapper_program": "waleo.aleo", + "amount": "1000000", "status": "accepted", + "tx_id": "at1...", "error": None}]}}), + ) + start = api.request_airdrop("aleo1abc") + assert (start.job_id, start.status) == ("j1", "running") + assert s.calls[0][2] == {"address": "aleo1abc"} + job = api.get_airdrop_job("j1") + assert job.status == "complete" and job.results[0].symbol == "wALEO" + assert s.calls[1][1] == "https://x/airdrop/j1" + + +def test_airdrop_429_maps_to_rate_limited(): + from aleo_shield_swap.errors import AirdropRateLimitedError + api, _ = _lifecycle_client(_Resp(429, {"error": "already claimed"})) + with pytest.raises(AirdropRateLimitedError): + api.request_airdrop("aleo1abc") + + +def test_create_api_token(): + api, s = _lifecycle_client(_Resp(200, {"data": { + "id": "u1", "name": "stress", "token": "sk-live", "token_prefix": "sk", + "created_at": "2026-07-15", "expires_at": None}})) + out = api.create_api_token("stress", expires_in_days=30) + assert out.token == "sk-live" + assert s.calls[0][1] == "https://x/api-tokens" + assert s.calls[0][2] == {"name": "stress", "expires_in_days": 30} + + +class _AsyncResp(_Resp): + pass + + +class _AsyncClient: + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + async def get(self, url, params=None, headers=None): + self.calls.append(("GET", url, params, headers)) + return self.responses.pop(0) + + async def post(self, url, json=None, headers=None): + self.calls.append(("POST", url, json, headers)) + return self.responses.pop(0) + + +@pytest.mark.asyncio +async def test_async_lifecycle_endpoints(): + from aleo_shield_swap.api import AsyncApiClient + from aleo_shield_swap.errors import AirdropRateLimitedError + c = _AsyncClient([ + _AsyncResp(200, {"data": {"has_access": False}}), + _AsyncResp(200, {"data": {"code": "C", "status": "redeemed", "token": "t2"}}), + _AsyncResp(200, {"data": {"job_id": "j1", "status": "running"}}), + _AsyncResp(200, {"data": {"status": "complete", "total": 1, "results": [ + {"symbol": "wETH", "wrapper_program": "weth.aleo", + "amount": "5", "status": "accepted"}]}}), + _AsyncResp(429, {"error": "already claimed"}), + ]) + api = AsyncApiClient(base_url="https://x", client=c, token="t") + assert (await api.access_status()).has_access is False + assert (await api.redeem_code("C")).token == "t2" + assert api._token == "t2" + assert (await api.request_airdrop("aleo1a")).job_id == "j1" + job = await api.get_airdrop_job("j1") + assert job.results[0].wrapper_program == "weth.aleo" + with pytest.raises(AirdropRateLimitedError): + await api.request_airdrop("aleo1a") From cb4b7ae27a82417737ed8a767b5f58b147ae6439 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 18:11:22 -0400 Subject: [PATCH 03/30] =?UTF-8?q?fix(shield-swap):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20HTTP=20errors=20subclass=20DexApiError,=20lazy=20bo?= =?UTF-8?q?dy=20decode,=20429=20in=20=5Fcheck,=20tolerant=20airdrop-job=20?= =?UTF-8?q?build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live-verified on testnet dev API: 401 mapping, access gating for fresh accounts, full airdrop job lifecycle (3/3 accepted), api-token tiering. --- .../python/aleo_shield_swap/api.py | 70 ++++++++++--------- .../python/aleo_shield_swap/errors.py | 28 +++++--- 2 files changed, 54 insertions(+), 44 deletions(-) diff --git a/shield-swap-sdk/python/aleo_shield_swap/api.py b/shield-swap-sdk/python/aleo_shield_swap/api.py index 8a0fce1..4d13fbe 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/api.py +++ b/shield-swap-sdk/python/aleo_shield_swap/api.py @@ -24,15 +24,25 @@ ) -def _check(status_code: int, text: str) -> None: - """Map DEX API failures to the lifecycle taxonomy; DexApiError otherwise.""" - if 200 <= status_code < 300: +def _check(resp: Any) -> None: + """Map DEX API failures to the lifecycle taxonomy; DexApiError otherwise. + + Takes the response object so the body is only decoded on failure. + The 403 classification keys on the API's "invite" wording — if the + message ever drifts, this degrades to a plain DexApiError(403), which + every catcher of these subclasses already handles. + """ + code = resp.status_code + if 200 <= code < 300: return - if status_code == 401: - raise NotAuthenticatedError() - if status_code == 403 and "invite" in text.lower(): - raise NotRedeemedError() - raise DexApiError(status_code, text) + text = resp.text + if code == 401: + raise NotAuthenticatedError(text) + if code == 403 and "invite" in text.lower(): + raise NotRedeemedError(text) + if code == 429: + raise AirdropRateLimitedError(text) + raise DexApiError(code, text) DEFAULT_API_URL = "https://amm-api.dev.provable.com" _TIMEOUT = 30.0 @@ -94,13 +104,13 @@ def _headers(self) -> dict[str, str]: def _get(self, path: str, params: dict[str, Any] | None = None) -> Any: resp = self._session.get(f"{self.base_url}{path}", params=params, headers=self._headers(), timeout=_TIMEOUT) - _check(resp.status_code, resp.text) + _check(resp) return resp.json() def _post(self, path: str, body: dict[str, Any]) -> Any: resp = self._session.post(f"{self.base_url}{path}", json=body, headers=self._headers(), timeout=_TIMEOUT) - _check(resp.status_code, resp.text) + _check(resp) return resp.json() # ── Auth ─────────────────────────────────────────────────────────────── @@ -150,28 +160,25 @@ def request_airdrop(self, address: str) -> models.AirdropStartResult: :class:`AirdropRateLimitedError` on 429. Poll the returned ``job_id`` with :meth:`get_airdrop_job`. """ - try: - return _build(models.AirdropStartResult, - self._post("/airdrop", {"address": address})["data"]) - except DexApiError as exc: - if exc.status == 429: - raise AirdropRateLimitedError() from exc - raise + return _build(models.AirdropStartResult, + self._post("/airdrop", {"address": address})["data"]) def get_airdrop_job(self, job_id: str) -> models.AirdropJob: """Progress of an airdrop job — ``running`` until every transfer lands.""" data = self._get(f"/airdrop/{job_id}")["data"] - job = _build(models.AirdropJob, data) - job.results = [_build(models.AirdropResult, r) for r in data.get("results", [])] - return job + results = [_build(models.AirdropResult, r) + for r in (data.get("results") or [])] + return _build(models.AirdropJob, {**data, "results": results}) def create_api_token(self, name: str, expires_in_days: "int | None" = None ) -> models.ApiTokenCreatedResponse: """Mint a long-lived DEX API token (the secret is returned ONCE). - JWTs from :meth:`authenticate` expire in 24h; adopt the returned - ``.token`` via :meth:`set_token` (or persist it) for durable access. + JWTs from :meth:`authenticate` expire in 24h; persist the returned + ``.token`` for durable access. Tiering (verified live): ``ss_…`` + tokens work on data/trading endpoints; ``/access/*`` and token + management still require a session JWT. """ body: dict[str, Any] = {"name": name} if expires_in_days is not None: @@ -250,13 +257,13 @@ def _headers(self) -> dict[str, str]: async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any: resp = await self._client.get(f"{self.base_url}{path}", params=params, headers=self._headers()) - _check(resp.status_code, resp.text) + _check(resp) return resp.json() async def _post(self, path: str, body: dict[str, Any]) -> Any: resp = await self._client.post(f"{self.base_url}{path}", json=body, headers=self._headers()) - _check(resp.status_code, resp.text) + _check(resp) return resp.json() async def authenticate(self, address: str, sign: Any) -> str: @@ -288,20 +295,15 @@ async def redeem_code(self, code: str) -> models.AccessRedeemResponse: async def request_airdrop(self, address: str) -> models.AirdropStartResult: """Start the airdrop job for *address* — see :meth:`ApiClient.request_airdrop`.""" - try: - return _build(models.AirdropStartResult, - (await self._post("/airdrop", {"address": address}))["data"]) - except DexApiError as exc: - if exc.status == 429: - raise AirdropRateLimitedError() from exc - raise + return _build(models.AirdropStartResult, + (await self._post("/airdrop", {"address": address}))["data"]) async def get_airdrop_job(self, job_id: str) -> models.AirdropJob: """Progress of an airdrop job — ``running`` until every transfer lands.""" data = (await self._get(f"/airdrop/{job_id}"))["data"] - job = _build(models.AirdropJob, data) - job.results = [_build(models.AirdropResult, r) for r in data.get("results", [])] - return job + results = [_build(models.AirdropResult, r) + for r in (data.get("results") or [])] + return _build(models.AirdropJob, {**data, "results": results}) async def create_api_token(self, name: str, expires_in_days: "int | None" = None diff --git a/shield-swap-sdk/python/aleo_shield_swap/errors.py b/shield-swap-sdk/python/aleo_shield_swap/errors.py index 33aae27..e094692 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/errors.py +++ b/shield-swap-sdk/python/aleo_shield_swap/errors.py @@ -50,27 +50,33 @@ class InvalidFeeTierError(ShieldSwapError): class DexApiError(ShieldSwapError): """A DEX REST API request failed; carries the HTTP status and body.""" - def __init__(self, status: int, body: str) -> None: - super().__init__(f"DEX API error {status}: {body[:200]}") + def __init__(self, status: int, body: str, + message: "str | None" = None) -> None: + super().__init__(message or f"DEX API error {status}: {body[:200]}") self.status = status self.body = body -class NotAuthenticatedError(ShieldSwapError): - """The DEX API rejected the request for lack of a valid JWT (401).""" +class NotAuthenticatedError(DexApiError): + """The DEX API rejected the request for lack of a valid JWT (401). - def __init__(self) -> None: + Subclasses :class:`DexApiError` so existing ``except DexApiError`` / + ``.status`` call sites keep working.""" + + def __init__(self, body: str = "") -> None: super().__init__( + 401, body, "Not authenticated with the DEX API — run dex.onboard() (or " "api.authenticate(address, sign) for manual control)." ) -class NotRedeemedError(ShieldSwapError): +class NotRedeemedError(DexApiError): """Authenticated, but the account has not redeemed an invite code (403).""" - def __init__(self) -> None: + def __init__(self, body: str = "") -> None: super().__init__( + 403, body, "This account has not redeemed an invite code — run " "dex.onboard(invite_code=...). Codes are distributed by the team." ) @@ -98,11 +104,13 @@ def __init__(self, job_id: "str | None" = None) -> None: self.job_id = job_id -class AirdropRateLimitedError(ShieldSwapError): - """``POST /airdrop`` returned 429 — one claim per address per 15 minutes.""" +class AirdropRateLimitedError(DexApiError): + """The DEX API returned 429 — for ``POST /airdrop``, one claim per + address per 15 minutes.""" - def __init__(self) -> None: + def __init__(self, body: str = "") -> None: super().__init__( + 429, body, "Airdrop already claimed for this address in the last 15 minutes " "— wait and retry, or proceed if dex.get_balances() shows funds." ) From 908d340350fb48965ea81d9fdd4126decdbb206d Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 18:12:24 -0400 Subject: [PATCH 04/30] feat(shield-swap): on-disk participant profile store --- .../python/aleo_shield_swap/profile.py | 101 ++++++++++++++++++ shield-swap-sdk/tests/test_profile.py | 41 +++++++ 2 files changed, 142 insertions(+) create mode 100644 shield-swap-sdk/python/aleo_shield_swap/profile.py create mode 100644 shield-swap-sdk/tests/test_profile.py diff --git a/shield-swap-sdk/python/aleo_shield_swap/profile.py b/shield-swap-sdk/python/aleo_shield_swap/profile.py new file mode 100644 index 0000000..7a3d2fa --- /dev/null +++ b/shield-swap-sdk/python/aleo_shield_swap/profile.py @@ -0,0 +1,101 @@ +"""On-disk participant profile — key material, credentials, journal location. + +One profile per home directory (``$SHIELD_SWAP_HOME`` or ``~/.shield-swap``). +``Profile.load_or_create()`` generates key material on first use and reuses +it forever after; credentials (JWT, delegated-proving keys) are stored +separately so they can be refreshed without touching the key. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Optional + +DEFAULT_ENDPOINT = "https://api.provable.com" +_PROFILE = "profile.json" +_CREDENTIALS = "credentials.json" + + +def _write_private(path: Path, payload: dict[str, Any]) -> None: + path.write_text(json.dumps(payload, indent=1)) + path.chmod(0o600) + + +def _generate_key(network: str) -> tuple[str, str]: + """(private_key, address) for a fresh random account.""" + import aleo + net = getattr(aleo, network) + pk = net.PrivateKey.random() + return str(pk), str(pk.address) + + +class Profile: + """A participant's persistent identity and credentials. + + Load with :meth:`load_or_create`; the profile is created (with fresh key + material) when the home directory has none yet. + """ + + def __init__(self, home: Path, data: dict[str, Any]) -> None: + self.home = home + self._data = data + + def __repr__(self) -> str: + return f"Profile({self.address!r}, home={str(self.home)!r})" + + # ── Construction ──────────────────────────────────────────────────────── + + @staticmethod + def default_home() -> Path: + env = os.environ.get("SHIELD_SWAP_HOME") + return Path(env) if env else Path.home() / ".shield-swap" + + @classmethod + def load_or_create(cls, home: "Path | str | None" = None, *, + network: str = "testnet", + endpoint: str = DEFAULT_ENDPOINT) -> "Profile": + home = Path(home) if home is not None else cls.default_home() + path = home / _PROFILE + if path.exists(): + return cls(home, json.loads(path.read_text())) + home.mkdir(parents=True, exist_ok=True) + private_key, address = _generate_key(network) + data = {"address": address, "private_key": private_key, + "network": network, "endpoint": endpoint} + _write_private(path, data) + return cls(home, data) + + # ── Identity ───────────────────────────────────────────────────────────── + + @property + def address(self) -> str: + return self._data["address"] + + @property + def private_key(self) -> str: + return self._data["private_key"] + + @property + def network(self) -> str: + return self._data["network"] + + @property + def endpoint(self) -> str: + return self._data.get("endpoint", DEFAULT_ENDPOINT) + + # ── Credentials ────────────────────────────────────────────────────────── + + @property + def credentials(self) -> dict[str, str]: + path = self.home / _CREDENTIALS + return json.loads(path.read_text()) if path.exists() else {} + + def save_credentials(self, **kv: Optional[str]) -> None: + """Merge non-None values into ``credentials.json`` (mode 600).""" + merged = {**self.credentials, **{k: v for k, v in kv.items() if v}} + _write_private(self.home / _CREDENTIALS, merged) + + @property + def journal_path(self) -> Path: + return self.home / "journal.jsonl" diff --git a/shield-swap-sdk/tests/test_profile.py b/shield-swap-sdk/tests/test_profile.py new file mode 100644 index 0000000..3002bb5 --- /dev/null +++ b/shield-swap-sdk/tests/test_profile.py @@ -0,0 +1,41 @@ +import stat + +from aleo_shield_swap.profile import Profile + + +def test_create_then_reload_is_stable(tmp_path): + p1 = Profile.load_or_create(tmp_path / "home") + assert p1.address.startswith("aleo1") + assert p1.private_key.startswith("APrivateKey1") + assert p1.network == "testnet" + p2 = Profile.load_or_create(tmp_path / "home") # reload, no regen + assert (p2.address, p2.private_key) == (p1.address, p1.private_key) + + +def test_key_file_is_owner_only(tmp_path): + p = Profile.load_or_create(tmp_path / "home") + mode = stat.S_IMODE((p.home / "profile.json").stat().st_mode) + assert mode == 0o600 + + +def test_env_home_override(tmp_path, monkeypatch): + monkeypatch.setenv("SHIELD_SWAP_HOME", str(tmp_path / "envhome")) + p = Profile.load_or_create() + assert p.home == tmp_path / "envhome" + + +def test_credentials_roundtrip(tmp_path): + p = Profile.load_or_create(tmp_path / "home") + assert p.credentials == {} + p.save_credentials(jwt="j", dps_api_key="k") + p.save_credentials(dps_consumer_id="c") # merges, not replaces + p2 = Profile.load_or_create(tmp_path / "home") + assert p2.credentials == {"jwt": "j", "dps_api_key": "k", + "dps_consumer_id": "c"} + mode = stat.S_IMODE((p.home / "credentials.json").stat().st_mode) + assert mode == 0o600 + + +def test_journal_path(tmp_path): + p = Profile.load_or_create(tmp_path / "home") + assert p.journal_path == p.home / "journal.jsonl" From e00daa453f5bc9e9554aca952c12a0f9b5bc794b Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 18:13:51 -0400 Subject: [PATCH 05/30] feat(shield-swap): append-only journal with locked counter reservation --- .../python/aleo_shield_swap/journal.py | 120 ++++++++++++++++++ shield-swap-sdk/tests/test_journal.py | 73 +++++++++++ 2 files changed, 193 insertions(+) create mode 100644 shield-swap-sdk/python/aleo_shield_swap/journal.py create mode 100644 shield-swap-sdk/tests/test_journal.py diff --git a/shield-swap-sdk/python/aleo_shield_swap/journal.py b/shield-swap-sdk/python/aleo_shield_swap/journal.py new file mode 100644 index 0000000..b6640b7 --- /dev/null +++ b/shield-swap-sdk/python/aleo_shield_swap/journal.py @@ -0,0 +1,120 @@ +"""Append-only participant journal — swaps, positions, counters, stages. + +One JSONL file per profile. State (pending claims, open positions, the +counter cursor) is always derived by replaying events, so a crash between +append and action never corrupts anything; the worst case is an event whose +action never happened, which downstream verbs tolerate (a claim of a swap +that never landed just reports not-finalized). + +Counter reservation is the concurrency-critical piece: blinded identities +must never collide, so counters are issued once, under an advisory file +lock, and burned (never reused) when their swap fails. +""" +from __future__ import annotations + +import fcntl +import json +import time +from pathlib import Path +from typing import Any + +from .types import SwapHandle + +_HANDLE_FIELDS = ("swap_id", "blinding_factor", "blinded_address", + "token_in_id", "token_out_id", "pool_key", "amount_in", + "transaction_id", "program") + + +class Journal: + """Event log at *path* (created on first append).""" + + def __init__(self, path: "Path | str") -> None: + self.path = Path(path) + self._lock_path = self.path.with_suffix(".lock") + + def __repr__(self) -> str: + return f"Journal({str(self.path)!r})" + + # ── Raw events ─────────────────────────────────────────────────────────── + + def append(self, type: str, **fields: Any) -> None: + event = {"type": type, "ts": time.time(), **fields} + self.path.parent.mkdir(parents=True, exist_ok=True) + with self.path.open("a") as f: + f.write(json.dumps(event) + "\n") + + def events(self) -> list[dict[str, Any]]: + if not self.path.exists(): + return [] + return [json.loads(line) + for line in self.path.read_text().splitlines() if line] + + # ── Counters ───────────────────────────────────────────────────────────── + + def reserve_counters(self, n: int) -> list[int]: + """Issue the next *n* counters, exactly once, under a file lock.""" + self.path.parent.mkdir(parents=True, exist_ok=True) + with self._lock_path.open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + try: + start = self.counter_cursor() + counters = list(range(start, start + n)) + self.append("counters_reserved", counters=counters) + return counters + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + + def counter_cursor(self) -> int: + """Next unissued counter (max seen in any event + 1).""" + top = -1 + for e in self.events(): + if e["type"] == "counters_reserved": + top = max(top, *e["counters"]) + elif e["type"] in ("swap", "swap_failed"): + top = max(top, e.get("counter", -1)) + return top + 1 + + # ── Typed events ───────────────────────────────────────────────────────── + + def record_swap(self, handle: SwapHandle, counter: int) -> None: + self.append("swap", counter=counter, + **{k: getattr(handle, k) for k in _HANDLE_FIELDS}) + + def record_swap_failed(self, counter: int, error: str) -> None: + self.append("swap_failed", counter=counter, error=error) + + def record_claim(self, swap_id: str, transaction_id: str, + amount_out: int) -> None: + self.append("claim", swap_id=swap_id, transaction_id=transaction_id, + amount_out=amount_out) + + def record_position(self, position_token_id: str, pool_key: str, + transaction_id: str) -> None: + self.append("position", position_token_id=position_token_id, + pool_key=pool_key, transaction_id=transaction_id) + + def record_position_burned(self, position_token_id: str, + transaction_id: str) -> None: + self.append("position_burned", position_token_id=position_token_id, + transaction_id=transaction_id) + + def record_stage(self, name: str, action: str, detail: str = "") -> None: + self.append("stage", name=name, action=action, detail=detail) + + # ── Derived state ──────────────────────────────────────────────────────── + + def pending_claims(self) -> list[SwapHandle]: + """Swaps recorded but never claimed, as re-hydrated handles.""" + claimed = {e["swap_id"] for e in self.events() if e["type"] == "claim"} + return [SwapHandle(**{k: e[k] for k in _HANDLE_FIELDS}) + for e in self.events() + if e["type"] == "swap" and e["swap_id"] not in claimed] + + def open_positions(self) -> list[dict[str, Any]]: + """Positions recorded and not burned: {position_token_id, pool_key}.""" + burned = {e["position_token_id"] for e in self.events() + if e["type"] == "position_burned"} + return [{"position_token_id": e["position_token_id"], + "pool_key": e["pool_key"]} + for e in self.events() + if e["type"] == "position" and e["position_token_id"] not in burned] diff --git a/shield-swap-sdk/tests/test_journal.py b/shield-swap-sdk/tests/test_journal.py new file mode 100644 index 0000000..5f1b6c1 --- /dev/null +++ b/shield-swap-sdk/tests/test_journal.py @@ -0,0 +1,73 @@ +import json +import threading + +from aleo_shield_swap.journal import Journal +from aleo_shield_swap.types import SwapHandle + + +def _handle(swap_id="s1", **kw): + base = dict(swap_id=swap_id, blinding_factor="bf", blinded_address="ba", + token_in_id="t0", token_out_id="t1", pool_key="pk", + amount_in=5, transaction_id="tx1", program="shield_swap_v3.aleo") + base.update(kw) + return SwapHandle(**base) + + +def test_append_is_jsonl_and_replayable(tmp_path): + j = Journal(tmp_path / "journal.jsonl") + j.append("stage", name="authenticate", action="ran") + lines = (tmp_path / "journal.jsonl").read_text().splitlines() + assert json.loads(lines[0])["type"] == "stage" + assert Journal(tmp_path / "journal.jsonl").events()[0]["name"] == "authenticate" + + +def test_reserve_counters_monotonic_and_persistent(tmp_path): + j = Journal(tmp_path / "journal.jsonl") + assert j.reserve_counters(3) == [0, 1, 2] + assert j.reserve_counters(2) == [3, 4] + j2 = Journal(tmp_path / "journal.jsonl") # fresh process + assert j2.reserve_counters(1) == [5] + assert j2.counter_cursor() == 6 + + +def test_reserve_counters_thread_safe(tmp_path): + got: list[int] = [] + lock = threading.Lock() + + def grab(): + counters = Journal(tmp_path / "journal.jsonl").reserve_counters(5) + with lock: + got.extend(counters) + + ts = [threading.Thread(target=grab) for _ in range(4)] + [t.start() for t in ts] + [t.join() for t in ts] + assert sorted(got) == list(range(20)) # no counter issued twice + + +def test_pending_claims_shrink_after_claim(tmp_path): + j = Journal(tmp_path / "journal.jsonl") + j.record_swap(_handle("s1"), counter=0) + j.record_swap(_handle("s2", transaction_id="tx2"), counter=1) + assert {h.swap_id for h in j.pending_claims()} == {"s1", "s2"} + j.record_claim("s1", "txc", amount_out=42) + pending = j.pending_claims() + assert [h.swap_id for h in pending] == ["s2"] + assert isinstance(pending[0], SwapHandle) + assert pending[0].transaction_id == "tx2" + + +def test_open_positions_close_on_burn(tmp_path): + j = Journal(tmp_path / "journal.jsonl") + j.record_position("p1field", "poolA", "tx1") + j.record_position("p2field", "poolB", "tx2") + j.record_position_burned("p1field", "tx3") + assert [p["position_token_id"] for p in j.open_positions()] == ["p2field"] + + +def test_failed_swap_burns_counter_and_is_not_pending(tmp_path): + j = Journal(tmp_path / "journal.jsonl") + cs = j.reserve_counters(1) + j.record_swap_failed(cs[0], "boom") + assert j.pending_claims() == [] + assert j.reserve_counters(1) == [1] # 0 never reused From 5495456c1f49a1a06cea415f1a3d6fe1ac08991e Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 18:18:09 -0400 Subject: [PATCH 06/30] feat(shield-swap): stage-list onboarding, from_profile, onboard verb --- .../python/aleo_shield_swap/client.py | 57 ++++++- .../python/aleo_shield_swap/lifecycle.py | 160 ++++++++++++++++++ .../python/aleo_shield_swap/types.py | 18 ++ shield-swap-sdk/tests/test_lifecycle.py | 133 +++++++++++++++ 4 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 shield-swap-sdk/python/aleo_shield_swap/lifecycle.py create mode 100644 shield-swap-sdk/tests/test_lifecycle.py diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 8a44e42..a4ca16a 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -37,7 +37,17 @@ PoolNotInitializedError, SwapOutputNotFinalizedError, ) -from .types import ClaimResult, MintResult, SlotView, SwapHandle, TxResult +from .journal import Journal +from .lifecycle import run_onboard +from .profile import Profile +from .types import ( + ClaimResult, + MintResult, + OnboardReport, + SlotView, + SwapHandle, + TxResult, +) from ._calls import DexCall from .tick_hints import pick_insert_hint from .tick_math import ( @@ -63,10 +73,55 @@ def __init__(self, aleo: Any, *, program: str = g.PROGRAM_ID, self._aleo = aleo self.program = program self.api = ApiClient(api_url) + self.profile: Any = None # set by from_profile() + self.journal: Any = None # set by from_profile() 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": + """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. + """ + from aleo import Aleo, HTTPProvider + + profile = Profile.load_or_create(home) + creds = profile.credentials + provider = HTTPProvider(profile.endpoint, network=profile.network, + api_key=creds.get("dps_api_key"), + consumer_id=creds.get("dps_consumer_id")) + aleo = Aleo(provider) + aleo.default_account = aleo.account.from_private_key(profile.private_key) + try: + # Hosted-scanner registration (keyless); scanning itself needs the + # DPS api key, which the credentials stage provisions. + aleo.records.register(aleo.default_account) + except Exception: + pass # offline or scanner down — record verbs will surface it + dex = cls(aleo) + dex.profile = profile + dex.journal = Journal(profile.journal_path) + if creds.get("jwt"): + dex.api.set_token(creds["jwt"]) + return dex + + def onboard(self, invite_code: Optional[str] = None) -> OnboardReport: + """Register this profile end to end — safe to re-run any time. + + Runs only the registration stages not already satisfied (see + ``lifecycle.REGISTRATION_STAGES``); a registered, funded account is + a no-op. The one thing it may need from you: *invite_code*, on the + first run. Requires a profile-bound client (``from_profile()``). + """ + if self.profile is None: + raise ValueError("onboard() needs a profile-bound client — " + "construct with ShieldSwap.from_profile().") + return run_onboard(self, self.profile, invite_code) + # ── Mapping plumbing ───────────────────────────────────────────────────── def _mapping_value(self, mapping: str, key: str) -> Optional[str]: diff --git a/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py b/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py new file mode 100644 index 0000000..3929263 --- /dev/null +++ b/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py @@ -0,0 +1,160 @@ +"""Stage-list-driven onboarding — the ONLY definition of the registration flow. + +Registration steps change over time. Each stage is a self-describing +(name, is_done, run) triple; adding/removing/reordering a step is an edit +to ``REGISTRATION_STAGES`` and nothing else — reports, journals, docs, and +tools all derive from the list. +""" +from __future__ import annotations + +import os +import time +from dataclasses import dataclass +from typing import Any, Callable, Optional + +from .errors import ( + AirdropPendingError, + AirdropRateLimitedError, + CredentialsMissingError, + NotRedeemedError, +) +from .journal import Journal +from .types import OnboardReport, StageOutcome + + +class _Ctx: + """Mutable state threaded through the stages of one onboard() run.""" + + def __init__(self, dex: Any, profile: Any, invite_code: Optional[str], + poll_seconds: float, timeout_seconds: float) -> None: + self.dex = dex + self.profile = profile + self.invite_code = invite_code + self.poll_seconds = poll_seconds + self.timeout_seconds = timeout_seconds + self.journal = Journal(profile.journal_path) + self._wrappers: Optional[list[str]] = None + + def wrapper_programs(self) -> list[str]: + """Airdroppable token programs, from the live token registry.""" + if self._wrappers is None: + self._wrappers = [t.wrapper_program for t in self.dex.api.get_tokens() + if t.wrapper_program] + return self._wrappers + + def funded(self) -> bool: + balances = self.dex.get_private_balances(self.wrapper_programs()) + return any(v > 0 for v in balances.values()) + + +@dataclass +class Stage: + name: str + is_done: Callable[[_Ctx], bool] + run: Callable[[_Ctx], str] # returns a one-line detail + + +# ── Stages ──────────────────────────────────────────────────────────────────── + +def _auth_done(ctx: _Ctx) -> bool: + return getattr(ctx.dex.api, "_token", None) is not None + + +def _auth_run(ctx: _Ctx) -> str: + import aleo + net = getattr(aleo, ctx.profile.network) + pk = net.PrivateKey.from_string(ctx.profile.private_key) + jwt = ctx.dex.api.authenticate(ctx.profile.address, + lambda msg: str(pk.sign(msg.encode()))) + ctx.profile.save_credentials(jwt=jwt) + return "authenticated (24h JWT)" + + +def _redeem_done(ctx: _Ctx) -> bool: + return bool(ctx.dex.api.access_status().has_access) + + +def _redeem_run(ctx: _Ctx) -> str: + if not ctx.invite_code: + raise NotRedeemedError() + out = ctx.dex.api.redeem_code(ctx.invite_code) + ctx.profile.save_credentials(jwt=getattr(out, "token", None)) + return f"invite redeemed ({out.status})" + + +def _creds_done(ctx: _Ctx) -> bool: + c = ctx.profile.credentials + return bool(c.get("dps_api_key") and c.get("dps_consumer_id")) + + +def _creds_run(ctx: _Ctx) -> str: + # No provisioning endpoint exists yet (spec blocker) — import from env. + # When the API grows one, this function body is the only change. + key = os.environ.get("ALEO_E2E_API_KEY") + cid = os.environ.get("ALEO_E2E_CONSUMER_ID") + if not (key and cid): + raise CredentialsMissingError() + ctx.profile.save_credentials(dps_api_key=key, dps_consumer_id=cid) + return "delegated-proving credentials imported from env" + + +def _airdrop_done(ctx: _Ctx) -> bool: + return ctx.funded() + + +def _airdrop_run(ctx: _Ctx) -> str: + try: + start = ctx.dex.api.request_airdrop(ctx.profile.address) + except AirdropRateLimitedError: + return "rate-limited (claimed <15min ago) — waiting on records" + deadline = time.monotonic() + ctx.timeout_seconds + while True: + job = ctx.dex.api.get_airdrop_job(start.job_id) + if job.status == "complete": + return f"airdrop complete ({job.total} tokens)" + if time.monotonic() >= deadline: + raise AirdropPendingError(start.job_id) + time.sleep(ctx.poll_seconds) + + +def _funded_done(ctx: _Ctx) -> bool: + return ctx.funded() + + +def _funded_run(ctx: _Ctx) -> str: + deadline = time.monotonic() + ctx.timeout_seconds + while True: + if ctx.funded(): + return "private records scanned and spendable" + if time.monotonic() >= deadline: + raise AirdropPendingError() + time.sleep(ctx.poll_seconds) + + +REGISTRATION_STAGES: list[Stage] = [ + Stage("authenticate", _auth_done, _auth_run), + Stage("redeem", _redeem_done, _redeem_run), + Stage("credentials", _creds_done, _creds_run), + Stage("airdrop", _airdrop_done, _airdrop_run), + Stage("funded", _funded_done, _funded_run), +] + + +def run_onboard(dex: Any, profile: Any, invite_code: Optional[str] = None, + poll_seconds: float = 5.0, + timeout_seconds: float = 600.0) -> OnboardReport: + """Run every not-yet-done registration stage, in order, and report. + + Idempotent: already-satisfied stages are skipped, so calling this on a + registered, funded account is a no-op that says so. + """ + ctx = _Ctx(dex, profile, invite_code, poll_seconds, timeout_seconds) + outcomes: list[StageOutcome] = [] + for stage in REGISTRATION_STAGES: + if stage.is_done(ctx): + outcome = StageOutcome(stage.name, "skipped", "already satisfied") + else: + outcome = StageOutcome(stage.name, "ran", stage.run(ctx)) + ctx.journal.record_stage(outcome.name, outcome.action, outcome.detail) + outcomes.append(outcome) + return OnboardReport(profile.address, outcomes, funded=ctx.funded()) diff --git a/shield-swap-sdk/python/aleo_shield_swap/types.py b/shield-swap-sdk/python/aleo_shield_swap/types.py index 9d41ef4..29661d7 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/types.py +++ b/shield-swap-sdk/python/aleo_shield_swap/types.py @@ -106,3 +106,21 @@ def tick_range(self, width: int) -> tuple[int, int]: round_tick_to_spacing(s.tick - s.tick_spacing * width, s.tick_spacing), round_tick_to_spacing(s.tick + s.tick_spacing * width, s.tick_spacing), ) + + +@dataclass +class StageOutcome: + """One onboarding stage's result: ``action`` is ``"ran"`` or ``"skipped"``.""" + + name: str + action: str + detail: str = "" + + +@dataclass +class OnboardReport: + """What ``onboard()`` did, stage by stage, and whether funds are usable.""" + + address: str + outcomes: list[StageOutcome] + funded: bool diff --git a/shield-swap-sdk/tests/test_lifecycle.py b/shield-swap-sdk/tests/test_lifecycle.py new file mode 100644 index 0000000..6bdcfa3 --- /dev/null +++ b/shield-swap-sdk/tests/test_lifecycle.py @@ -0,0 +1,133 @@ +import pytest + +from aleo_shield_swap.errors import (AirdropRateLimitedError, + CredentialsMissingError, NotRedeemedError) +from aleo_shield_swap.journal import Journal +from aleo_shield_swap.lifecycle import REGISTRATION_STAGES, run_onboard +from aleo_shield_swap.profile import Profile + + +class _Tok: + def __init__(self, wrapper_program): + self.wrapper_program = wrapper_program + + +class _StubApi: + def __init__(self, has_access=False): + self._token = None + self.has_access = has_access + self.redeemed_with = None + self.airdrops = 0 + + def authenticate(self, address, sign): + self._token = "jwt" + return "jwt" + + def access_status(self): + return type("S", (), {"has_access": self.has_access})() + + def redeem_code(self, code): + self.redeemed_with = code + self.has_access = True + return type("R", (), {"code": code, "status": "redeemed", + "token": "jwt2"})() + + def request_airdrop(self, address): + self.airdrops += 1 + return type("A", (), {"job_id": "j1", "status": "running"})() + + def get_airdrop_job(self, job_id): + return type("J", (), {"status": "complete", "total": 3, "results": []})() + + def get_tokens(self): + return [_Tok("waleo.aleo"), _Tok("wusdcx.aleo"), _Tok("weth.aleo")] + + +class _StubDex: + def __init__(self, api, balances, funded_from_start=False): + self.api = api + self._balances = balances + self._funded_from_start = funded_from_start + + def get_private_balances(self, programs, account=None): + if not (self._funded_from_start or self.api.airdrops): + return {p: 0 for p in programs} # records land with the airdrop + return {p: self._balances.get(p, 0) for p in programs} + + +@pytest.fixture +def profile(tmp_path): + # Real keygen: the authenticate stage parses the key with the native + # PrivateKey type, so a fake string won't do. + return Profile.load_or_create(tmp_path / "home") + + +@pytest.fixture +def dps_env(monkeypatch): + monkeypatch.setenv("ALEO_E2E_API_KEY", "k") + monkeypatch.setenv("ALEO_E2E_CONSUMER_ID", "c") + + +def test_fresh_account_runs_every_stage(profile, dps_env): + api = _StubApi() + dex = _StubDex(api, {"waleo.aleo": 7}) # funded once airdrop "lands" + report = run_onboard(dex, profile, invite_code="CODE", poll_seconds=0) + assert [o.name for o in report.outcomes] == [s.name for s in REGISTRATION_STAGES] + # funded is a verification stage: once the airdrop lands it is already + # satisfied, so it reports "skipped" rather than polling. + assert [o.action for o in report.outcomes] == ["ran"] * 4 + ["skipped"] + assert api.redeemed_with == "CODE" and api.airdrops == 1 + assert report.funded is True + assert profile.credentials["dps_api_key"] == "k" + assert profile.credentials["jwt"] == "jwt2" # redeem's fresh token wins + + +def test_registered_funded_account_is_noop(profile, dps_env): + profile.save_credentials(jwt="oldjwt", dps_api_key="k", dps_consumer_id="c") + api = _StubApi(has_access=True) + api._token = "oldjwt" + dex = _StubDex(api, {"waleo.aleo": 7}, funded_from_start=True) + report = run_onboard(dex, profile) + assert all(o.action == "skipped" for o in report.outcomes) + assert api.airdrops == 0 + + +def test_redeem_without_code_raises_instructively(profile, dps_env): + api = _StubApi(has_access=False) + api._token = "jwt" # authenticated already + dex = _StubDex(api, {"waleo.aleo": 7}) + with pytest.raises(NotRedeemedError): + run_onboard(dex, profile) # no invite_code + + +def test_missing_dps_creds_raise(profile, monkeypatch): + monkeypatch.delenv("ALEO_E2E_API_KEY", raising=False) + monkeypatch.delenv("ALEO_E2E_CONSUMER_ID", raising=False) + api = _StubApi(has_access=True) + api._token = "jwt" + dex = _StubDex(api, {"waleo.aleo": 7}) + with pytest.raises(CredentialsMissingError): + run_onboard(dex, profile) + + +def test_rate_limited_airdrop_with_funds_is_tolerated(profile, dps_env): + api = _StubApi(has_access=True) + api._token = "jwt" + + def limited(address): + raise AirdropRateLimitedError() + + api.request_airdrop = limited + dex = _StubDex(api, {"waleo.aleo": 7}, funded_from_start=True) # already has funds + report = run_onboard(dex, profile) + airdrop = next(o for o in report.outcomes if o.name == "airdrop") + assert airdrop.action == "skipped" # funded → stage was done + assert report.funded is True + + +def test_stage_progress_journaled(profile, dps_env): + dex = _StubDex(_StubApi(), {"waleo.aleo": 7}) + run_onboard(dex, profile, invite_code="C", poll_seconds=0) + names = [e["name"] for e in Journal(profile.journal_path).events() + if e["type"] == "stage"] + assert names == [s.name for s in REGISTRATION_STAGES] From 32d6b1d397cb404427d2274d8fcd9959f1af7cb5 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 18:21:08 -0400 Subject: [PATCH 07/30] feat(shield-swap): status() and get_positions() discovery verbs --- .../python/aleo_shield_swap/client.py | 83 +++++++++++++++++++ .../python/aleo_shield_swap/types.py | 23 +++++ shield-swap-sdk/tests/test_status.py | 66 +++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 shield-swap-sdk/tests/test_status.py diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index a4ca16a..e478f52 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -35,6 +35,7 @@ InvalidFeeTierError, PoolNotFoundError, PoolNotInitializedError, + ShieldSwapError, SwapOutputNotFinalizedError, ) from .journal import Journal @@ -44,6 +45,8 @@ ClaimResult, MintResult, OnboardReport, + PositionView, + SessionStatus, SlotView, SwapHandle, TxResult, @@ -170,6 +173,86 @@ def is_pool_initialized(self, pool_key: str) -> bool: raw = self._mapping_value("initialized_pools", pool_key) return raw is not None and "true" in raw + # ── Discovery ──────────────────────────────────────────────────────────── + + def get_positions(self, account: Any = None) -> list[PositionView]: + """Every open position — journaled ones plus a record scan. + + 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. + """ + views: dict[Optional[str], PositionView] = {} + if self.journal is not None: + for p in self.journal.open_positions(): + views[p["position_token_id"]] = PositionView( + p["position_token_id"], p["pool_key"], "journal") + acct = self._account(account) + provider = getattr(self._aleo, "record_provider", None) + if provider is not None: + try: + records = list(provider.find(acct, program=self.program, + unspent=True)) + except Exception: + records = [] # scanner unavailable — journal only + for rec in records: + plaintext = (rec.get("record_plaintext") if isinstance(rec, dict) + else getattr(rec, "record_plaintext", None)) + if not plaintext: + continue + try: + decoded = parse_plaintext(plaintext) + except (ValueError, TypeError): + continue + if not (isinstance(decoded, dict) and "pool" in decoded): + continue + pid = decoded.get("position_token_id") + pid = str(pid) if pid is not None else None + if pid not in views: + views[pid] = PositionView(pid, str(decoded["pool"]), "scanned") + return list(views.values()) + + def status(self) -> SessionStatus: + """One re-orientation call: identity, access, holdings, pending work. + + Run this first in any session — it answers "is this account already + registered, what do I hold, what is in flight" from the profile, + journal, chain, and API without changing anything. + """ + authenticated = getattr(self.api, "_token", None) is not None + has_access: Optional[bool] = None + if authenticated: + try: + has_access = bool(self.api.access_status().has_access) + except ShieldSwapError: + has_access = None + address = (self.profile.address if self.profile + else str(self._account().address)) + try: + balances = self.get_balances() + except Exception: + # Private scan unavailable (e.g. credentials stage not run yet) — + # degrade to public-only rather than reporting nothing. + try: + balances = {b.token_id: {"symbol": b.symbol, + "decimals": b.decimals, + "public": int(b.balance), "private": 0, + "total": int(b.balance)} + for b in self.api.get_public_balances(address)} + except Exception: + balances = {} + pending = self.journal.pending_claims() if self.journal else [] + return SessionStatus( + address=address, + network=getattr(self._aleo, "network_name", "testnet"), + authenticated=authenticated, + has_access=has_access, + balances=balances, + pending_claim_ids=[h.swap_id for h in pending if h.swap_id], + open_positions=self.get_positions(), + counter_cursor=(self.journal.counter_cursor() if self.journal else 0), + ) + # ── Pure derivations (no network) ──────────────────────────────────────── def derive_pool_key(self, token0: str, token1: str, fee: int) -> str: diff --git a/shield-swap-sdk/python/aleo_shield_swap/types.py b/shield-swap-sdk/python/aleo_shield_swap/types.py index 29661d7..bc42838 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/types.py +++ b/shield-swap-sdk/python/aleo_shield_swap/types.py @@ -124,3 +124,26 @@ class OnboardReport: address: str outcomes: list[StageOutcome] funded: bool + + +@dataclass +class PositionView: + """An open position: journaled, or discovered by scanning records.""" + + position_token_id: Optional[str] + pool_key: str + source: str # "journal" | "scanned" + + +@dataclass +class SessionStatus: + """Everything an agent needs to re-orient in one call.""" + + address: str + network: str + authenticated: bool + has_access: Optional[bool] # None when not authenticated / unreachable + balances: dict + pending_claim_ids: list[str] + open_positions: list[PositionView] + counter_cursor: int diff --git a/shield-swap-sdk/tests/test_status.py b/shield-swap-sdk/tests/test_status.py new file mode 100644 index 0000000..5df332f --- /dev/null +++ b/shield-swap-sdk/tests/test_status.py @@ -0,0 +1,66 @@ +import pytest + +from aleo_shield_swap import ShieldSwap +from aleo_shield_swap.journal import Journal +from aleo_shield_swap.profile import Profile + +POS_A = "{ owner: aleo1x.private, pool: 1field.private, tick_lower: -60i32.private, tick_upper: 60i32.private, liquidity: 5u128.private, position_token_id: 11field.private }" +POS_B = "{ owner: aleo1x.private, pool: 2field.private, tick_lower: -10i32.private, tick_upper: 10i32.private, liquidity: 9u128.private, position_token_id: 22field.private }" + + +class _Provider: + def find(self, account, program=None, unspent=True): + return [{"record_plaintext": POS_A}, {"record_plaintext": POS_B}] + + +class _Account: + address = "aleo1x" + + +class _Facade: + record_provider = _Provider() + default_account = _Account() + network_name = "testnet" + + +@pytest.fixture +def dex(tmp_path): + d = ShieldSwap(_Facade()) + d.profile = Profile.load_or_create(tmp_path / "home") + d.journal = Journal(d.profile.journal_path) + return d + + +def test_get_positions_merges_journal_and_scan(dex): + dex.journal.record_position("11field", "1field", "tx1") + views = dex.get_positions() + by_id = {v.position_token_id: v.source for v in views} + assert by_id == {"11field": "journal", "22field": "scanned"} + assert {v.pool_key for v in views} == {"1field", "2field"} + + +def test_status_reorients_from_disk(dex, monkeypatch): + dex.api.set_token("jwt") + monkeypatch.setattr(dex.api, "access_status", + lambda: type("S", (), {"has_access": True})()) + monkeypatch.setattr(dex, "get_balances", lambda: {"tok": {"total": 5}}) + dex.journal.append( + "swap", counter=0, swap_id="s1", blinding_factor="bf", + blinded_address="ba", token_in_id="t0", token_out_id="t1", + pool_key="1field", amount_in=5, transaction_id="tx", program="p") + st = dex.status() + assert st.authenticated is True and st.has_access is True + assert st.pending_claim_ids == ["s1"] + assert st.counter_cursor == 1 + assert st.balances == {"tok": {"total": 5}} + assert st.address == dex.profile.address + + +def test_status_survives_unauthenticated_api(dex, monkeypatch): + def boom(): + raise AssertionError("must not be called without a token") + + monkeypatch.setattr(dex.api, "access_status", boom) + monkeypatch.setattr(dex, "get_balances", lambda: {}) + st = dex.status() # no token set + assert st.authenticated is False and st.has_access is None From 871f4654351356080b170b353ba57df338de8709 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 18:23:53 -0400 Subject: [PATCH 08/30] feat(shield-swap): identity override + counter-safe swap_many --- .../python/aleo_shield_swap/client.py | 46 +++++++++++- .../python/aleo_shield_swap/derivations.py | 20 +++++ .../python/aleo_shield_swap/types.py | 8 ++ shield-swap-sdk/tests/test_blinding.py | 25 +++++++ shield-swap-sdk/tests/test_swap_many.py | 74 +++++++++++++++++++ 5 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 shield-swap-sdk/tests/test_swap_many.py diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index e478f52..1655428 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -26,6 +26,8 @@ ) from .api import ApiClient, DEFAULT_API_URL from .derivations import ( + BlindedIdentity, + blinded_identity_at, derive_pool_key as _derive_pool_key, derive_tick_key as _derive_tick_key, next_blinded_identity, @@ -48,6 +50,7 @@ PositionView, SessionStatus, SlotView, + SwapBatchReport, SwapHandle, TxResult, ) @@ -349,6 +352,7 @@ def swap( sqrt_price_limit: Optional[int] = None, deadline_offset_blocks: int = 100, nonce: Optional[int] = None, + identity: Optional[BlindedIdentity] = None, token_in_program: Optional[str] = None, token_record: Optional[str] = None, imports: Optional[dict[str, str]] = None, @@ -365,6 +369,8 @@ 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. """ acct = self._account(account) pool = self.get_pool(pool_key) @@ -376,7 +382,7 @@ def swap( ) deadline = get_deadline(self._aleo, deadline_offset_blocks) swap_nonce = nonce if nonce is not None else generate_swap_nonce() - identity = next_blinded_identity(self._aleo, acct, self.program) + identity = identity or next_blinded_identity(self._aleo, acct, self.program) record = token_record if record is None: @@ -493,6 +499,44 @@ def _token_program(self, token_id: str) -> str: # ── Liquidity ──────────────────────────────────────────────────────────── + def swap_many( + self, + *, + pool_key: str, + token_in_id: str, + amount_in: int, + count: int, + slippage_bps: int = 50, + account: Any = None, + ) -> SwapBatchReport: + """*count* private swaps of *amount_in* each, with reserved counters. + + Counters come from the journal (no probe races); every handle is + journaled before the next swap fires, so a crash mid-batch loses + nothing — ``collect_all()`` later claims whatever landed. A failed + swap burns its counter and the batch continues; failures are + reported, not raised. Requires ``from_profile()``. + """ + if self.journal is None: + raise ValueError("swap_many() needs a journal — construct with " + "ShieldSwap.from_profile().") + acct = self._account(account) + counters = self.journal.reserve_counters(count) + handles: list[SwapHandle] = [] + failures: list[dict] = [] + for counter in counters: + ident = blinded_identity_at(self._aleo, acct, self.program, counter) + try: + handle = self.swap(pool_key=pool_key, token_in_id=token_in_id, + amount_in=amount_in, slippage_bps=slippage_bps, + identity=ident, account=acct).delegate(acct) + self.journal.record_swap(handle, counter) + handles.append(handle) + except Exception as exc: # journal + continue + self.journal.record_swap_failed(counter, str(exc)) + failures.append({"counter": counter, "error": str(exc)}) + return SwapBatchReport(handles=handles, failures=failures) + def create_pool( self, *, diff --git a/shield-swap-sdk/python/aleo_shield_swap/derivations.py b/shield-swap-sdk/python/aleo_shield_swap/derivations.py index 29d624b..f1b7a7d 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/derivations.py +++ b/shield-swap-sdk/python/aleo_shield_swap/derivations.py @@ -177,3 +177,23 @@ def next_blinded_identity( f"No unused blinded address in counters [{start_counter}, " f"{start_counter + max_scan}) for {program} — wrong program or scan range?" ) + + +def blinded_identity_at( + aleo: Any, + account: Any, + program: str, + counter: int, +) -> BlindedIdentity: + """The identity at an exact *counter* — no on-chain probing. + + Use with journal-reserved counters for concurrent swaps; + :func:`next_blinded_identity` (probe-based) remains the recovery path + when no journal exists. + """ + network = aleo.network_name + scalar = str(account.view_key.to_scalar()) + signer = str(account.address) + bf = derive_blinding_factor(scalar, counter, program, network=network) + ba = derive_blinded_address(bf, signer, program, network=network) + return BlindedIdentity(counter, bf, ba) diff --git a/shield-swap-sdk/python/aleo_shield_swap/types.py b/shield-swap-sdk/python/aleo_shield_swap/types.py index bc42838..95ae3db 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/types.py +++ b/shield-swap-sdk/python/aleo_shield_swap/types.py @@ -147,3 +147,11 @@ class SessionStatus: pending_claim_ids: list[str] open_positions: list[PositionView] counter_cursor: int + + +@dataclass +class SwapBatchReport: + """``swap_many()`` outcome — journaled handles plus per-counter failures.""" + + handles: list[SwapHandle] + failures: list[dict] diff --git a/shield-swap-sdk/tests/test_blinding.py b/shield-swap-sdk/tests/test_blinding.py index e40399b..2e56b8a 100644 --- a/shield-swap-sdk/tests/test_blinding.py +++ b/shield-swap-sdk/tests/test_blinding.py @@ -90,3 +90,28 @@ def test_next_blinded_identity_max_scan(): used = {VECTORS[0][2], VECTORS[1][2]} with pytest.raises(ValueError, match="No unused blinded address"): next_blinded_identity(_StubAleo(used=used), _StubAccount(), max_scan=2) + + +def test_blinded_identity_at_exact_counter_no_probe(): + from aleo_shield_swap.derivations import blinded_identity_at + + class _VK: + def to_scalar(self): + return VIEW_KEY_SCALAR + + class _Acct: + view_key = _VK() + address = SIGNER + + class _Aleo: + network_name = "testnet" + + @property + def programs(self): # any chain probe is a bug + raise AssertionError("blinded_identity_at must not touch the chain") + + for counter, bf, ba in VECTORS: + ident = blinded_identity_at(_Aleo(), _Acct(), "shield_swap_v3.aleo", + counter) + assert (ident.counter, ident.blinding_factor, + ident.blinded_address) == (counter, bf, ba) diff --git a/shield-swap-sdk/tests/test_swap_many.py b/shield-swap-sdk/tests/test_swap_many.py new file mode 100644 index 0000000..0a4ae58 --- /dev/null +++ b/shield-swap-sdk/tests/test_swap_many.py @@ -0,0 +1,74 @@ +import pytest + +from aleo_shield_swap import ShieldSwap +from aleo_shield_swap.journal import Journal +from aleo_shield_swap.profile import Profile +from aleo_shield_swap.types import SwapHandle + + +class _Facade: + network_name = "testnet" + + +@pytest.fixture +def dex(tmp_path, monkeypatch): + d = ShieldSwap(_Facade()) + d.profile = Profile.load_or_create(tmp_path / "home") + d.journal = Journal(d.profile.journal_path) + monkeypatch.setattr(ShieldSwap, "_account", lambda self, a=None: object()) + monkeypatch.setattr( + "aleo_shield_swap.client.blinded_identity_at", + lambda aleo, acct, prog, c: type( + "I", (), {"counter": c, "blinding_factor": f"bf{c}", + "blinded_address": f"ba{c}"})()) + return d + + +def _fake_swap_factory(fail_counters=()): + calls = [] + + def fake_swap(self, *, pool_key, token_in_id, amount_in, identity=None, **kw): + calls.append(identity.counter) + + class _Call: + def delegate(inner, account=None): + if identity.counter in fail_counters: + raise RuntimeError(f"boom at {identity.counter}") + return SwapHandle(swap_id=f"s{identity.counter}", + blinding_factor=identity.blinding_factor, + blinded_address=identity.blinded_address, + token_in_id=token_in_id, token_out_id="t1", + pool_key=pool_key, amount_in=amount_in, + transaction_id=f"tx{identity.counter}", + program="shield_swap_v3.aleo") + return _Call() + + return fake_swap, calls + + +def test_swap_many_reserves_distinct_counters_and_journals(dex, monkeypatch): + fake, calls = _fake_swap_factory() + monkeypatch.setattr(ShieldSwap, "swap", fake) + report = dex.swap_many(pool_key="1field", token_in_id="t0", + amount_in=5, count=3) + assert calls == [0, 1, 2] + assert [h.swap_id for h in report.handles] == ["s0", "s1", "s2"] + assert report.failures == [] + assert [h.swap_id for h in dex.journal.pending_claims()] == ["s0", "s1", "s2"] + + +def test_swap_many_burns_failed_counter_and_continues(dex, monkeypatch): + fake, calls = _fake_swap_factory(fail_counters={1}) + monkeypatch.setattr(ShieldSwap, "swap", fake) + report = dex.swap_many(pool_key="1field", token_in_id="t0", + amount_in=5, count=3) + assert [h.swap_id for h in report.handles] == ["s0", "s2"] + assert report.failures == [{"counter": 1, "error": "boom at 1"}] + assert dex.journal.counter_cursor() == 3 # 1 burned, not reused + assert [h.swap_id for h in dex.journal.pending_claims()] == ["s0", "s2"] + + +def test_swap_many_requires_journal(dex): + dex.journal = None + with pytest.raises(ValueError, match="from_profile"): + dex.swap_many(pool_key="1field", token_in_id="t0", amount_in=5, count=2) From 47dcdef7ff93e83aed76d25e7a81cb197ad1eaf7 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 18:26:14 -0400 Subject: [PATCH 09/30] =?UTF-8?q?feat(shield-swap):=20collect=5Fall=20?= =?UTF-8?q?=E2=80=94=20claims=20+=20owed-exact=20fee=20collection=20off=20?= =?UTF-8?q?the=20journal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../python/aleo_shield_swap/client.py | 52 ++++++++ .../python/aleo_shield_swap/types.py | 9 ++ shield-swap-sdk/tests/test_collect_all.py | 119 ++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 shield-swap-sdk/tests/test_collect_all.py diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 1655428..02f728b 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -45,6 +45,7 @@ from .profile import Profile from .types import ( ClaimResult, + CollectReport, MintResult, OnboardReport, PositionView, @@ -537,6 +538,57 @@ def swap_many( failures.append({"counter": counter, "error": str(exc)}) return SwapBatchReport(handles=handles, failures=failures) + def _position_state(self, position_token_id: str) -> Optional[g.Position]: + """The on-chain position entry (owed amounts), or None if absent.""" + raw = self._mapping_value("positions", position_token_id) + return g.Position.from_plaintext(raw) if raw is not None else None + + def collect_all(self, account: Any = None) -> CollectReport: + """Claim every finalized swap and collect owed fees on open positions. + + Safe to run any time, from any session: works off the journal, skips + swaps whose finalize hasn't landed (they stay pending for next time), + never double-claims, and requests exactly the owed amounts the chain + reports. Requires ``from_profile()``. + """ + if self.journal is None: + raise ValueError("collect_all() needs a journal — construct with " + "ShieldSwap.from_profile().") + acct = self._account(account) + claimed: list[dict] = [] + still_pending: list[str] = [] + for handle in self.journal.pending_claims(): + try: + res = self.claim_swap_output(handle, account=acct).delegate(acct) + except SwapOutputNotFinalizedError: + still_pending.append(handle.swap_id or "") + continue + self.journal.record_claim(handle.swap_id or "", res.transaction_id, + res.amount_out) + claimed.append({"swap_id": handle.swap_id, + "transaction_id": res.transaction_id, + "amount_out": res.amount_out}) + fees: list[dict] = [] + for view in self.get_positions(account=acct): + if view.source != "journal" or not view.position_token_id: + continue # scanned-only positions lack journal context + pos = self._position_state(view.position_token_id) + if pos is None or (pos.tokens_owed0 == 0 and pos.tokens_owed1 == 0): + continue + pool = self.get_pool(view.pool_key) + # The contract asserts requested <= owed and requested % scale == 0; + # owed is stored in scaled units, so request exactly owed * scale. + res = self.collect( + pool_key=view.pool_key, + amount0_requested=pos.tokens_owed0 * pool.scale0, + amount1_requested=pos.tokens_owed1 * pool.scale1, + account=acct, + ).delegate(acct) + fees.append({"position_token_id": view.position_token_id, + "pool_key": view.pool_key, + "transaction_id": res.transaction_id}) + return CollectReport(claimed, still_pending, fees) + def create_pool( self, *, diff --git a/shield-swap-sdk/python/aleo_shield_swap/types.py b/shield-swap-sdk/python/aleo_shield_swap/types.py index 95ae3db..368130d 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/types.py +++ b/shield-swap-sdk/python/aleo_shield_swap/types.py @@ -155,3 +155,12 @@ class SwapBatchReport: handles: list[SwapHandle] failures: list[dict] + + +@dataclass +class CollectReport: + """``collect_all()`` outcome — what was claimed, what is not ready yet.""" + + claimed: list[dict] + still_pending: list[str] + fees: list[dict] diff --git a/shield-swap-sdk/tests/test_collect_all.py b/shield-swap-sdk/tests/test_collect_all.py new file mode 100644 index 0000000..a743280 --- /dev/null +++ b/shield-swap-sdk/tests/test_collect_all.py @@ -0,0 +1,119 @@ +import pytest + +from aleo_shield_swap import ShieldSwap +from aleo_shield_swap.errors import SwapOutputNotFinalizedError +from aleo_shield_swap.journal import Journal +from aleo_shield_swap.profile import Profile +from aleo_shield_swap.types import ClaimResult, PositionView, SwapHandle, TxResult + + +def _handle(sid): + return SwapHandle(swap_id=sid, blinding_factor="bf", blinded_address="ba", + token_in_id="t0", token_out_id="t1", pool_key="1field", + amount_in=5, transaction_id="tx", program="p") + + +class _Facade: + network_name = "testnet" + + +@pytest.fixture +def dex(tmp_path, monkeypatch): + d = ShieldSwap(_Facade()) + d.profile = Profile.load_or_create(tmp_path / "home") + d.journal = Journal(d.profile.journal_path) + monkeypatch.setattr(ShieldSwap, "_account", lambda self, a=None: object()) + return d + + +def test_collect_all_claims_finalized_skips_pending(dex, monkeypatch): + dex.journal.record_swap(_handle("ok"), 0) + dex.journal.record_swap(_handle("early"), 1) + + def fake_claim(self, handle, **kw): + class _Call: + def delegate(inner, account=None): + if handle.swap_id == "early": + raise SwapOutputNotFinalizedError("early") + return ClaimResult("txc", 42, 0) + return _Call() + + monkeypatch.setattr(ShieldSwap, "claim_swap_output", fake_claim) + monkeypatch.setattr(ShieldSwap, "get_positions", + lambda self, account=None: []) + + report = dex.collect_all() + assert report.claimed == [{"swap_id": "ok", "transaction_id": "txc", + "amount_out": 42}] + assert report.still_pending == ["early"] + assert [h.swap_id for h in dex.journal.pending_claims()] == ["early"] + + +def test_collect_all_rerun_is_idempotent(dex, monkeypatch): + dex.journal.record_swap(_handle("ok"), 0) + calls = [] + + def fake_claim(self, handle, **kw): + calls.append(handle.swap_id) + + class _Call: + def delegate(inner, account=None): + return ClaimResult("txc", 42, 0) + return _Call() + + monkeypatch.setattr(ShieldSwap, "claim_swap_output", fake_claim) + monkeypatch.setattr(ShieldSwap, "get_positions", + lambda self, account=None: []) + dex.collect_all() + report2 = dex.collect_all() + assert calls == ["ok"] # not re-claimed + assert report2.claimed == [] and report2.still_pending == [] + + +def test_collect_all_requests_exactly_owed_fees(dex, monkeypatch): + class _Pos: + tokens_owed0 = 3 + tokens_owed1 = 0 + + class _Pool: + scale0 = 1000 + scale1 = 10 + + monkeypatch.setattr(ShieldSwap, "get_positions", lambda self, account=None: [ + PositionView("11field", "1field", "journal"), + PositionView("22field", "2field", "scanned"), # skipped: no journal context + ]) + monkeypatch.setattr(ShieldSwap, "_position_state", + lambda self, pid: _Pos() if pid == "11field" else None) + monkeypatch.setattr(ShieldSwap, "get_pool", lambda self, key: _Pool()) + collected = [] + + def fake_collect(self, *, pool_key, amount0_requested, amount1_requested, + account=None, **kw): + collected.append((pool_key, amount0_requested, amount1_requested)) + + class _Call: + def delegate(inner, account=None): + return TxResult("11field", "txf") + return _Call() + + monkeypatch.setattr(ShieldSwap, "collect", fake_collect) + report = dex.collect_all() + assert collected == [("1field", 3000, 0)] # owed * scale, exactly + assert report.fees == [{"position_token_id": "11field", + "pool_key": "1field", "transaction_id": "txf"}] + + +def test_collect_all_skips_zero_owed_positions(dex, monkeypatch): + class _Pos: + tokens_owed0 = 0 + tokens_owed1 = 0 + + monkeypatch.setattr(ShieldSwap, "get_positions", lambda self, account=None: [ + PositionView("11field", "1field", "journal")]) + monkeypatch.setattr(ShieldSwap, "_position_state", + lambda self, pid: _Pos()) + monkeypatch.setattr(ShieldSwap, "collect", + lambda self, **kw: pytest.fail("must not collect zero")) + report = dex.collect_all() + assert report.fees == [] From d7febbf3f46eacc20a76f2496548e527bffa14a0 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 18:34:05 -0400 Subject: [PATCH 10/30] =?UTF-8?q?fix(shield-swap):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20live=20credential=20refresh,=20locked=20journal=20a?= =?UTF-8?q?ppends,=20scanned-position=20id,=20atomic=20profile=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - onboard() on a fresh profile now works end to end: the credentials stage pushes the provisioned DPS key onto the live provider/network client/ scanner instead of requiring a rebuilt client - journal appends take the same advisory lock as counter reservation (no torn JSONL lines); empty counter reservations are no-ops; malformed events are skipped, id-less swaps excluded from pending claims - scanned PositionNFTs read token_id (the actual record field) - profile files are written atomically with 0600 from birth; a shared record_plaintext helper replaces four copy-pasted extractions --- .../python/aleo_shield_swap/_core.py | 13 ++- .../python/aleo_shield_swap/client.py | 40 ++++++- .../python/aleo_shield_swap/journal.py | 65 +++++++---- .../python/aleo_shield_swap/lifecycle.py | 3 + .../python/aleo_shield_swap/profile.py | 9 +- shield-swap-sdk/tests/test_lifecycle.py | 14 +++ shield-swap-sdk/tests/test_live_api.py | 106 ++++++++++++++++++ shield-swap-sdk/tests/test_status.py | 4 +- 8 files changed, 219 insertions(+), 35 deletions(-) create mode 100644 shield-swap-sdk/tests/test_live_api.py diff --git a/shield-swap-sdk/python/aleo_shield_swap/_core.py b/shield-swap-sdk/python/aleo_shield_swap/_core.py index b8f7eff..b775e2b 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_core.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_core.py @@ -140,8 +140,7 @@ def pick_covering_record(records: Any, *, min_amount: int, """ candidates: list[tuple[int, str]] = [] for rec in records: - plaintext = (rec.get("record_plaintext") if isinstance(rec, dict) - else getattr(rec, "record_plaintext", None)) + plaintext = record_plaintext(rec) if not plaintext: continue info = parse_token_record_info(plaintext) @@ -153,11 +152,17 @@ def pick_covering_record(records: Any, *, min_amount: int, return min(candidates)[1] if candidates else None +def record_plaintext(rec: Any) -> Optional[str]: + """The decrypted plaintext of a provider record, dict- or object-shaped.""" + if isinstance(rec, dict): + return rec.get("record_plaintext") + return getattr(rec, "record_plaintext", None) + + def find_position_plaintext(records: Any, pool_key: str) -> Optional[str]: """First unspent PositionNFT plaintext whose ``pool`` matches, or None.""" for rec in records: - plaintext = (rec.get("record_plaintext") if isinstance(rec, dict) - else getattr(rec, "record_plaintext", None)) + plaintext = record_plaintext(rec) if not plaintext: continue try: diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 02f728b..abd9c72 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -16,6 +16,7 @@ from ._core import ( ensure_programs, find_position_plaintext, + record_plaintext, generate_field_nonce, generate_swap_nonce, get_deadline, @@ -116,6 +117,37 @@ def from_profile(cls, home: Any = None) -> "ShieldSwap": dex.api.set_token(creds["jwt"]) return dex + def _refresh_credentials(self) -> None: + """Push freshly provisioned profile credentials onto the live facade. + + ``from_profile()`` builds the provider before the credentials stage + has run, so a fresh ``onboard()`` re-applies them here instead of + requiring a rebuilt client. + """ + creds = self.profile.credentials if self.profile else {} + key = creds.get("dps_api_key") + cid = creds.get("dps_consumer_id") + if not key: + return + provider = getattr(self._aleo, "provider", None) + if provider is not None: # future lazy builds (e.g. scanner) + provider._api_key = key + if cid: + provider._consumer_id = cid + nc = getattr(self._aleo, "network_client", None) + if nc is not None: # delegated proving + nc.api_key = key + if cid: + nc.consumer_id = cid + records = getattr(self._aleo, "records", None) + if records is not None: # already-built scanner + try: + records.scanner.set_api_key(key) + if cid: + records.scanner.consumer_id = cid + except Exception: + pass # scanner unreachable — built lazily later + def onboard(self, invite_code: Optional[str] = None) -> OnboardReport: """Register this profile end to end — safe to re-run any time. @@ -200,8 +232,7 @@ def get_positions(self, account: Any = None) -> list[PositionView]: except Exception: records = [] # scanner unavailable — journal only for rec in records: - plaintext = (rec.get("record_plaintext") if isinstance(rec, dict) - else getattr(rec, "record_plaintext", None)) + plaintext = record_plaintext(rec) if not plaintext: continue try: @@ -210,7 +241,7 @@ def get_positions(self, account: Any = None) -> list[PositionView]: continue if not (isinstance(decoded, dict) and "pool" in decoded): continue - pid = decoded.get("position_token_id") + pid = decoded.get("token_id") # PositionNFT's id field pid = str(pid) if pid is not None else None if pid not in views: views[pid] = PositionView(pid, str(decoded["pool"]), "scanned") @@ -277,8 +308,7 @@ def get_private_balances(self, programs: list[str], for program in programs: total = 0 for rec in provider.find(account, program=program, unspent=True): - plaintext = (rec.get("record_plaintext") if isinstance(rec, dict) - else getattr(rec, "record_plaintext", None)) + plaintext = record_plaintext(rec) info = parse_token_record_info(plaintext) if plaintext else None if info is not None: total += info["amount"] diff --git a/shield-swap-sdk/python/aleo_shield_swap/journal.py b/shield-swap-sdk/python/aleo_shield_swap/journal.py index b6640b7..be81c87 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/journal.py +++ b/shield-swap-sdk/python/aleo_shield_swap/journal.py @@ -15,8 +15,9 @@ import fcntl import json import time +from contextlib import contextmanager from pathlib import Path -from typing import Any +from typing import Any, Iterator from .types import SwapHandle @@ -35,13 +36,24 @@ def __init__(self, path: "Path | str") -> None: def __repr__(self) -> str: return f"Journal({str(self.path)!r})" + @contextmanager + def _locked(self) -> Iterator[None]: + """Advisory lock shared by every writer (and the counter reader).""" + self.path.parent.mkdir(parents=True, exist_ok=True) + with self._lock_path.open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + # ── Raw events ─────────────────────────────────────────────────────────── def append(self, type: str, **fields: Any) -> None: event = {"type": type, "ts": time.time(), **fields} - self.path.parent.mkdir(parents=True, exist_ok=True) - with self.path.open("a") as f: - f.write(json.dumps(event) + "\n") + with self._locked(): + with self.path.open("a") as f: + f.write(json.dumps(event) + "\n") def events(self) -> list[dict[str, Any]]: if not self.path.exists(): @@ -53,23 +65,23 @@ def events(self) -> list[dict[str, Any]]: def reserve_counters(self, n: int) -> list[int]: """Issue the next *n* counters, exactly once, under a file lock.""" - self.path.parent.mkdir(parents=True, exist_ok=True) - with self._lock_path.open("a") as lock: - fcntl.flock(lock, fcntl.LOCK_EX) - try: - start = self.counter_cursor() - counters = list(range(start, start + n)) - self.append("counters_reserved", counters=counters) - return counters - finally: - fcntl.flock(lock, fcntl.LOCK_UN) + if n <= 0: + return [] + with self._locked(): + start = self.counter_cursor() + counters = list(range(start, start + n)) + event = {"type": "counters_reserved", "ts": time.time(), + "counters": counters} + with self.path.open("a") as f: # already under the lock + f.write(json.dumps(event) + "\n") + return counters def counter_cursor(self) -> int: """Next unissued counter (max seen in any event + 1).""" top = -1 for e in self.events(): if e["type"] == "counters_reserved": - top = max(top, *e["counters"]) + top = max(top, *e.get("counters") or [-1]) elif e["type"] in ("swap", "swap_failed"): top = max(top, e.get("counter", -1)) return top + 1 @@ -104,17 +116,26 @@ def record_stage(self, name: str, action: str, detail: str = "") -> None: # ── Derived state ──────────────────────────────────────────────────────── def pending_claims(self) -> list[SwapHandle]: - """Swaps recorded but never claimed, as re-hydrated handles.""" - claimed = {e["swap_id"] for e in self.events() if e["type"] == "claim"} - return [SwapHandle(**{k: e[k] for k in _HANDLE_FIELDS}) - for e in self.events() - if e["type"] == "swap" and e["swap_id"] not in claimed] + """Claimable swaps: recorded with a swap id and never claimed.""" + events = self.events() + claimed = {e["swap_id"] for e in events if e["type"] == "claim"} + out: list[SwapHandle] = [] + for e in events: + if e["type"] != "swap" or not e.get("swap_id"): + continue # id-less swaps need manual recovery + if e["swap_id"] in claimed: + continue + if not all(k in e for k in _HANDLE_FIELDS): + continue # legacy/malformed event — skip + out.append(SwapHandle(**{k: e[k] for k in _HANDLE_FIELDS})) + return out def open_positions(self) -> list[dict[str, Any]]: """Positions recorded and not burned: {position_token_id, pool_key}.""" - burned = {e["position_token_id"] for e in self.events() + events = self.events() + burned = {e["position_token_id"] for e in events if e["type"] == "position_burned"} return [{"position_token_id": e["position_token_id"], "pool_key": e["pool_key"]} - for e in self.events() + for e in events if e["type"] == "position" and e["position_token_id"] not in burned] diff --git a/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py b/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py index 3929263..1430cc4 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py +++ b/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py @@ -95,6 +95,9 @@ def _creds_run(ctx: _Ctx) -> str: if not (key and cid): raise CredentialsMissingError() ctx.profile.save_credentials(dps_api_key=key, dps_consumer_id=cid) + refresh = getattr(ctx.dex, "_refresh_credentials", None) + if refresh is not None: + refresh() # live facade picks up the new key return "delegated-proving credentials imported from env" diff --git a/shield-swap-sdk/python/aleo_shield_swap/profile.py b/shield-swap-sdk/python/aleo_shield_swap/profile.py index 7a3d2fa..26da8dd 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/profile.py +++ b/shield-swap-sdk/python/aleo_shield_swap/profile.py @@ -18,8 +18,12 @@ def _write_private(path: Path, payload: dict[str, Any]) -> None: - path.write_text(json.dumps(payload, indent=1)) - path.chmod(0o600) + """Owner-only file, written atomically (no umask window, no torn reads).""" + tmp = path.with_suffix(path.suffix + ".tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + f.write(json.dumps(payload, indent=1)) + os.replace(tmp, path) def _generate_key(network: str) -> tuple[str, str]: @@ -58,6 +62,7 @@ def load_or_create(cls, home: "Path | str | None" = None, *, home = Path(home) if home is not None else cls.default_home() path = home / _PROFILE if path.exists(): + path.chmod(0o600) # heal a pre-existing loose mode return cls(home, json.loads(path.read_text())) home.mkdir(parents=True, exist_ok=True) private_key, address = _generate_key(network) diff --git a/shield-swap-sdk/tests/test_lifecycle.py b/shield-swap-sdk/tests/test_lifecycle.py index 6bdcfa3..097238a 100644 --- a/shield-swap-sdk/tests/test_lifecycle.py +++ b/shield-swap-sdk/tests/test_lifecycle.py @@ -131,3 +131,17 @@ def test_stage_progress_journaled(profile, dps_env): names = [e["name"] for e in Journal(profile.journal_path).events() if e["type"] == "stage"] assert names == [s.name for s in REGISTRATION_STAGES] + + +def test_credentials_stage_refreshes_live_facade(profile, dps_env): + refreshed = [] + + class _RefreshingDex(_StubDex): + def _refresh_credentials(self): + refreshed.append(True) + + api = _StubApi(has_access=True) + api._token = "jwt" + dex = _RefreshingDex(api, {"waleo.aleo": 7}, funded_from_start=True) + run_onboard(dex, profile) + assert refreshed == [True] # live provider picked up the new key diff --git a/shield-swap-sdk/tests/test_live_api.py b/shield-swap-sdk/tests/test_live_api.py new file mode 100644 index 0000000..dfeae3f --- /dev/null +++ b/shield-swap-sdk/tests/test_live_api.py @@ -0,0 +1,106 @@ +"""Live smoke tests against the deployed amm-api — auth handshake included. + +Opt-in: set ``SHIELD_SWAP_LIVE=1`` to run (they hit the network). The funded +half additionally needs ``ALEO_E2E_PRIVATE_KEY`` — an account that has +redeemed an invite code (``/access/status`` → ``has_access: true``). + +Verified live invariants: + * challenge/verify works for ANY account — signature only, no funds; + * gated endpoints 401 without a token and 403 without redeemed access; + * with the e2e account: route quoting, OHLCV, and balances succeed. +""" +from __future__ import annotations + +import os +import time + +import pytest + +import aleo +from aleo_shield_swap import ApiClient +from aleo_shield_swap.errors import DexApiError + +pytestmark = pytest.mark.skipif( + os.environ.get("SHIELD_SWAP_LIVE") != "1", + reason="live API tests — set SHIELD_SWAP_LIVE=1 to run", +) + + +def _authed_client(pk) -> ApiClient: + api = ApiClient() + api.authenticate(str(pk.address), lambda msg: str(pk.sign(msg.encode()))) + return api + + +@pytest.fixture(scope="module") +def funded_api() -> ApiClient: + key = os.environ.get("ALEO_E2E_PRIVATE_KEY") + if not key: + pytest.skip("ALEO_E2E_PRIVATE_KEY not set") + pk = aleo.testnet.PrivateKey.from_string(key) + api = _authed_client(pk) + status = api._get("/access/status")["data"] + if not status.get("has_access"): + pytest.skip("e2e account has not redeemed an invite code") + return api + + +def test_authenticate_needs_no_funds(): + pk = aleo.testnet.PrivateKey.random() + api = _authed_client(pk) + assert api._token and api._token.count(".") == 2 # JWT shape + + +def test_gated_endpoint_rejects_missing_token(): + api = ApiClient() + pools = api.get_pools() + with pytest.raises(DexApiError) as exc: + api.get_route(token_in=pools[0].token0, token_out=pools[0].token1, + amount_in=1_000) + assert exc.value.status == 401 + + +def test_gated_endpoint_rejects_unredeemed_account(): + pk = aleo.testnet.PrivateKey.random() + api = _authed_client(pk) + assert api._get("/access/status")["data"]["has_access"] is False + pools = api.get_pools() + with pytest.raises(DexApiError) as exc: + api.get_route(token_in=pools[0].token0, token_out=pools[0].token1, + amount_in=1_000) + assert exc.value.status == 403 + + +def test_funded_route_quote(funded_api): + pools = funded_api.get_pools() + assert pools + # Quote against the first pool that has an executable route in either + # direction — pools can legitimately lack one-directional liquidity. + for p in pools: + for tin, tout in ((p.token1, p.token0), (p.token0, p.token1)): + try: + route = funded_api.get_route(token_in=tin, token_out=tout, + amount_in=1_000) + except DexApiError as e: + if e.status == 404: + continue + raise + assert route.hops + assert route.token_in == tin + return + pytest.fail("no pool had an executable route in either direction") + + +def test_funded_ohlcv(funded_api): + pool = funded_api.get_pools()[0] + now = int(time.time()) + candles = funded_api.get_ohlcv(pool.key, granularity="1h", + from_ts=now - 86_400, to_ts=now) + assert isinstance(candles, list) # may be empty on a quiet pool + + +def test_funded_balances(funded_api): + key = os.environ["ALEO_E2E_PRIVATE_KEY"] + addr = str(aleo.testnet.PrivateKey.from_string(key).address) + balances = funded_api.get_public_balances(addr) + assert isinstance(balances, list) diff --git a/shield-swap-sdk/tests/test_status.py b/shield-swap-sdk/tests/test_status.py index 5df332f..e8e7c05 100644 --- a/shield-swap-sdk/tests/test_status.py +++ b/shield-swap-sdk/tests/test_status.py @@ -4,8 +4,8 @@ from aleo_shield_swap.journal import Journal from aleo_shield_swap.profile import Profile -POS_A = "{ owner: aleo1x.private, pool: 1field.private, tick_lower: -60i32.private, tick_upper: 60i32.private, liquidity: 5u128.private, position_token_id: 11field.private }" -POS_B = "{ owner: aleo1x.private, pool: 2field.private, tick_lower: -10i32.private, tick_upper: 10i32.private, liquidity: 9u128.private, position_token_id: 22field.private }" +POS_A = "{ owner: aleo1x.private, pool: 1field.private, tick_lower: -60i32.private, tick_upper: 60i32.private, liquidity: 5u128.private, token_id: 11field.private }" +POS_B = "{ owner: aleo1x.private, pool: 2field.private, tick_lower: -10i32.private, tick_upper: 10i32.private, liquidity: 9u128.private, token_id: 22field.private }" class _Provider: From 208c19acd9ea43300c70f022e007ccf790a0d585 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 18:37:24 -0400 Subject: [PATCH 11/30] feat(shield-swap): curated 11-tool agent surface, MCP binds the local profile --- .../python/aleo_shield_swap/agent.py | 163 +++++++++--------- .../python/aleo_shield_swap/mcp.py | 9 +- shield-swap-sdk/tests/test_agent.py | 127 ++++++++++++-- shield-swap-sdk/tests/test_mcp.py | 14 +- 4 files changed, 209 insertions(+), 104 deletions(-) diff --git a/shield-swap-sdk/python/aleo_shield_swap/agent.py b/shield-swap-sdk/python/aleo_shield_swap/agent.py index 6404ed9..047da39 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/agent.py +++ b/shield-swap-sdk/python/aleo_shield_swap/agent.py @@ -4,17 +4,16 @@ shape (name / description / input_schema) — they plug into any framework that speaks JSON-schema tools. ``dispatch_tool(dex, name, args)`` executes one against a :class:`~aleo_shield_swap.client.ShieldSwap` (write verbs run -``.delegate()``) and returns a JSON-serializable result; handles serialize -as dicts so an agent can persist and resume the two-step swap flow. +``.delegate()``) and returns a JSON-serializable result. The surface is the +curated lifecycle set — swap handles and counters live in the profile +journal, so agents never carry state between calls; the long tail of verbs +is reachable by writing Python against the client instead. """ from __future__ import annotations -import json from dataclasses import asdict, is_dataclass from typing import Any, Callable -from .types import SwapHandle - _S = {"type": "string"} _I = {"type": "integer"} @@ -58,108 +57,116 @@ def _h_get_balances(dex: Any, args: dict[str, Any]) -> Any: return dex.get_balances(address=args.get("address")) -def _h_swap(dex: Any, args: dict[str, Any]) -> Any: - call = dex.swap( - pool_key=args["pool_key"], token_in_id=args["token_in_id"], - amount_in=int(args["amount_in"]), - slippage_bps=int(args.get("slippage_bps", 50)), - expected_out=(int(args["expected_out"]) - if args.get("expected_out") is not None else None), - token_in_program=args.get("token_in_program")) - return asdict(call.delegate()) - - -def _h_claim(dex: Any, args: dict[str, Any]) -> Any: - handle = SwapHandle.from_json(json.dumps(args["handle"])) - return asdict(dex.claim_swap_output(handle).delegate()) +def _h_setup_account(dex: Any, args: dict[str, Any]) -> Any: + return _serialize(dex.onboard(invite_code=args.get("invite_code"))) -def _h_increase(dex: Any, args: dict[str, Any]) -> Any: - call = dex.increase_liquidity( - pool_key=args["pool_key"], - amount0_desired=int(args["amount0_desired"]), - amount1_desired=int(args["amount1_desired"]), - token0_program=args.get("token0_program"), - token1_program=args.get("token1_program")) - return asdict(call.delegate()) +def _h_redeem_invite(dex: Any, args: dict[str, Any]) -> Any: + return _serialize(dex.api.redeem_code(args["code"])) -def _h_decrease(dex: Any, args: dict[str, Any]) -> Any: - call = dex.decrease_liquidity( - pool_key=args["pool_key"], - liquidity_to_remove=int(args["liquidity_to_remove"])) - return asdict(call.delegate()) +def _h_request_airdrop(dex: Any, args: dict[str, Any]) -> Any: + address = args.get("address") or (dex.profile.address if dex.profile + else None) + if not address: + raise ValueError("No address: pass address= or bind a profile.") + return _serialize(dex.api.request_airdrop(address)) -def _h_collect(dex: Any, args: dict[str, Any]) -> Any: - call = dex.collect( - pool_key=args["pool_key"], - amount0_requested=int(args["amount0_requested"]), - amount1_requested=int(args["amount1_requested"])) - return asdict(call.delegate()) +def _h_status(dex: Any, args: dict[str, Any]) -> Any: + return _serialize(dex.status()) -def _h_burn(dex: Any, args: dict[str, Any]) -> Any: - return asdict(dex.burn(pool_key=args["pool_key"]).delegate()) +def _h_get_positions(dex: Any, args: dict[str, Any]) -> Any: + return _serialize(dex.get_positions()) -def _h_create_pool(dex: Any, args: dict[str, Any]) -> Any: - call = dex.create_pool( - token0_id=args["token0_id"], token1_id=args["token1_id"], - fee=int(args["fee"]), initial_tick=int(args["initial_tick"])) - return asdict(call.delegate()) +def _h_swap_many(dex: Any, args: dict[str, Any]) -> Any: + return _serialize(dex.swap_many( + pool_key=args["pool_key"], token_in_id=args["token_in_id"], + amount_in=int(args["amount_in"]), count=int(args["count"]), + slippage_bps=int(args.get("slippage_bps", 50)))) -def _h_mint(dex: Any, args: dict[str, Any]) -> Any: - call = dex.mint( +def _h_mint_position(dex: Any, args: dict[str, Any]) -> Any: + result = dex.mint( pool_key=args["pool_key"], tick_lower=int(args["tick_lower"]), tick_upper=int(args["tick_upper"]), amount0_desired=int(args["amount0_desired"]), amount1_desired=int(args["amount1_desired"]), token0_program=args.get("token0_program"), - token1_program=args.get("token1_program")) - return asdict(call.delegate()) + token1_program=args.get("token1_program")).delegate() + if dex.journal is not None and result.position_token_id: + dex.journal.record_position(result.position_token_id, + args["pool_key"], result.transaction_id) + return _serialize(result) + + +def _h_adjust_liquidity(dex: Any, args: dict[str, Any]) -> Any: + delta = int(args["liquidity_delta"]) + if delta >= 0: + call = dex.increase_liquidity(pool_key=args["pool_key"], + amount0_desired=delta, + amount1_desired=delta) + else: + call = dex.decrease_liquidity(pool_key=args["pool_key"], + liquidity_to_remove=-delta) + return _serialize(call.delegate()) + + +def _h_collect_all(dex: Any, args: dict[str, Any]) -> Any: + return _serialize(dex.collect_all()) _TOOLS: list[tuple[str, str, dict[str, Any], Callable[[Any, dict[str, Any]], Any]]] = [ + ("setup_account", + "Register this machine's shield-swap profile end to end (auth, invite " + "redeem, credentials, airdrop, funded check). Pass invite_code on the " + "first run; re-running is a safe no-op that reports what was skipped.", + _schema({"invite_code": _S}, []), _h_setup_account), + ("redeem_invite", + "Redeem an invite code for the authenticated account (setup_account " + "does this for you; use this only for manual control).", + _schema({"code": _S}, ["code"]), _h_redeem_invite), + ("request_airdrop", + "Queue the test-token airdrop (private records; one claim per address " + "per 15 minutes). Defaults to the profile's own address.", + _schema({"address": _S}, []), _h_request_airdrop), + ("status", + "Re-orient: registration state, balances, open positions, pending swap " + "claims, counter cursor. Run this FIRST in any session.", + _schema({}, []), _h_status), ("get_pools", "List shield_swap pools with their token pairs and fee tiers.", _schema({}, []), _h_get_pools), - ("get_route", "Quote a trade route between two tokens; returns the estimated output.", - _schema({"token_in": _S, "token_out": _S, "amount_in": _I}, - ["token_in", "token_out"]), _h_get_route), - ("get_slot", "Live pool state: sqrt price, current tick, in-range liquidity.", - _schema({"pool_key": _S}, ["pool_key"]), _h_get_slot), ("get_balances", "Public + private + total balances per token for an address.", _schema({"address": _S}, []), _h_get_balances), - ("swap", "Request a private swap (phase 1 of 2). Returns the swap handle — " - "persist it; claim_swap_output consumes it after finalization.", + ("get_positions", + "Open liquidity positions — journaled ones plus any recovered by " + "scanning the account's records.", + _schema({}, []), _h_get_positions), + ("swap_many", + "Fire N private swaps with journal-reserved counters; handles are " + "journaled for collect_all. Requires a funded, registered profile.", _schema({"pool_key": _S, "token_in_id": _S, "amount_in": _I, - "slippage_bps": _I, "expected_out": _I, "token_in_program": _S}, - ["pool_key", "token_in_id", "amount_in"]), _h_swap), - ("claim_swap_output", "Claim a private swap's output (phase 2). Takes the " - "handle returned by swap; retry if not finalized yet.", - _schema({"handle": {"type": "object"}}, ["handle"]), _h_claim), - ("mint", "Mint a concentrated-liquidity position over a tick range.", + "count": _I, "slippage_bps": _I}, + ["pool_key", "token_in_id", "amount_in", "count"]), _h_swap_many), + ("mint_position", + "Mint a concentrated-liquidity position over a tick range (journaled).", _schema({"pool_key": _S, "tick_lower": _I, "tick_upper": _I, "amount0_desired": _I, "amount1_desired": _I, "token0_program": _S, "token1_program": _S}, ["pool_key", "tick_lower", "tick_upper", - "amount0_desired", "amount1_desired"]), _h_mint), - ("increase_liquidity", "Add funds to an existing position (range fixed at mint).", - _schema({"pool_key": _S, "amount0_desired": _I, "amount1_desired": _I, - "token0_program": _S, "token1_program": _S}, - ["pool_key", "amount0_desired", "amount1_desired"]), _h_increase), - ("decrease_liquidity", "Remove liquidity from a position; owed amounts become collectable.", - _schema({"pool_key": _S, "liquidity_to_remove": _I}, - ["pool_key", "liquidity_to_remove"]), _h_decrease), - ("collect", "Collect owed token amounts from a position.", - _schema({"pool_key": _S, "amount0_requested": _I, "amount1_requested": _I}, - ["pool_key", "amount0_requested", "amount1_requested"]), _h_collect), - ("burn", "Burn an empty position NFT.", - _schema({"pool_key": _S}, ["pool_key"]), _h_burn), - ("create_pool", "Create a new pool for a token pair at a registered fee tier.", - _schema({"token0_id": _S, "token1_id": _S, "fee": _I, "initial_tick": _I}, - ["token0_id", "token1_id", "fee", "initial_tick"]), _h_create_pool), + "amount0_desired", "amount1_desired"]), _h_mint_position), + ("adjust_liquidity", + "Resize a position: positive liquidity_delta adds that much of each " + "token (increase), negative removes liquidity (decrease; owed amounts " + "become collectable).", + _schema({"pool_key": _S, "liquidity_delta": _I}, + ["pool_key", "liquidity_delta"]), _h_adjust_liquidity), + ("collect_all", + "Claim every finalized swap and collect owed LP fees, from the journal. " + "Safe to run any time; reports what is still pending.", + _schema({}, []), _h_collect_all), ] diff --git a/shield-swap-sdk/python/aleo_shield_swap/mcp.py b/shield-swap-sdk/python/aleo_shield_swap/mcp.py index c94d1d8..3654075 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/mcp.py +++ b/shield-swap-sdk/python/aleo_shield_swap/mcp.py @@ -13,7 +13,9 @@ ALEO_ENDPOINT API origin (default ``https://api.provable.com`` — the provider derives ``/v2`` reads, ``/prove``, and ``/scanner`` from it) - ALEO_PRIVATE_KEY Signer for write tools (omit for read-only) + ALEO_PRIVATE_KEY Explicit signer (overrides the profile); without it + the server binds ShieldSwap.from_profile() — + ~/.shield-swap key material, journal, credentials ALEO_NETWORK ``testnet`` (default) or ``mainnet`` ALEO_E2E_API_KEY / ALEO_E2E_CONSUMER_ID Delegated-proving + hosted-scanner credentials @@ -81,7 +83,10 @@ def _build_dex() -> Any: pk = os.environ.get("ALEO_PRIVATE_KEY") if pk: aleo.default_account = aleo.account.from_private_key(pk) - return ShieldSwap(aleo) + return ShieldSwap(aleo) + # No explicit key: bind the local participant profile (created on first + # use), so MCP agents get persistence — journal, counters, credentials. + return ShieldSwap.from_profile() def main() -> None: diff --git a/shield-swap-sdk/tests/test_agent.py b/shield-swap-sdk/tests/test_agent.py index 2557fcd..7977348 100644 --- a/shield-swap-sdk/tests/test_agent.py +++ b/shield-swap-sdk/tests/test_agent.py @@ -3,36 +3,127 @@ import pytest from aleo_shield_swap.agent import dispatch_tool, shield_swap_tools -from aleo_shield_swap.client import ShieldSwap +from aleo_shield_swap.types import (CollectReport, MintResult, OnboardReport, + StageOutcome, SwapBatchReport, SwapHandle, + TxResult) +CURATED = {"setup_account", "redeem_invite", "request_airdrop", "status", + "get_pools", "get_balances", "get_positions", "swap_many", + "mint_position", "adjust_liquidity", "collect_all"} -def test_tool_definitions_shape(): + +def test_tool_surface_is_curated(): tools = shield_swap_tools() - names = {t["name"] for t in tools} - assert {"get_pools", "get_route", "get_slot", "get_balances", - "swap", "claim_swap_output", "mint"} <= names + assert {t["name"] for t in tools} == CURATED for t in tools: - assert t["description"] + assert t["description"], f"{t['name']} needs a teaching description" assert t["input_schema"]["type"] == "object" json.dumps(t) # fully serializable -def test_dispatch_get_slot(stub_aleo): - out = dispatch_tool(ShieldSwap(stub_aleo), "get_slot", {"pool_key": "5field"}) - assert out["tick"] == 4055 +def test_dispatch_setup_account_serializes_report(): + class _Dex: + def onboard(self, invite_code=None): + assert invite_code == "C" + return OnboardReport("aleo1x", [StageOutcome("authenticate", "ran")], + funded=True) + + out = dispatch_tool(_Dex(), "setup_account", {"invite_code": "C"}) + assert out["funded"] is True + assert out["outcomes"][0]["name"] == "authenticate" json.dumps(out) -def test_dispatch_swap_returns_serialized_handle(stub_aleo): - out = dispatch_tool(ShieldSwap(stub_aleo), "swap", - {"pool_key": "5field", "token_in_id": "1field", - "amount_in": 10**9, "expected_out": 1_000_000, - "token_in_program": "tok.aleo"}) - assert out["swap_id"] == "77field" # delegate path, decoded tx - assert out["blinding_factor"] +def test_dispatch_swap_many_and_collect_all(): + handle = SwapHandle(swap_id="s0", blinding_factor="bf", + blinded_address="ba", token_in_id="t0", + token_out_id="t1", pool_key="pk", amount_in=5, + transaction_id="tx", program="p") + + class _Dex: + def swap_many(self, *, pool_key, token_in_id, amount_in, count, + slippage_bps=50): + assert (pool_key, count) == ("pk", 2) + return SwapBatchReport(handles=[handle, handle], failures=[]) + + def collect_all(self): + return CollectReport(claimed=[{"swap_id": "s0"}], + still_pending=[], fees=[]) + + out = dispatch_tool(_Dex(), "swap_many", + {"pool_key": "pk", "token_in_id": "t0", + "amount_in": 5, "count": 2}) + assert len(out["handles"]) == 2 and out["handles"][0]["swap_id"] == "s0" + out2 = dispatch_tool(_Dex(), "collect_all", {}) + assert out2["claimed"] == [{"swap_id": "s0"}] and out2["fees"] == [] json.dumps(out) + json.dumps(out2) + + +def test_dispatch_adjust_liquidity_signs(): + calls = [] + + class _Call: + def delegate(self): + return TxResult("p1", "tx") + + class _Dex: + def increase_liquidity(self, **kw): + calls.append(("inc", kw)) + return _Call() + + def decrease_liquidity(self, **kw): + calls.append(("dec", kw)) + return _Call() + + dispatch_tool(_Dex(), "adjust_liquidity", + {"pool_key": "k", "liquidity_delta": -10}) + dispatch_tool(_Dex(), "adjust_liquidity", + {"pool_key": "k", "liquidity_delta": 7}) + assert calls[0][0] == "dec" and calls[0][1]["liquidity_to_remove"] == 10 + assert calls[1][0] == "inc" and calls[1][1]["amount0_desired"] == 7 + + +def test_dispatch_mint_position_journals(): + journaled = [] + + class _Journal: + def record_position(self, pid, pool_key, tx): + journaled.append((pid, pool_key, tx)) + + class _Call: + def delegate(self): + return MintResult("11field", "txm") + + class _Dex: + journal = _Journal() + + def mint(self, **kw): + return _Call() + + out = dispatch_tool(_Dex(), "mint_position", + {"pool_key": "pk", "tick_lower": -60, "tick_upper": 60, + "amount0_desired": 1, "amount1_desired": 1}) + assert out["position_token_id"] == "11field" + assert journaled == [("11field", "pk", "txm")] + + +def test_dispatch_request_airdrop_defaults_to_profile(): + class _Api: + def request_airdrop(self, address): + return {"job_id": "j1", "status": "running", "address": address} + + class _Profile: + address = "aleo1me" + + class _Dex: + api = _Api() + profile = _Profile() + + out = dispatch_tool(_Dex(), "request_airdrop", {}) + assert out["address"] == "aleo1me" -def test_dispatch_unknown_tool(stub_aleo): +def test_dispatch_unknown_tool(): with pytest.raises(ValueError, match="Unknown"): - dispatch_tool(ShieldSwap(stub_aleo), "nope", {}) + dispatch_tool(object(), "nope", {}) diff --git a/shield-swap-sdk/tests/test_mcp.py b/shield-swap-sdk/tests/test_mcp.py index d81d04c..e4179bc 100644 --- a/shield-swap-sdk/tests/test_mcp.py +++ b/shield-swap-sdk/tests/test_mcp.py @@ -15,10 +15,10 @@ def test_tool_definitions_carry_exact_schemas(): expected = {t["name"]: t for t in shield_swap_tools()} assert set(by_name) == set(expected) # The precise agent schema must survive — not FastMCP signature inference. - swap = by_name["swap"] - assert swap.inputSchema == expected["swap"]["input_schema"] - assert "pool_key" in swap.inputSchema["properties"] - assert "amount_in" in swap.inputSchema["required"] + swap_many = by_name["swap_many"] + assert swap_many.inputSchema == expected["swap_many"]["input_schema"] + assert "pool_key" in swap_many.inputSchema["properties"] + assert "count" in swap_many.inputSchema["required"] def test_build_server_constructs(): @@ -26,6 +26,8 @@ def test_build_server_constructs(): async def test_call_tool_dispatches_and_serializes(stub_aleo): - out = await call_tool(ShieldSwap(stub_aleo), "get_slot", {"pool_key": "5field"}) + dex = ShieldSwap(stub_aleo) + dex.api.get_pools = lambda: [] # no network in unit tests + out = await call_tool(dex, "get_pools", {}) assert out[0].type == "text" - assert json.loads(out[0].text)["tick"] == 4055 + assert json.loads(out[0].text) == [] From 257c99075271671fb63ce8b7bfdfd0e18bf7ef40 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 19:37:28 -0400 Subject: [PATCH 12/30] feat(shield-swap): generated AGENTS.md context, skill pointer, CI staleness gate --- .claude/skills/shield-swap/SKILL.md | 11 + .github/workflows/sdk-wheels.yml | 2 + shield-swap-sdk/AGENTS.md | 297 ++++++++++++++++++++++ shield-swap-sdk/codegen/gen_context.py | 160 ++++++++++++ shield-swap-sdk/tests/test_gen_context.py | 45 ++++ 5 files changed, 515 insertions(+) create mode 100644 .claude/skills/shield-swap/SKILL.md create mode 100644 shield-swap-sdk/AGENTS.md create mode 100644 shield-swap-sdk/codegen/gen_context.py create mode 100644 shield-swap-sdk/tests/test_gen_context.py diff --git a/.claude/skills/shield-swap/SKILL.md b/.claude/skills/shield-swap/SKILL.md new file mode 100644 index 0000000..46baaaa --- /dev/null +++ b/.claude/skills/shield-swap/SKILL.md @@ -0,0 +1,11 @@ +--- +name: shield-swap +description: Use when the user wants to trade, LP, or build on the shield_swap AMM — setting up an account, redeeming an invite, getting the airdrop, swapping privately, managing liquidity positions, or collecting winnings via the aleo_shield_swap Python SDK. +--- + +Read `shield-swap-sdk/AGENTS.md` (generated from the SDK — always current) +and follow its Tier 1 lifecycle and conversation pattern. Write Python +against the SDK; don't re-implement flows the verbs already provide, and +don't read SDK source unless AGENTS.md genuinely lacks the answer. +Preconditions are enforced in code — on error, read the exception message; +it names the verb that fixes it. diff --git a/.github/workflows/sdk-wheels.yml b/.github/workflows/sdk-wheels.yml index e3e27bf..3ea1632 100644 --- a/.github/workflows/sdk-wheels.yml +++ b/.github/workflows/sdk-wheels.yml @@ -254,6 +254,8 @@ jobs: pip install --find-links sdk-dist aleo-sdk pip install --find-links shield-swap-sdk/dist "shield-swap-sdk[async,mcp]" pytest pytest-asyncio cd shield-swap-sdk && python -m pytest + - name: AGENTS.md up to date + run: python shield-swap-sdk/codegen/gen_context.py --check # Prove the installed wheel imports on its own, away from the source tree. - name: Smoke test wheel run: | diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md new file mode 100644 index 0000000..25cad6e --- /dev/null +++ b/shield-swap-sdk/AGENTS.md @@ -0,0 +1,297 @@ +# shield-swap — agent guide + +> GENERATED from SDK docstrings by `codegen/gen_context.py` — do not +> edit by hand; edit the docstrings and regenerate. + +Typed Python client for the shield_swap AMM on Aleo +(`pip install shield-swap-sdk`, imports as `aleo_shield_swap`). +MCP alternative: `python -m aleo_shield_swap.mcp` exposes the same +lifecycle as tools. + +## Tier 1 — the lifecycle (six lines end to end) + +```python +from aleo_shield_swap import ShieldSwap + +dex = ShieldSwap.from_profile() # key material auto-managed on disk +dex.onboard(invite_code="...") # first run only; no-op afterwards +pools = dex.api.get_pools() +report = dex.swap_many(pool_key=pools[0].key, token_in_id=pools[0].token0, + amount_in=10**6, count=5) +dex.collect_all() # any session, any time +``` + +### `from_profile(home: 'Any' = 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. + +### `onboard(self, invite_code: 'Optional[str]' = None) -> 'OnboardReport'` + +Register this profile end to end — safe to re-run any time. + +Runs only the registration stages not already satisfied (see +``lifecycle.REGISTRATION_STAGES``); a registered, funded account is +a no-op. The one thing it may need from you: *invite_code*, on the +first run. Requires a profile-bound client (``from_profile()``). + +### `status(self) -> 'SessionStatus'` + +One re-orientation call: identity, access, holdings, pending work. + +Run this first in any session — it answers "is this account already +registered, what do I hold, what is in flight" from the profile, +journal, chain, and API without changing anything. + +### `get_positions(self, account: 'Any' = None) -> 'list[PositionView]'` + +Every open position — journaled ones plus a record scan. + +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, account: 'Any' = None) -> 'SwapBatchReport'` + +*count* private swaps of *amount_in* each, with reserved counters. + +Counters come from the journal (no probe races); every handle is +journaled before the next swap fires, so a crash mid-batch loses +nothing — ``collect_all()`` later claims whatever landed. A failed +swap burns its counter and the batch continues; failures are +reported, not raised. Requires ``from_profile()``. + +### `collect_all(self, account: 'Any' = None) -> 'CollectReport'` + +Claim every finalized swap and collect owed fees on open positions. + +Safe to run any time, from any session: works off the journal, skips +swaps whose finalize hasn't landed (they stay pending for next time), +never double-claims, and requests exactly the owed amounts the chain +reports. Requires ``from_profile()``. + +## Serving a chatting user (the conversation pattern) + +1. Run `status()` first — is the account registered and funded, what does + it hold, what is pending. Never onboard an account that `status()` + shows is already set up. +2. Present tradable pairs from the user's ACTUAL balances crossed with + `dex.api.get_pools()`, and ask which they want to trade. +3. Proactively recommend minting/liquidity options: pools matching their + tokens, tick ranges around `get_slot(pool_key)` (see + `SlotView.tick_range`). Don't wait for exact parameters. +4. Confirm, act, report ids. + +The invite code (first run) is the only thing you should ever need to ask +the user for. Errors name their own fix — read the exception message and +do what it says. State (handles, counters, positions) lives in +`~/.shield-swap/`, not in your context: any fresh session re-orients with +`status()`. + +## Tier 2 — the development guide (building your own tools) + +Every write verb returns a prepared `DexCall`: nothing touches the +network until a terminal verb — `.simulate()` (local, free), +`.transact()` (local proving, slow), or `.delegate()` (delegated +proving — the practical path). + +### Registration, unbundled + +`onboard()` is a stage list, and the steps WILL change over time — +introspect `lifecycle.REGISTRATION_STAGES`, never hard-code the +sequence. Current stages: + +- `authenticate` +- `redeem` +- `credentials` +- `airdrop` +- `funded` + +Apps that own their onboarding call the same `dex.api` endpoints +the stages use: + +### `api.authenticate(self, address: 'str', sign: 'Any') -> 'str'` + +Challenge/verify handshake; stores and returns the JWT. + +*sign* is a callable taking the challenge message string and +returning an Aleo signature literal (``sign1…``) — e.g.:: + + pk = aleo.testnet.PrivateKey.from_string(key) + api.authenticate(str(pk.address), + lambda msg: str(pk.sign(msg.encode()))) + +### `api.access_status(self) -> 'models.AccessStatusResponse'` + +Whether this authenticated account has redeemed an invite code. + +### `api.redeem_code(self, code: 'str') -> 'models.AccessRedeemResponse'` + +Redeem an invite code; adopts the fresh token the API returns. + +### `api.request_airdrop(self, address: 'str') -> 'models.AirdropStartResult'` + +Start the test-token airdrop job for *address* (private records). + +One claim per address per 15 minutes — raises +:class:`AirdropRateLimitedError` on 429. Poll the returned +``job_id`` with :meth:`get_airdrop_job`. + +### `api.get_airdrop_job(self, job_id: 'str') -> 'models.AirdropJob'` + +Progress of an airdrop job — ``running`` until every transfer lands. + +### `api.create_api_token(self, name: 'str', expires_in_days: "'int | None'" = None) -> 'models.ApiTokenCreatedResponse'` + +Mint a long-lived DEX API token (the secret is returned ONCE). + +JWTs from :meth:`authenticate` expire in 24h; persist the returned +``.token`` for durable access. Tiering (verified live): ``ss_…`` +tokens work on data/trading endpoints; ``/access/*`` and token +management still require a session JWT. + +### `api.get_pools(self) -> 'list[PoolEntry]'` + + + +### `api.get_tokens(self) -> 'list[models.TokenDoc]'` + + + +### `api.get_route(self, *, token_in: 'str', token_out: 'str', amount_in: 'int | None' = None) -> 'models.RouteResultDoc'` + + + +### Counters & blinding + +Blinded identities derive deterministically from (view key, counter, +program). Counters must NEVER be reused: reserve them via +`dex.journal.reserve_counters(n)` (what `swap_many` does), or probe +on-chain when no journal exists. Persist `SwapHandle`s — the +blinding factor is the claim secret. + +### `blinded_identity_at(aleo: 'Any', account: 'Any', program: 'str', counter: 'int') -> 'BlindedIdentity'` + +The identity at an exact *counter* — no on-chain probing. + +Use with journal-reserved counters for concurrent swaps; +:func:`next_blinded_identity` (probe-based) remains the recovery path +when no journal exists. + +### `next_blinded_identity(aleo: 'Any', account: 'Any', program: 'str' = 'shield_swap_v3.aleo', *, start_counter: 'int' = 0, max_scan: 'int' = 64) -> 'BlindedIdentity'` + +First unused single-use identity for *account*. + +Derives at ``start_counter, +1, …`` and probes the program's +``used_blinded_addresses`` mapping until one is free. ``max_scan`` fails +fast when something is systematically wrong (e.g. wrong program). + +### Chain verbs + +### `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' = 100, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` + +Request a private swap — phase one of the two-transaction flow. + +Resolves the intent against live pool state, derives a single-use +blinded identity from the signer's view key, selects an unspent token +record (or takes *token_record* verbatim), and returns a prepared +call. The terminal verb (``transact``/``delegate``) returns a +:class:`~aleo_shield_swap.types.SwapHandle` — persist it if the +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. + +### `claim_swap_output(self, handle: 'SwapHandle', *, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[ClaimResult]'` + +Claim a private swap's output — phase two of the lifecycle. + +Reads the chain-computed result from ``swap_outputs`` (never an +off-chain service — these amounts gate money movement), proves +ownership of the blinded identity, and prepares ``claim_swap_output``. +The output and any refund arrive as private records owned by the +signer; the mapping entry is consumed. + +Raises :class:`SwapOutputNotFinalizedError` **at prepare time** when +the output is not readable yet (retry after a few blocks) or was +already claimed. + +### `create_pool(self, *, token0_id: 'str', token1_id: 'str', fee: 'int', initial_tick: 'int', tick_spacing: 'Optional[int]' = None, initial_sqrt_price: 'Optional[int]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` + +Create a pool — a single public transaction, no records involved. + +The fee tier must be registered with the program (validated before +submission); tick spacing defaults to the tier's on-chain binding and +the opening price to the tick's sqrt price. + +### `mint(self, *, pool_key: 'str', tick_lower: 'int', tick_upper: 'int', amount0_desired: 'int', amount1_desired: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, recipient: 'Optional[str]' = None, nonce: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[MintResult]'` + +Mint a concentrated-liquidity position as a private PositionNFT. + +Tick bounds are rounded to the pool's spacing; insert hints derive +from the slot's neighbors unless given explicitly. + +### `increase_liquidity(self, *, pool_key: 'str', amount0_desired: 'int', amount1_desired: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, position_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` + +Add funds to an existing position (range fixed at mint). + +### `decrease_liquidity(self, *, pool_key: 'str', liquidity_to_remove: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, position_record: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` + +Remove liquidity from a position; owed amounts become collectable. + +### `collect(self, *, pool_key: 'str', amount0_requested: 'int', amount1_requested: 'int', recipient: 'Optional[str]' = None, position_record: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` + +Collect owed token amounts from a position. + +### `burn(self, *, pool_key: 'str', position_record: 'Optional[str]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` + +Burn an empty position NFT. + +### `get_pool(self, pool_key: 'str') -> 'g.PoolState'` + +Static pool configuration (token pair, fee, decimal scales). + +### `get_slot(self, pool_key: 'str') -> 'SlotView'` + +Live trading state (sqrt price, tick, in-range liquidity). + +Raises :class:`PoolNotFoundError` when the pool does not exist, or +:class:`PoolNotInitializedError` when it exists but has no slot yet. + +### `get_swap_output(self, swap: "'SwapHandle | str'") -> 'g.SwapOutput'` + +Chain-computed output of a finalized swap request. + +Accepts the :class:`SwapHandle` from ``swap()`` or a bare swap id. +Raises :class:`SwapOutputNotFinalizedError` when the entry is absent — +not finalized yet (retry after a few blocks) or already claimed. + +### `get_balances(self, address: 'Optional[str]' = None, account: 'Any' = None) -> 'dict[str, dict[str, Any]]'` + +Public + private + total per token id, joined via the API's +token registry. Defaults to the bound account's address; returns +only tokens actually held. + +Private balances can only be scanned for the bound account's view +key — when *address* names someone else, ``private`` is 0 for every +token (their records are not scannable) rather than silently mixing +in the caller's own private holdings. + +### `get_private_balances(self, programs: 'list[str]', account: 'Any' = None) -> 'dict[str, int]'` + +Sum of unspent record amounts per wrapper program (spendable +privately). Requires a configured record provider. + +### `derive_pool_key(self, token0: 'str', token1: 'str', fee: 'int') -> 'str'` + + + +### `derive_tick_key(self, pool_key: 'str', tick: 'int') -> 'str'` + + + diff --git a/shield-swap-sdk/codegen/gen_context.py b/shield-swap-sdk/codegen/gen_context.py new file mode 100644 index 0000000..1235950 --- /dev/null +++ b/shield-swap-sdk/codegen/gen_context.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Render AGENTS.md from the SDK's docstrings — the anti-drift context page. + +Everything an agent needs is derived from code: tier 1 (lifecycle + the +conversation pattern), tier 2 (the development guide's building-block +reference + registration stages). Run with no args to rewrite AGENTS.md; +``--check`` exits 1 when the committed page is stale (CI); ``--stdout`` +prints instead of writing. +""" +from __future__ import annotations + +import argparse +import inspect +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_ROOT / "python")) + +from aleo_shield_swap import ShieldSwap, derivations # noqa: E402 +from aleo_shield_swap.api import ApiClient # noqa: E402 +from aleo_shield_swap.lifecycle import REGISTRATION_STAGES # noqa: E402 + +OUT = _ROOT / "AGENTS.md" + +TIER1 = ["from_profile", "onboard", "status", "get_positions", + "swap_many", "collect_all"] +TIER2_CLIENT = ["swap", "claim_swap_output", "create_pool", "mint", + "increase_liquidity", "decrease_liquidity", "collect", "burn", + "get_pool", "get_slot", "get_swap_output", "get_balances", + "get_private_balances", "derive_pool_key", "derive_tick_key"] +TIER2_API = ["authenticate", "access_status", "redeem_code", + "request_airdrop", "get_airdrop_job", "create_api_token", + "get_pools", "get_tokens", "get_route"] +TIER2_DERIVATIONS = ["blinded_identity_at", "next_blinded_identity"] + +QUICKSTART = """\ +```python +from aleo_shield_swap import ShieldSwap + +dex = ShieldSwap.from_profile() # key material auto-managed on disk +dex.onboard(invite_code="...") # first run only; no-op afterwards +pools = dex.api.get_pools() +report = dex.swap_many(pool_key=pools[0].key, token_in_id=pools[0].token0, + amount_in=10**6, count=5) +dex.collect_all() # any session, any time +```""" + +CONVERSATION_PATTERN = """\ +## Serving a chatting user (the conversation pattern) + +1. Run `status()` first — is the account registered and funded, what does + it hold, what is pending. Never onboard an account that `status()` + shows is already set up. +2. Present tradable pairs from the user's ACTUAL balances crossed with + `dex.api.get_pools()`, and ask which they want to trade. +3. Proactively recommend minting/liquidity options: pools matching their + tokens, tick ranges around `get_slot(pool_key)` (see + `SlotView.tick_range`). Don't wait for exact parameters. +4. Confirm, act, report ids. + +The invite code (first run) is the only thing you should ever need to ask +the user for. Errors name their own fix — read the exception message and +do what it says. State (handles, counters, positions) lives in +`~/.shield-swap/`, not in your context: any fresh session re-orients with +`status()`.""" + + +def _entry(name: str, fn: object) -> str: + try: + sig = str(inspect.signature(fn)) # type: ignore[arg-type] + except (TypeError, ValueError): + sig = "(...)" + doc = inspect.getdoc(fn) or "" + return f"### `{name}{sig}`\n\n{doc.strip()}\n" + + +def render() -> str: + parts = [ + "# shield-swap — agent guide", + "", + "> GENERATED from SDK docstrings by `codegen/gen_context.py` — do not", + "> edit by hand; edit the docstrings and regenerate.", + "", + "Typed Python client for the shield_swap AMM on Aleo", + "(`pip install shield-swap-sdk`, imports as `aleo_shield_swap`).", + "MCP alternative: `python -m aleo_shield_swap.mcp` exposes the same", + "lifecycle as tools.", + "", + "## Tier 1 — the lifecycle (six lines end to end)", + "", + QUICKSTART, + "", + ] + parts += [_entry(n, getattr(ShieldSwap, n)) for n in TIER1] + parts += [ + CONVERSATION_PATTERN, + "", + "## Tier 2 — the development guide (building your own tools)", + "", + "Every write verb returns a prepared `DexCall`: nothing touches the", + "network until a terminal verb — `.simulate()` (local, free),", + "`.transact()` (local proving, slow), or `.delegate()` (delegated", + "proving — the practical path).", + "", + "### Registration, unbundled", + "", + "`onboard()` is a stage list, and the steps WILL change over time —", + "introspect `lifecycle.REGISTRATION_STAGES`, never hard-code the", + "sequence. Current stages:", + "", + ] + parts += [f"- `{s.name}`" for s in REGISTRATION_STAGES] + parts += [ + "", + "Apps that own their onboarding call the same `dex.api` endpoints", + "the stages use:", + "", + ] + parts += [_entry(f"api.{n}", getattr(ApiClient, n)) for n in TIER2_API] + parts += [ + "### Counters & blinding", + "", + "Blinded identities derive deterministically from (view key, counter,", + "program). Counters must NEVER be reused: reserve them via", + "`dex.journal.reserve_counters(n)` (what `swap_many` does), or probe", + "on-chain when no journal exists. Persist `SwapHandle`s — the", + "blinding factor is the claim secret.", + "", + ] + parts += [_entry(n, getattr(derivations, n)) for n in TIER2_DERIVATIONS] + parts += ["### Chain verbs", ""] + parts += [_entry(n, getattr(ShieldSwap, n)) for n in TIER2_CLIENT] + return "\n".join(parts) + "\n" + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--check", action="store_true", + help="exit 1 when AGENTS.md is stale (CI gate)") + ap.add_argument("--stdout", action="store_true") + args = ap.parse_args() + page = render() + if args.stdout: + print(page, end="") + return 0 + if args.check: + current = OUT.read_text() if OUT.exists() else "" + if current != page: + print("AGENTS.md is stale — run: python codegen/gen_context.py", + file=sys.stderr) + return 1 + return 0 + OUT.write_text(page) + print(f"wrote {OUT} ({len(page)} chars)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shield-swap-sdk/tests/test_gen_context.py b/shield-swap-sdk/tests/test_gen_context.py new file mode 100644 index 0000000..307c719 --- /dev/null +++ b/shield-swap-sdk/tests/test_gen_context.py @@ -0,0 +1,45 @@ +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +GEN = ROOT / "codegen" / "gen_context.py" + + +def _render() -> str: + out = subprocess.run([sys.executable, str(GEN), "--stdout"], + capture_output=True, text=True, cwd=ROOT) + assert out.returncode == 0, out.stderr + return out.stdout + + +def test_tier1_lifecycle_and_conversation_pattern(): + page = _render() + assert "from_profile" in page and "onboard" in page + assert "`status()` first" in page # conversation pattern + assert "recommend" in page.lower() # minting/LP recommendations + assert "invite code" in page.lower() + + +def test_tier2_covers_building_blocks_and_stages(): + page = _render() + for verb in ("swap_many", "claim_swap_output", "collect_all", + "increase_liquidity", "decrease_liquidity", + "derive_pool_key", "simulate", "blinded_identity_at", + "redeem_code", "request_airdrop"): + assert verb in page, verb + # stages rendered FROM the list, not hand-written + from aleo_shield_swap.lifecycle import REGISTRATION_STAGES + for stage in REGISTRATION_STAGES: + assert f"- `{stage.name}`" in page + + +def test_committed_page_is_current(): + check = subprocess.run([sys.executable, str(GEN), "--check"], + capture_output=True, text=True, cwd=ROOT) + assert check.returncode == 0, (check.stderr or + "AGENTS.md stale — run codegen/gen_context.py") + + +def test_page_stays_compact(): + assert len(_render()) < 20_000 # ~5k tokens — cheap context, enforced From 891ecc6bd35166a1a0d6844d6c085a9f6aee1609 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 19:38:56 -0400 Subject: [PATCH 13/30] feat(shield-swap): lifecycle exports, README agents section, live lifecycle tests --- shield-swap-sdk/README.md | 8 ++++++ .../python/aleo_shield_swap/__init__.py | 22 ++++++++++++++++ shield-swap-sdk/tests/test_live_api.py | 25 +++++++++++++++++-- shield-swap-sdk/tests/test_package.py | 12 +++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index d1f4a2d..278b8f5 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -31,6 +31,14 @@ pip install -e "shield-swap-sdk[mcp]" # + the MCP server Requires `aleo-sdk>=0.2` (this repo's SDK; imports as `aleo`) and Python 3.10+. +## Agents + +`AGENTS.md` (generated from the SDK's docstrings — always current) is the +one page an agent needs: the five-verb lifecycle, the conversation pattern, +and the building-block reference. Claude Code users get it via the +`shield-swap` skill; any MCP client can run the same lifecycle through +`python -m aleo_shield_swap.mcp`. + ## How calls work Every read returns a value immediately. Every write returns a prepared diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index 65b2724..f7746cd 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/__init__.py +++ b/shield-swap-sdk/python/aleo_shield_swap/__init__.py @@ -19,20 +19,36 @@ from .api import ApiClient as ApiClient, AsyncApiClient as AsyncApiClient from .types import ( ClaimResult as ClaimResult, + CollectReport as CollectReport, MintResult as MintResult, + OnboardReport as OnboardReport, + PositionView as PositionView, + SessionStatus as SessionStatus, SlotView as SlotView, + StageOutcome as StageOutcome, + SwapBatchReport as SwapBatchReport, SwapHandle as SwapHandle, TxResult as TxResult, ) +from .profile import Profile as Profile +from .journal import Journal as Journal +from .lifecycle import REGISTRATION_STAGES as REGISTRATION_STAGES from .derivations import ( BlindedIdentity as BlindedIdentity, + blinded_identity_at as blinded_identity_at, derive_blinded_address as derive_blinded_address, derive_blinding_factor as derive_blinding_factor, derive_pool_key as derive_pool_key, derive_tick_key as derive_tick_key, ) from .errors import ( + AirdropPendingError as AirdropPendingError, + AirdropRateLimitedError as AirdropRateLimitedError, + CredentialsMissingError as CredentialsMissingError, DexApiError as DexApiError, + NotAuthenticatedError as NotAuthenticatedError, + NotFundedError as NotFundedError, + NotRedeemedError as NotRedeemedError, InsufficientRecordsError as InsufficientRecordsError, InvalidFeeTierError as InvalidFeeTierError, PoolNotFoundError as PoolNotFoundError, @@ -55,6 +71,12 @@ "ShieldSwapError", "SwapOutputNotFinalizedError", "PoolNotFoundError", "PoolNotInitializedError", "InsufficientRecordsError", "InvalidFeeTierError", "DexApiError", + "NotAuthenticatedError", "NotRedeemedError", "NotFundedError", + "AirdropPendingError", "AirdropRateLimitedError", + "CredentialsMissingError", + "Profile", "Journal", "REGISTRATION_STAGES", + "OnboardReport", "StageOutcome", "SessionStatus", "PositionView", + "SwapBatchReport", "CollectReport", "blinded_identity_at", "shield_swap_tools", "dispatch_tool", "__version__", ] diff --git a/shield-swap-sdk/tests/test_live_api.py b/shield-swap-sdk/tests/test_live_api.py index dfeae3f..4880ecd 100644 --- a/shield-swap-sdk/tests/test_live_api.py +++ b/shield-swap-sdk/tests/test_live_api.py @@ -32,6 +32,14 @@ def _authed_client(pk) -> ApiClient: return api +@pytest.fixture(scope="module") +def e2e_address() -> str: + key = os.environ.get("ALEO_E2E_PRIVATE_KEY") + if not key: + pytest.skip("ALEO_E2E_PRIVATE_KEY not set") + return str(aleo.testnet.PrivateKey.from_string(key).address) + + @pytest.fixture(scope="module") def funded_api() -> ApiClient: key = os.environ.get("ALEO_E2E_PRIVATE_KEY") @@ -39,8 +47,7 @@ def funded_api() -> ApiClient: pytest.skip("ALEO_E2E_PRIVATE_KEY not set") pk = aleo.testnet.PrivateKey.from_string(key) api = _authed_client(pk) - status = api._get("/access/status")["data"] - if not status.get("has_access"): + if not api.access_status().has_access: pytest.skip("e2e account has not redeemed an invite code") return api @@ -104,3 +111,17 @@ def test_funded_balances(funded_api): addr = str(aleo.testnet.PrivateKey.from_string(key).address) balances = funded_api.get_public_balances(addr) assert isinstance(balances, list) + + +def test_access_status_typed(funded_api): + assert funded_api.access_status().has_access is True + + +def test_airdrop_request_is_rate_limit_tolerant(funded_api, e2e_address): + from aleo_shield_swap.errors import AirdropRateLimitedError + try: + start = funded_api.request_airdrop(e2e_address) + job = funded_api.get_airdrop_job(start.job_id) + assert job.status in ("running", "complete") + except AirdropRateLimitedError: + pass # claimed recently — contract confirmed diff --git a/shield-swap-sdk/tests/test_package.py b/shield-swap-sdk/tests/test_package.py index e05475d..1df211f 100644 --- a/shield-swap-sdk/tests/test_package.py +++ b/shield-swap-sdk/tests/test_package.py @@ -3,3 +3,15 @@ def test_version(): assert aleo_shield_swap.__version__ == "0.2.1" + + +def test_lifecycle_exports(): + import aleo_shield_swap as pkg + for name in ("Profile", "Journal", "OnboardReport", "SessionStatus", + "PositionView", "SwapBatchReport", "CollectReport", + "StageOutcome", "NotAuthenticatedError", "NotRedeemedError", + "NotFundedError", "AirdropPendingError", + "AirdropRateLimitedError", "CredentialsMissingError", + "blinded_identity_at", "REGISTRATION_STAGES"): + assert hasattr(pkg, name), name + assert name in pkg.__all__, name From f2a42097d2a74fdfa585206608ff1c2a7ced934d Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Wed, 15 Jul 2026 19:40:53 -0400 Subject: [PATCH 14/30] test(shield-swap): live lifecycle proof (fresh profile) + rehearsal script Registration half verified against the live API: fresh account auth, self-minted invite redemption (POST /access/generate via the e2e account), instructive credentials error, JWT persistence. The full swap/collect gate needs ALEO_E2E_API_KEY/ALEO_E2E_CONSUMER_ID. --- shield-swap-sdk/scripts/rehearsal.py | 73 +++++++++++ .../integration/test_agent_lifecycle_live.py | 120 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 shield-swap-sdk/scripts/rehearsal.py create mode 100644 shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py diff --git a/shield-swap-sdk/scripts/rehearsal.py b/shield-swap-sdk/scripts/rehearsal.py new file mode 100644 index 0000000..d46449a --- /dev/null +++ b/shield-swap-sdk/scripts/rehearsal.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Stress-test rehearsal: the four flows, end to end, via the tier-1 verbs. + +Usage: python scripts/rehearsal.py [--code INVITE] [--home DIR] +Needs: network access; ALEO_E2E_API_KEY/ALEO_E2E_CONSUMER_ID (until key +provisioning has an endpoint). Without --code, an invite is minted with +ALEO_E2E_PRIVATE_KEY (the e2e account can generate codes). + +This script deliberately uses ONLY what AGENTS.md documents — if it needs +anything more, that's a finding. +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "python")) + + +def _mint_invite_code() -> str: + import aleo + from aleo_shield_swap import ApiClient + + pk = aleo.testnet.PrivateKey.from_string(os.environ["ALEO_E2E_PRIVATE_KEY"]) + api = ApiClient() + api.authenticate(str(pk.address), lambda m: str(pk.sign(m.encode()))) + return api._post("/access/generate", {"count": 1})["data"]["codes"][0] + + +def main() -> int: + from aleo_shield_swap import ShieldSwap + + ap = argparse.ArgumentParser() + ap.add_argument("--home", default=None) + ap.add_argument("--code", default=None) + args = ap.parse_args() + results: list[tuple[str, str]] = [] + + dex = ShieldSwap.from_profile(args.home) + code = args.code or _mint_invite_code() + + report = dex.onboard(invite_code=code) + results.append(("startup", "ok" if report.funded else "NOT FUNDED")) + + st = dex.status() + pools = dex.api.get_pools() + results.append(("discovery", + f"ok: {len(pools)} pools, {len(st.balances)} tokens held, " + f"{len(st.open_positions)} positions")) + + batch = dex.swap_many(pool_key=pools[0].key, token_in_id=pools[0].token0, + amount_in=10**5, count=3) + results.append(("swaps", f"{len(batch.handles)} ok, " + f"{len(batch.failures)} failed")) + + collected = dex.collect_all() + results.append(("collection", f"{len(collected.claimed)} claimed, " + f"{len(collected.still_pending)} pending")) + # Liquidity flow (mint/adjust) joins once ops designates a rehearsal + # pool with mintable ranges — noted rather than silently skipped. + results.append(("liquidity", "SKIPPED: no designated rehearsal pool yet")) + + failed = False + for flow, outcome in results: + print(f"{flow:12} {outcome}") + failed |= "NOT" in outcome or outcome.startswith("0 ok") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py b/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py new file mode 100644 index 0000000..9ddbcd5 --- /dev/null +++ b/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py @@ -0,0 +1,120 @@ +"""Live proof of the full agent lifecycle — fresh profile to collected swap. + +Opt in: python -m pytest tests/integration/test_agent_lifecycle_live.py -m live +Env: ALEO_E2E_PRIVATE_KEY mints a fresh invite code (or set + SHIELD_SWAP_INVITE_CODE explicitly) + ALEO_E2E_API_KEY / + ALEO_E2E_CONSUMER_ID delegated-proving + scanner credentials + ALEO_E2E_ENDPOINT API origin (default https://api.provable.com) + +One ordered test: onboarding a fresh account is rate-limited and slow, so +each phase asserts and feeds the next rather than re-onboarding. +""" +from __future__ import annotations + +import json +import os +import time + +import pytest + +pytestmark = pytest.mark.live + +_HAS_CODE_SOURCE = bool(os.environ.get("SHIELD_SWAP_INVITE_CODE") + or os.environ.get("ALEO_E2E_PRIVATE_KEY")) +_HAS_DPS = all(os.environ.get(k) + for k in ("ALEO_E2E_API_KEY", "ALEO_E2E_CONSUMER_ID")) + + +def _invite_code() -> str: + explicit = os.environ.get("SHIELD_SWAP_INVITE_CODE") + if explicit: + return explicit + import aleo + + from aleo_shield_swap import ApiClient + + pk = aleo.testnet.PrivateKey.from_string(os.environ["ALEO_E2E_PRIVATE_KEY"]) + api = ApiClient() + api.authenticate(str(pk.address), lambda m: str(pk.sign(m.encode()))) + return api._post("/access/generate", {"count": 1})["data"]["codes"][0] + + +@pytest.mark.skipif(not _HAS_CODE_SOURCE, + reason="no invite code source (SHIELD_SWAP_INVITE_CODE " + "or ALEO_E2E_PRIVATE_KEY)") +@pytest.mark.skipif(not _HAS_DPS, + reason="delegated-proving env missing " + "(ALEO_E2E_API_KEY/ALEO_E2E_CONSUMER_ID)") +def test_full_lifecycle_from_fresh_profile(tmp_path): + from aleo_shield_swap import ShieldSwap + + # ── Startup: fresh key material, full registration, airdrop ──────────── + dex = ShieldSwap.from_profile(tmp_path / "home") + report = dex.onboard(invite_code=_invite_code()) + assert report.funded, f"onboard did not fund: {report.outcomes}" + ran = {o.name for o in report.outcomes if o.action == "ran"} + assert "authenticate" in ran and "redeem" in ran # genuinely fresh + + # Idempotence: a second onboard is a no-op. + again = dex.onboard() + assert all(o.action == "skipped" for o in again.outcomes) + + # ── Discovery: pools, balances, positions ────────────────────────────── + st = dex.status() + assert st.authenticated and st.has_access + held = {tid for tid, v in st.balances.items() if v.get("private", 0) > 0} + assert held, "airdrop records not visible in private balances" + pools = dex.api.get_pools() + assert pools, "no pools available to trade" + + # Pick a pool whose tokens we actually hold (the conversation pattern). + pool = next(p for p in pools if p.token0 in held or p.token1 in held) + token_in = pool.token0 if pool.token0 in held else pool.token1 + + # ── Swaps: concurrent counters, journaled handles ─────────────────────── + batch = dex.swap_many(pool_key=pool.key, token_in_id=token_in, + amount_in=10**4, count=2) + assert len(batch.handles) == 2, f"swap failures: {batch.failures}" + assert len({h.blinded_address for h in batch.handles}) == 2 + + # ── Collection: poll until both claims land ──────────────────────────── + deadline = time.monotonic() + 900 + claimed_total = 0 + while time.monotonic() < deadline and claimed_total < 2: + result = dex.collect_all() + claimed_total += len(result.claimed) + if claimed_total < 2: + time.sleep(15) + assert claimed_total == 2, "swap outputs never became claimable" + + # ── Resumability: a brand-new client sees a clean, consistent state ──── + fresh = ShieldSwap.from_profile(tmp_path / "home") + st2 = fresh.status() + assert st2.pending_claim_ids == [] + assert st2.counter_cursor == 2 + + +@pytest.mark.skipif(not _HAS_CODE_SOURCE, + reason="no invite code source") +def test_live_registration_without_dps_creds(tmp_path, monkeypatch): + """The registration half of the lifecycle, provable without DPS creds: + fresh account authenticates, redeems a minted invite, and the + credentials stage fails with the instructive error (not a crash).""" + from aleo_shield_swap import ShieldSwap + from aleo_shield_swap.errors import CredentialsMissingError + + monkeypatch.delenv("ALEO_E2E_API_KEY", raising=False) + monkeypatch.delenv("ALEO_E2E_CONSUMER_ID", raising=False) + + dex = ShieldSwap.from_profile(tmp_path / "home") + with pytest.raises(CredentialsMissingError, match="ALEO_E2E_API_KEY"): + dex.onboard(invite_code=_invite_code()) + + st = dex.status() + assert st.authenticated and st.has_access # auth + redeem DID land + stages = {e["name"]: e["action"] for e in dex.journal.events() + if e["type"] == "stage"} + assert stages["authenticate"] == "ran" and stages["redeem"] == "ran" + # And the fresh JWT is persisted for the next session. + assert json.loads((dex.profile.home / "credentials.json").read_text())["jwt"] From 80b6d00353c94e178432dbf8dc9051bad9d3e336 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 10:07:20 -0400 Subject: [PATCH 15/30] chore(shield-swap): pyright covers gen_context.py --- shield-swap-sdk/pyrightconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shield-swap-sdk/pyrightconfig.json b/shield-swap-sdk/pyrightconfig.json index 9294a93..4ac6fa0 100644 --- a/shield-swap-sdk/pyrightconfig.json +++ b/shield-swap-sdk/pyrightconfig.json @@ -1,7 +1,7 @@ { - "include": ["python/aleo_shield_swap"], + "include": ["python/aleo_shield_swap", "codegen/gen_context.py"], "exclude": ["python/aleo_shield_swap/_generated.py", "python/aleo_shield_swap/_api_models.py"], - "extraPaths": ["../sdk/python"], + "extraPaths": ["../sdk/python", "python"], "venvPath": "../sdk", "venv": ".env", "reportMissingModuleSource": false From 2f6689c79fd1a0ff8e97119a921d68f3739ce2a2 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 10:14:51 -0400 Subject: [PATCH 16/30] feat(shield-swap): self-provision both credential systems in the credentials stage Provable API consumer + key via keyless POST /consumers (env vars remain an override), durable ss_ DEX token via POST /api-tokens; both persisted to the profile and pushed onto the live facade. Expired session JWTs no longer count as authenticated; from_profile falls back to the durable token for the data tier. --- .../python/aleo_shield_swap/client.py | 4 +- .../python/aleo_shield_swap/errors.py | 13 ++-- .../python/aleo_shield_swap/lifecycle.py | 63 ++++++++++++++++--- shield-swap-sdk/tests/test_lifecycle.py | 31 ++++++++- 4 files changed, 92 insertions(+), 19 deletions(-) diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index abd9c72..a394609 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -114,7 +114,9 @@ def from_profile(cls, home: Any = None) -> "ShieldSwap": dex.profile = profile dex.journal = Journal(profile.journal_path) if creds.get("jwt"): - dex.api.set_token(creds["jwt"]) + dex.api.set_token(creds["jwt"]) # session tier (24h) + elif creds.get("dex_api_token"): + dex.api.set_token(creds["dex_api_token"]) # durable data tier return dex def _refresh_credentials(self) -> None: diff --git a/shield-swap-sdk/python/aleo_shield_swap/errors.py b/shield-swap-sdk/python/aleo_shield_swap/errors.py index e094692..4c2263e 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/errors.py +++ b/shield-swap-sdk/python/aleo_shield_swap/errors.py @@ -117,11 +117,14 @@ def __init__(self, body: str = "") -> None: class CredentialsMissingError(ShieldSwapError): - """Delegated-proving/scanner credentials are not configured.""" + """Provable API (delegated-proving/scanner) credentials could not be + provisioned or found.""" - def __init__(self) -> None: + def __init__(self, detail: str = "") -> None: super().__init__( - "No delegated-proving credentials — set ALEO_E2E_API_KEY and " - "ALEO_E2E_CONSUMER_ID in the environment (they are persisted to " - "the profile on next onboard())." + "Could not obtain Provable API credentials — automatic " + "provisioning failed and ALEO_E2E_API_KEY/ALEO_E2E_CONSUMER_ID " + "are not set. Retry dex.onboard(), or set those env vars (they " + "are persisted to the profile)." + + (f" ({detail})" if detail else "") ) diff --git a/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py b/shield-swap-sdk/python/aleo_shield_swap/lifecycle.py index 1430cc4..697d7af 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, + NotAuthenticatedError, NotRedeemedError, ) from .journal import Journal @@ -57,7 +58,13 @@ class Stage: # ── Stages ──────────────────────────────────────────────────────────────────── def _auth_done(ctx: _Ctx) -> bool: - return getattr(ctx.dex.api, "_token", None) is not None + if getattr(ctx.dex.api, "_token", None) is None: + return False + try: # a stored-but-expired JWT is not auth + ctx.dex.api.access_status() + return True + except NotAuthenticatedError: + return False def _auth_run(ctx: _Ctx) -> str: @@ -82,23 +89,59 @@ def _redeem_run(ctx: _Ctx) -> str: return f"invite redeemed ({out.status})" +def provision_provable_credentials(endpoint: str, username: str) -> tuple[str, str]: + """Create a Provable API consumer + key (``POST /consumers``, keyless). + + Returns ``(api_key, consumer_id)`` — the pair the scanner and delegated + proving authenticate with. + """ + import requests + + resp = requests.post(f"{endpoint.rstrip('/')}/consumers", + json={"username": username}, timeout=30.0) + if not 200 <= resp.status_code < 300: + raise CredentialsMissingError( + f"POST /consumers -> {resp.status_code}: {resp.text[:120]}") + data = resp.json() + return data["key"], data["consumer"]["id"] + + def _creds_done(ctx: _Ctx) -> bool: c = ctx.profile.credentials - return bool(c.get("dps_api_key") and c.get("dps_consumer_id")) + return bool(c.get("dps_api_key") and c.get("dps_consumer_id") + and c.get("dex_api_token")) def _creds_run(ctx: _Ctx) -> str: - # No provisioning endpoint exists yet (spec blocker) — import from env. - # When the API grows one, this function body is the only change. - key = os.environ.get("ALEO_E2E_API_KEY") - cid = os.environ.get("ALEO_E2E_CONSUMER_ID") - if not (key and cid): - raise CredentialsMissingError() - ctx.profile.save_credentials(dps_api_key=key, dps_consumer_id=cid) + """Register BOTH credential systems, unless already stored. + + Provable API (scanner + delegated proving): imported from + ``ALEO_E2E_API_KEY``/``ALEO_E2E_CONSUMER_ID`` when set, otherwise + provisioned via ``POST /consumers``. Shield-swap API: a durable + ``ss_…`` token minted via ``POST /api-tokens`` (the 24h session JWT + stays for the ``/access/*`` tier). + """ + details: list[str] = [] + creds = ctx.profile.credentials + if not (creds.get("dps_api_key") and creds.get("dps_consumer_id")): + key = os.environ.get("ALEO_E2E_API_KEY") + cid = os.environ.get("ALEO_E2E_CONSUMER_ID") + if key and cid: + details.append("Provable credentials imported from env") + else: + key, cid = provision_provable_credentials( + ctx.profile.endpoint, f"shield-swap-{ctx.profile.address}") + details.append("Provable consumer + API key provisioned") + ctx.profile.save_credentials(dps_api_key=key, dps_consumer_id=cid) + if not ctx.profile.credentials.get("dex_api_token"): + tok = ctx.dex.api.create_api_token( + f"shield-swap-profile-{ctx.profile.address[:16]}") + ctx.profile.save_credentials(dex_api_token=tok.token) + details.append("durable DEX API token minted") refresh = getattr(ctx.dex, "_refresh_credentials", None) if refresh is not None: refresh() # live facade picks up the new key - return "delegated-proving credentials imported from env" + return "; ".join(details) or "already stored" def _airdrop_done(ctx: _Ctx) -> bool: diff --git a/shield-swap-sdk/tests/test_lifecycle.py b/shield-swap-sdk/tests/test_lifecycle.py index 097238a..59e1186 100644 --- a/shield-swap-sdk/tests/test_lifecycle.py +++ b/shield-swap-sdk/tests/test_lifecycle.py @@ -42,6 +42,9 @@ def get_airdrop_job(self, job_id): def get_tokens(self): return [_Tok("waleo.aleo"), _Tok("wusdcx.aleo"), _Tok("weth.aleo")] + def create_api_token(self, name, expires_in_days=None): + return type("T", (), {"token": f"ss_minted_{name[:12]}"})() + class _StubDex: def __init__(self, api, balances, funded_from_start=False): @@ -79,11 +82,13 @@ def test_fresh_account_runs_every_stage(profile, dps_env): assert api.redeemed_with == "CODE" and api.airdrops == 1 assert report.funded is True assert profile.credentials["dps_api_key"] == "k" + assert profile.credentials["dex_api_token"].startswith("ss_minted_") assert profile.credentials["jwt"] == "jwt2" # redeem's fresh token wins def test_registered_funded_account_is_noop(profile, dps_env): - profile.save_credentials(jwt="oldjwt", dps_api_key="k", dps_consumer_id="c") + profile.save_credentials(jwt="oldjwt", dps_api_key="k", dps_consumer_id="c", + dex_api_token="ss_stored") api = _StubApi(has_access=True) api._token = "oldjwt" dex = _StubDex(api, {"waleo.aleo": 7}, funded_from_start=True) @@ -100,16 +105,36 @@ def test_redeem_without_code_raises_instructively(profile, dps_env): run_onboard(dex, profile) # no invite_code -def test_missing_dps_creds_raise(profile, monkeypatch): +def test_provisioning_failure_raises_instructively(profile, monkeypatch): monkeypatch.delenv("ALEO_E2E_API_KEY", raising=False) monkeypatch.delenv("ALEO_E2E_CONSUMER_ID", raising=False) + monkeypatch.setattr("aleo_shield_swap.lifecycle.provision_provable_credentials", + lambda endpoint, username: (_ for _ in ()).throw( + CredentialsMissingError("POST /consumers -> 500"))) api = _StubApi(has_access=True) api._token = "jwt" dex = _StubDex(api, {"waleo.aleo": 7}) - with pytest.raises(CredentialsMissingError): + with pytest.raises(CredentialsMissingError, match="consumers"): run_onboard(dex, profile) +def test_credentials_auto_provision_both_systems(profile, monkeypatch): + monkeypatch.delenv("ALEO_E2E_API_KEY", raising=False) + monkeypatch.delenv("ALEO_E2E_CONSUMER_ID", raising=False) + monkeypatch.setattr("aleo_shield_swap.lifecycle.provision_provable_credentials", + lambda endpoint, username: ("pk-auto", "cid-auto")) + api = _StubApi(has_access=True) + api._token = "jwt" + dex = _StubDex(api, {"waleo.aleo": 7}, funded_from_start=True) + report = run_onboard(dex, profile) + creds_stage = next(o for o in report.outcomes if o.name == "credentials") + assert creds_stage.action == "ran" + assert "provisioned" in creds_stage.detail and "minted" in creds_stage.detail + assert profile.credentials["dps_api_key"] == "pk-auto" + assert profile.credentials["dps_consumer_id"] == "cid-auto" + assert profile.credentials["dex_api_token"].startswith("ss_minted_") + + def test_rate_limited_airdrop_with_funds_is_tolerated(profile, dps_env): api = _StubApi(has_access=True) api._token = "jwt" From 927835ac5e791804d219ac65c911c5b729acb81c Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 10:23:16 -0400 Subject: [PATCH 17/30] =?UTF-8?q?fix(shield-swap):=20dogfooding=20fixes=20?= =?UTF-8?q?=E2=80=94=20DPS=20payload=20shape,=20scanner=20re-registration,?= =?UTF-8?q?=20swap=5Fmany=20quotes=20before=20swapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three found by walking the participant journey live: the DPS now nests the transaction under 'transaction'; fresh accounts must re-register with the scanner once the API key exists; and spot-estimated min-out is unsatisfiable when the pool fee exceeds the slippage allowance, so swap_many quotes the route once per batch. --- .../python/aleo_shield_swap/_calls.py | 4 +++ .../python/aleo_shield_swap/client.py | 19 +++++++++++++ shield-swap-sdk/tests/test_core.py | 14 ++++++++++ shield-swap-sdk/tests/test_swap_many.py | 27 +++++++++++++++++++ 4 files changed, 64 insertions(+) diff --git a/shield-swap-sdk/python/aleo_shield_swap/_calls.py b/shield-swap-sdk/python/aleo_shield_swap/_calls.py index 71b81ae..dae5e93 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_calls.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_calls.py @@ -22,6 +22,10 @@ def extract_tx_id(payload: Any) -> str: if isinstance(payload, str) and payload.strip(): return payload.strip() if isinstance(payload, dict): + # Current DPS shape: the whole transaction nested under "transaction". + tx = payload.get("transaction") + if isinstance(tx, dict) and tx.get("id"): + return str(tx["id"]) for key in ("transaction_id", "transactionId", "id", "txid", "tx_id"): if payload.get(key): return str(payload[key]) diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index a394609..210790f 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -149,6 +149,11 @@ def _refresh_credentials(self) -> None: records.scanner.consumer_id = cid except Exception: pass # scanner unreachable — built lazily later + # Re-register now that the key exists: a keyless registration at + # from_profile() time does not stick for fresh accounts. + account = getattr(self._aleo, "default_account", None) + if account is not None: + records.register(account) def onboard(self, invite_code: Optional[str] = None) -> OnboardReport: """Register this profile end to end — safe to re-run any time. @@ -554,6 +559,19 @@ def swap_many( 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. + expected_out: Optional[int] = None + pool = self.get_pool(pool_key) + token_out_id = pool.token1 if token_in_id == pool.token0 else pool.token0 + try: + route = self.api.get_route(token_in=token_in_id, + token_out=token_out_id, + amount_in=amount_in) + if route.estimated_amount_out: + expected_out = int(route.estimated_amount_out) + except ShieldSwapError: + pass # no quotable route — spot fallback counters = self.journal.reserve_counters(count) handles: list[SwapHandle] = [] failures: list[dict] = [] @@ -562,6 +580,7 @@ def swap_many( try: handle = self.swap(pool_key=pool_key, token_in_id=token_in_id, amount_in=amount_in, slippage_bps=slippage_bps, + expected_out=expected_out, identity=ident, account=acct).delegate(acct) self.journal.record_swap(handle, counter) handles.append(handle) diff --git a/shield-swap-sdk/tests/test_core.py b/shield-swap-sdk/tests/test_core.py index e3105e9..e4474f2 100644 --- a/shield-swap-sdk/tests/test_core.py +++ b/shield-swap-sdk/tests/test_core.py @@ -127,3 +127,17 @@ def test_select_token_record_filters_token_id_and_raises(): min_amount=400, token_id="9field") with pytest.raises(InsufficientRecordsError): select_token_record(_Aleo([]), program="tok.aleo", min_amount=1) + + +def test_extract_tx_id_handles_all_dps_shapes(): + import pytest + from aleo_shield_swap._calls import extract_tx_id + + assert extract_tx_id("at1abc") == "at1abc" + assert extract_tx_id({"transaction_id": "at1a"}) == "at1a" + assert extract_tx_id({"id": "at1b"}) == "at1b" + # current DPS shape: full transaction nested under "transaction" + assert extract_tx_id({"transaction": {"type": "execute", "id": "at1c", + "execution": {}}}) == "at1c" + with pytest.raises(ValueError, match="Cannot find"): + extract_tx_id({"transaction": {"type": "execute"}}) diff --git a/shield-swap-sdk/tests/test_swap_many.py b/shield-swap-sdk/tests/test_swap_many.py index 0a4ae58..2fc92a4 100644 --- a/shield-swap-sdk/tests/test_swap_many.py +++ b/shield-swap-sdk/tests/test_swap_many.py @@ -21,6 +21,11 @@ def dex(tmp_path, monkeypatch): lambda aleo, acct, prog, c: type( "I", (), {"counter": c, "blinding_factor": f"bf{c}", "blinded_address": f"ba{c}"})()) + monkeypatch.setattr(ShieldSwap, "get_pool", + lambda self, key: type("P", (), {"token0": "t0", + "token1": "t1"})()) + monkeypatch.setattr(d.api, "get_route", lambda **kw: type( + "R", (), {"estimated_amount_out": None})()) return d @@ -72,3 +77,25 @@ def test_swap_many_requires_journal(dex): dex.journal = None with pytest.raises(ValueError, match="from_profile"): dex.swap_many(pool_key="1field", token_in_id="t0", amount_in=5, count=2) + + +def test_swap_many_quotes_route_for_expected_out(dex, monkeypatch): + monkeypatch.setattr(dex.api, "get_route", + lambda **kw: type("R", (), {"estimated_amount_out": "990"})()) + seen = [] + + def fake_swap(self, *, expected_out=None, identity=None, **kw): + seen.append(expected_out) + + class _Call: + def delegate(inner, account=None): + return SwapHandle(swap_id=f"s{identity.counter}", + blinding_factor="bf", blinded_address="ba", + token_in_id="t0", token_out_id="t1", + pool_key="1field", amount_in=5, + transaction_id="tx", program="p") + return _Call() + + monkeypatch.setattr(ShieldSwap, "swap", fake_swap) + dex.swap_many(pool_key="1field", token_in_id="t0", amount_in=5, count=2) + assert seen == [990, 990] # quoted once, applied to every swap From 8a1f49149fb5d805ea70b5d5fc244739786c0746 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 10:37:00 -0400 Subject: [PATCH 18/30] fix(shield-swap): route quotes use canonical decimal units; DPS-tolerant swap deadline Live-diffed a rejected swap against a confirmed one: our amount_out_min was 83x too high because /route speaks canonical decimals while swap_many passed base units. Quotes now convert base->decimal->base via registry decimals; swap's default deadline widens to 10k blocks (~8h at ~3s blocks) since DPS latency can outlive 100. --- shield-swap-sdk/AGENTS.md | 13 +++-- .../python/aleo_shield_swap/api.py | 5 +- .../python/aleo_shield_swap/client.py | 49 ++++++++++++++----- shield-swap-sdk/tests/test_swap.py | 2 +- shield-swap-sdk/tests/test_swap_many.py | 29 +++++++++-- 5 files changed, 77 insertions(+), 21 deletions(-) diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index 25cad6e..6fe3728 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -161,9 +161,11 @@ management still require a session JWT. -### `api.get_route(self, *, token_in: 'str', token_out: 'str', amount_in: 'int | None' = None) -> 'models.RouteResultDoc'` - +### `api.get_route(self, *, token_in: 'str', token_out: 'str', amount_in: 'Any' = None) -> 'models.RouteResultDoc'` +Best route between two tokens. *amount_in* is a CANONICAL +decimal amount (human units, e.g. ``1.5``) — not base units — +and the returned ``estimated_amount_out`` is decimal too. ### Counters & blinding @@ -191,7 +193,7 @@ fast when something is systematically wrong (e.g. wrong program). ### Chain verbs -### `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' = 100, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: '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, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` Request a private swap — phase one of the two-transaction flow. @@ -205,7 +207,10 @@ 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. +on-chain probe — required for concurrent swaps. The default +*deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- +proving latency; a tight deadline aborts at finalize when proving +outlives it. ### `claim_swap_output(self, handle: 'SwapHandle', *, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[ClaimResult]'` diff --git a/shield-swap-sdk/python/aleo_shield_swap/api.py b/shield-swap-sdk/python/aleo_shield_swap/api.py index 4d13fbe..33bdb59 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/api.py +++ b/shield-swap-sdk/python/aleo_shield_swap/api.py @@ -205,7 +205,10 @@ def get_tokens(self) -> list[models.TokenDoc]: # ── Trading ──────────────────────────────────────────────────────────── def get_route(self, *, token_in: str, token_out: str, - amount_in: int | None = None) -> models.RouteResultDoc: + amount_in: Any = None) -> models.RouteResultDoc: + """Best route between two tokens. *amount_in* is a CANONICAL + decimal amount (human units, e.g. ``1.5``) — not base units — + and the returned ``estimated_amount_out`` is decimal too.""" params: dict[str, Any] = {"token_in": token_in, "token_out": token_out} if amount_in is not None: params["amount_in"] = str(amount_in) diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 210790f..b06bf14 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -388,7 +388,7 @@ def swap( slippage_bps: int = 50, expected_out: Optional[int] = None, sqrt_price_limit: Optional[int] = None, - deadline_offset_blocks: int = 100, + deadline_offset_blocks: int = 10_000, nonce: Optional[int] = None, identity: Optional[BlindedIdentity] = None, token_in_program: Optional[str] = None, @@ -408,7 +408,10 @@ 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. + on-chain probe — required for concurrent swaps. The default + *deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- + proving latency; a tight deadline aborts at finalize when proving + outlives it. """ acct = self._account(account) pool = self.get_pool(pool_key) @@ -537,6 +540,36 @@ def _token_program(self, token_id: str) -> str: # ── Liquidity ──────────────────────────────────────────────────────────── + def _token_decimals(self, token_id: str) -> Optional[int]: + for t in self.api.get_tokens(): + if t.address == token_id: + return int(t.decimals) + return None + + def _quote_expected_out(self, *, token_in_id: str, token_out_id: str, + amount_in: int) -> Optional[int]: + """Base-unit expected output for a trade, via the route quote. + + The route endpoint speaks canonical decimal amounts, the contract + speaks 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. + """ + from decimal import Decimal + dec_in = self._token_decimals(token_in_id) + dec_out = self._token_decimals(token_out_id) + if dec_in is None or dec_out is None: + return None + try: + route = self.api.get_route( + token_in=token_in_id, token_out=token_out_id, + amount_in=Decimal(amount_in) / (10 ** dec_in)) + except ShieldSwapError: + return None # no quotable route + if not route.estimated_amount_out: + return None + return int(Decimal(route.estimated_amount_out) * (10 ** dec_out)) + def swap_many( self, *, @@ -561,17 +594,11 @@ def swap_many( 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. - expected_out: Optional[int] = None pool = self.get_pool(pool_key) token_out_id = pool.token1 if token_in_id == pool.token0 else pool.token0 - try: - route = self.api.get_route(token_in=token_in_id, - token_out=token_out_id, - amount_in=amount_in) - if route.estimated_amount_out: - expected_out = int(route.estimated_amount_out) - except ShieldSwapError: - pass # no quotable route — spot fallback + expected_out = self._quote_expected_out( + token_in_id=token_in_id, token_out_id=token_out_id, + amount_in=amount_in) counters = self.journal.reserve_counters(count) handles: list[SwapHandle] = [] failures: list[dict] = [] diff --git a/shield-swap-sdk/tests/test_swap.py b/shield-swap-sdk/tests/test_swap.py index 8089fae..6cc9643 100644 --- a/shield-swap-sdk/tests/test_swap.py +++ b/shield-swap-sdk/tests/test_swap.py @@ -42,7 +42,7 @@ def test_swap_builds_exact_inputs(stub_aleo): assert args[6] == f"{1_000_000 * 9950 // 10000}u128" # slippage applied assert args[7] == f"{MIN_SQRT_PRICE}u128" # directional default assert args[8] == "123u64" - assert args[9] == "1100u32" # height 1000 + 100 + assert args[9] == "11000u32" # height 1000 + 10_000 (DPS latency) assert args[10] == "1field" and args[11] == "2field" assert len(args) == 12 # Dynamic dispatch: the DEX program and the token wrapper program must be diff --git a/shield-swap-sdk/tests/test_swap_many.py b/shield-swap-sdk/tests/test_swap_many.py index 2fc92a4..cfa90b6 100644 --- a/shield-swap-sdk/tests/test_swap_many.py +++ b/shield-swap-sdk/tests/test_swap_many.py @@ -24,8 +24,8 @@ def dex(tmp_path, monkeypatch): monkeypatch.setattr(ShieldSwap, "get_pool", lambda self, key: type("P", (), {"token0": "t0", "token1": "t1"})()) - monkeypatch.setattr(d.api, "get_route", lambda **kw: type( - "R", (), {"estimated_amount_out": None})()) + monkeypatch.setattr(ShieldSwap, "_quote_expected_out", + lambda self, **kw: None) return d @@ -80,8 +80,8 @@ def test_swap_many_requires_journal(dex): def test_swap_many_quotes_route_for_expected_out(dex, monkeypatch): - monkeypatch.setattr(dex.api, "get_route", - lambda **kw: type("R", (), {"estimated_amount_out": "990"})()) + monkeypatch.setattr(ShieldSwap, "_quote_expected_out", + lambda self, **kw: 990) seen = [] def fake_swap(self, *, expected_out=None, identity=None, **kw): @@ -99,3 +99,24 @@ def delegate(inner, account=None): monkeypatch.setattr(ShieldSwap, "swap", fake_swap) dex.swap_many(pool_key="1field", token_in_id="t0", amount_in=5, count=2) assert seen == [990, 990] # quoted once, applied to every swap + + +def test_quote_expected_out_converts_units_both_ways(tmp_path, monkeypatch): + # fresh client: the shared fixture stubs _quote_expected_out itself + dex = ShieldSwap(_Facade()) + monkeypatch.setattr(dex.api, "get_tokens", lambda: [ + type("T", (), {"address": "tin", "decimals": 18})(), + type("T", (), {"address": "tout", "decimals": 6})()]) + seen = {} + + def fake_route(*, token_in, token_out, amount_in): + seen["amount_in"] = str(amount_in) + return type("R", (), {"estimated_amount_out": "1089.461274"})() + + monkeypatch.setattr(dex.api, "get_route", fake_route) + out = dex._quote_expected_out(token_in_id="tin", token_out_id="tout", + amount_in=10**19) + assert seen["amount_in"] == "10" # base -> canonical decimal + assert out == 1089461274 # canonical -> base units + assert dex._quote_expected_out(token_in_id="unknown", token_out_id="tout", + amount_in=1) is None From e4595580c8b9570c4280d5b0c2f627080289df7f Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 10:48:51 -0400 Subject: [PATCH 19/30] feat(shield-swap): delegate harvests outputs from DPS payload, swap_many fires without confirmation wait --- shield-swap-sdk/AGENTS.md | 8 ++-- .../python/aleo_shield_swap/_calls.py | 46 +++++++++++++++---- .../python/aleo_shield_swap/client.py | 11 +++-- shield-swap-sdk/tests/test_swap_many.py | 4 +- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index 6fe3728..1215f56 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -59,9 +59,11 @@ provider and is skipped silently without one. *count* private swaps of *amount_in* each, with reserved counters. Counters come from the journal (no probe races); every handle is -journaled before the next swap fires, so a crash mid-batch loses -nothing — ``collect_all()`` later claims whatever landed. A failed -swap burns its counter and the batch continues; failures are +journaled as soon as its broadcast is accepted (no confirmation +wait), so a crash mid-batch loses nothing — ``collect_all()`` later +claims whatever finalized. A swap the network rejects simply never +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()``. ### `collect_all(self, account: 'Any' = None) -> 'CollectReport'` diff --git a/shield-swap-sdk/python/aleo_shield_swap/_calls.py b/shield-swap-sdk/python/aleo_shield_swap/_calls.py index dae5e93..84a1dd5 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_calls.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_calls.py @@ -32,6 +32,21 @@ def extract_tx_id(payload: Any) -> str: raise ValueError(f"Cannot find a transaction id in DPS payload: {payload!r}") +def _payload_transitions(payload: Any) -> "list[dict[str, Any]] | None": + """Decoded transitions straight from a DPS payload carrying the full + transaction, or None when the payload is just an id.""" + if not isinstance(payload, dict): + return None + tx = payload.get("transaction") + if not isinstance(tx, dict): + return None + transitions = (tx.get("execution") or {}).get("transitions") + if not isinstance(transitions, list): + return None + return [{"program": str(t.get("program")), "function": str(t.get("function")), + "outputs": t.get("outputs", [])} for t in transitions] + + def output_values(outputs: Any) -> list[str]: """Normalize transition outputs (dicts or raw objects) to value strings.""" vals: list[str] = [] @@ -85,18 +100,29 @@ def transact(self, account: Any = None, **fee_kwargs: Any) -> R: self._aleo.network.submit_transaction(tx.raw) return self._build(tx.id, outputs) - def delegate(self, account: Any = None, **fee_kwargs: Any) -> R: - """Delegate proving to the DPS (fee master pays by default), wait for - the transaction, and build the typed result from its root transition.""" + def delegate(self, account: Any = None, *, wait: bool = True, + wait_timeout: float = 180.0, **fee_kwargs: Any) -> R: + """Delegate proving to the DPS (fee master pays by default) and build + the typed result from the root transition. + + Outputs are harvested from the DPS payload itself when it carries + the full transaction, so ``wait=False`` returns as soon as the + broadcast is accepted — callers that read chain state later (e.g. + ``collect_all``) don't need to block on confirmation here. + """ payload = self._bound.delegate(account, **fee_kwargs) tx_id = extract_tx_id(payload) - self._aleo.network.wait_for_transaction(tx_id) - tx = self._aleo.network.get_transaction_object(tx_id) - decoded = [ - {"program": str(t.program_id), "function": str(t.function_name), - "outputs": list(t.outputs())} - for t in tx.transitions() - ] + decoded = _payload_transitions(payload) + if decoded is None: + self._aleo.network.wait_for_transaction(tx_id, timeout=wait_timeout) + tx = self._aleo.network.get_transaction_object(tx_id) + decoded = [ + {"program": str(t.program_id), "function": str(t.function_name), + "outputs": list(t.outputs())} + for t in tx.transitions() + ] + elif wait: + self._aleo.network.wait_for_transaction(tx_id, timeout=wait_timeout) outputs = root_outputs(decoded, self._bound.program_id, self._bound.function_name) return self._build(tx_id, outputs) diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index b06bf14..11ae0fd 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -583,9 +583,11 @@ def swap_many( """*count* private swaps of *amount_in* each, with reserved counters. Counters come from the journal (no probe races); every handle is - journaled before the next swap fires, so a crash mid-batch loses - nothing — ``collect_all()`` later claims whatever landed. A failed - swap burns its counter and the batch continues; failures are + journaled as soon as its broadcast is accepted (no confirmation + wait), so a crash mid-batch loses nothing — ``collect_all()`` later + claims whatever finalized. A swap the network rejects simply never + 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()``. """ if self.journal is None: @@ -608,7 +610,8 @@ def swap_many( handle = self.swap(pool_key=pool_key, token_in_id=token_in_id, amount_in=amount_in, slippage_bps=slippage_bps, expected_out=expected_out, - identity=ident, account=acct).delegate(acct) + identity=ident, account=acct + ).delegate(acct, wait=False) self.journal.record_swap(handle, counter) handles.append(handle) except Exception as exc: # journal + continue diff --git a/shield-swap-sdk/tests/test_swap_many.py b/shield-swap-sdk/tests/test_swap_many.py index cfa90b6..fd16531 100644 --- a/shield-swap-sdk/tests/test_swap_many.py +++ b/shield-swap-sdk/tests/test_swap_many.py @@ -36,7 +36,7 @@ def fake_swap(self, *, pool_key, token_in_id, amount_in, identity=None, **kw): calls.append(identity.counter) class _Call: - def delegate(inner, account=None): + def delegate(inner, account=None, **kw): if identity.counter in fail_counters: raise RuntimeError(f"boom at {identity.counter}") return SwapHandle(swap_id=f"s{identity.counter}", @@ -88,7 +88,7 @@ def fake_swap(self, *, expected_out=None, identity=None, **kw): seen.append(expected_out) class _Call: - def delegate(inner, account=None): + def delegate(inner, account=None, **kw): return SwapHandle(swap_id=f"s{identity.counter}", blinding_factor="bf", blinded_address="ba", token_in_id="t0", token_out_id="t1", From 5b81ab2a7c7d81aa46ab38f56f59c8a150024dd5 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 11:36:40 -0400 Subject: [PATCH 20/30] feat(shield-swap): batch record partitioning + claim registers token programs + transient-error tolerance in collect_all Live-journey findings: concurrent swaps double-spent the single airdrop record (scanner can't see in-flight spends), claims in fresh processes lacked the token programs' stacks, and a network blip aborted the whole collect loop. swap_many now assigns each swap a distinct record (falling back to confirm-and-rescan when records run out), claim_swap_output registers both token programs, and collect_all leaves transiently-failed claims pending for the next run. --- shield-swap-sdk/AGENTS.md | 2 +- .../python/aleo_shield_swap/client.py | 59 ++++++++++++++++++- shield-swap-sdk/tests/test_swap_many.py | 46 +++++++++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index 1215f56..1b377ce 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -54,7 +54,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, account: 'Any' = None) -> 'SwapBatchReport'` +### `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'` *count* private swaps of *amount_in* each, with reserved counters. diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 11ae0fd..7873c64 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -510,7 +510,15 @@ def claim_swap_output( ) # Trust-critical read: the amounts the claim moves come from the chain. out = self.get_swap_output(handle.swap_id) - self._ensure([], imports) + # The claim dynamically calls BOTH token programs (payout + refund) — + # register them; a fresh process has neither cached. + token_programs = [] + for token_id in (out.token_in, out.token_out): + try: + token_programs.append(self._token_program(str(token_id))) + except ValueError: + pass # unknown to the registry — imports= override + self._ensure(token_programs, imports) inputs = [ handle.blinding_factor, @@ -578,6 +586,7 @@ def swap_many( amount_in: int, count: int, slippage_bps: int = 50, + record_wait_seconds: float = 120.0, account: Any = None, ) -> SwapBatchReport: """*count* private swaps of *amount_in* each, with reserved counters. @@ -602,14 +611,54 @@ def swap_many( token_in_id=token_in_id, token_out_id=token_out_id, amount_in=amount_in) counters = self.journal.reserve_counters(count) + program = self._token_program(token_in_id) + used_records: set[str] = set() + + def _fresh_record() -> Optional[str]: + """An unspent covering record not already used by this batch.""" + for rec in self._aleo.record_provider.find(acct, program=program, + unspent=True): + plaintext = record_plaintext(rec) + info = parse_token_record_info(plaintext) if plaintext else None + if (plaintext and plaintext not in used_records + and info and info["amount"] >= amount_in + and info.get("token_id") in (None, token_in_id)): + return plaintext + return None + handles: list[SwapHandle] = [] failures: list[dict] = [] for counter in counters: + # Each in-flight swap must spend a DISTINCT record — the scanner + # still shows a record unspent until its swap confirms, so reuse + # would double-spend and the network would reject the copy. + record = _fresh_record() + if record is None and handles: + # Out of distinct records: wait for the last broadcast swap + # to confirm, then poll for its change record. + import time as _time + try: + self._aleo.network.wait_for_transaction( + handles[-1].transaction_id, timeout=180.0) + except Exception: + pass + poll_deadline = _time.monotonic() + record_wait_seconds + while record is None and _time.monotonic() < poll_deadline: + _time.sleep(5.0) + record = _fresh_record() + if record is None: + msg = (f"no distinct unspent record covers {amount_in} for " + f"this swap — earlier swaps in the batch hold the " + f"records; collect_all() then retry") + self.journal.record_swap_failed(counter, msg) + failures.append({"counter": counter, "error": msg}) + continue + used_records.add(record) ident = blinded_identity_at(self._aleo, acct, self.program, counter) try: handle = self.swap(pool_key=pool_key, token_in_id=token_in_id, amount_in=amount_in, slippage_bps=slippage_bps, - expected_out=expected_out, + expected_out=expected_out, token_record=record, identity=ident, account=acct ).delegate(acct, wait=False) self.journal.record_swap(handle, counter) @@ -644,6 +693,12 @@ def collect_all(self, account: Any = None) -> CollectReport: except SwapOutputNotFinalizedError: still_pending.append(handle.swap_id or "") continue + except Exception as exc: + # Transient failure (network blip, node hiccup): the claim + # was not journaled, so the next collect_all retries it. + failures_note = f"{type(exc).__name__}: {exc}" + still_pending.append(handle.swap_id or failures_note[:0]) + continue self.journal.record_claim(handle.swap_id or "", res.transaction_id, res.amount_out) claimed.append({"swap_id": handle.swap_id, diff --git a/shield-swap-sdk/tests/test_swap_many.py b/shield-swap-sdk/tests/test_swap_many.py index fd16531..df4b701 100644 --- a/shield-swap-sdk/tests/test_swap_many.py +++ b/shield-swap-sdk/tests/test_swap_many.py @@ -6,8 +6,20 @@ from aleo_shield_swap.types import SwapHandle +RECORDS = [ + {"record_plaintext": f"{{ owner: aleo1x.private, amount: {n}u128.private }}"} + for n in (100, 101, 102) +] + + +class _Provider: + def find(self, account, program=None, unspent=True): + return list(RECORDS) + + class _Facade: network_name = "testnet" + record_provider = _Provider() @pytest.fixture @@ -26,6 +38,8 @@ def dex(tmp_path, monkeypatch): "token1": "t1"})()) monkeypatch.setattr(ShieldSwap, "_quote_expected_out", lambda self, **kw: None) + monkeypatch.setattr(ShieldSwap, "_token_program", + lambda self, token_id: "tok.aleo") return d @@ -120,3 +134,35 @@ def fake_route(*, token_in, token_out, amount_in): assert out == 1089461274 # canonical -> base units assert dex._quote_expected_out(token_in_id="unknown", token_out_id="tout", amount_in=1) is None + + +def test_swap_many_partitions_distinct_records(dex, monkeypatch): + fake, _ = _fake_swap_factory() + seen_records = [] + orig = fake + + def spy(self, *, token_record=None, **kw): + seen_records.append(token_record) + return orig(self, **kw) + + monkeypatch.setattr(ShieldSwap, "swap", spy) + report = dex.swap_many(pool_key="1field", token_in_id="t0", + amount_in=5, count=3) + assert len(report.handles) == 3 + assert len(set(seen_records)) == 3 # every swap spends its own record + + +def test_swap_many_fails_cleanly_when_records_run_out(dex, monkeypatch): + fake, _ = _fake_swap_factory() + monkeypatch.setattr(ShieldSwap, "swap", fake) + + class _Net: + def wait_for_transaction(self, tx_id, timeout=180.0): + return None + + dex._aleo.network = _Net() + report = dex.swap_many(pool_key="1field", token_in_id="t0", + amount_in=5, count=5, record_wait_seconds=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) From 15a879f8595f58f404918648772a478cfe2c3a05 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 11:38:32 -0400 Subject: [PATCH 21/30] =?UTF-8?q?feat(shield-swap):=20Veil-style=20convers?= =?UTF-8?q?ation=20flow=20=E2=80=94=20ask-first=20menu,=20key=20import,=20?= =?UTF-8?q?human=20units?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated guide now opens with the two pre-flight questions (existing account? invite code?), then an ask-what-next options menu in plain language, and ground rules (never paste keys in chat, never show raw base units, don't re-submit slow writes). Profiles import SHIELD_SWAP_PRIVATE_KEY(_FILE) so existing users keep their account. --- shield-swap-sdk/AGENTS.md | 75 +++++++++++++++---- shield-swap-sdk/codegen/gen_context.py | 75 +++++++++++++++---- .../python/aleo_shield_swap/profile.py | 18 ++++- shield-swap-sdk/tests/test_profile.py | 20 +++++ 4 files changed, 154 insertions(+), 34 deletions(-) diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index 1b377ce..49c155a 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -77,21 +77,66 @@ reports. Requires ``from_profile()``. ## Serving a chatting user (the conversation pattern) -1. Run `status()` first — is the account registered and funded, what does - it hold, what is pending. Never onboard an account that `status()` - shows is already set up. -2. Present tradable pairs from the user's ACTUAL balances crossed with - `dex.api.get_pools()`, and ask which they want to trade. -3. Proactively recommend minting/liquidity options: pools matching their - tokens, tick ranges around `get_slot(pool_key)` (see - `SlotView.tick_range`). Don't wait for exact parameters. -4. Confirm, act, report ids. - -The invite code (first run) is the only thing you should ever need to ask -the user for. Errors name their own fix — read the exception message and -do what it says. State (handles, counters, positions) lives in -`~/.shield-swap/`, not in your context: any fresh session re-orients with -`status()`. +### Before doing anything: two questions + +1. **Existing account?** When `status()` shows a brand-new, unregistered + profile, ask whether the user already has a shield-swap account before + creating anything — their funds and access live on the old key. + **NEVER ask the user to paste a private key into the conversation.** + They supply it out-of-band: `export SHIELD_SWAP_PRIVATE_KEY=...` (or + `SHIELD_SWAP_PRIVATE_KEY_FILE=path`) in their own shell before the + profile is first created. +2. **Invite code.** Access is invite-gated per account; `onboard()` stops + with `NotRedeemedError` until one is supplied. Ask the user for their + code; codes are one-time — never guess or reuse. + +### After startup: ask what's next + +When onboarding reports funded, STOP and ask the user what they want to do +— never launch into a journey unprompted. Present the options in plain +language (identities, records, and journals are your business, not the +user's). Frame the setting first — Shield Swap is a private exchange on +Aleo's test network: trading uses test tokens, and what is traded, and by +whom, stays hidden on the public chain — then offer: + +- **Swap tokens** — trade one token for another. It settles in two steps + — placing the trade, then collecting what was bought — and you do both, + so the proceeds arrive without a separate trip. The natural first move. +- **Several swaps at once** — place a handful of trades and watch them all + land (`swap_many`); the busiest way to exercise the exchange. First + show which trades are possible right now (tokens held x live pools) and + ask how many — and which — they want. +- **Open a liquidity position** — instead of trading, become the market: + deposit a pair of tokens so others can trade against them (`mint`). + The user picks the price range; while the market price sits inside it + they earn a cut of every trade passing through. +- **Add or remove liquidity** — grow a position or take some back out + (`increase_liquidity`/`decrease_liquidity`); whatever comes out becomes + earnings to collect. +- **Collect earnings** — sweep everything the account is owed (tokens + bought in earlier swaps, fees its liquidity earned) into the wallet + (`collect_all`); good after any trading session. +- **Building something?** If they are developing a dApp, bot, or agent + integration rather than trading here, use Tier 2 below. + +If the user brings their own playbook (a strategy file, notes, a memory +store), read it and treat it as the plan — it decides what to do, the +verbs here are how. + +### While acting + +1. `status()` first in any session — never onboard an account that is + already set up; state lives in `~/.shield-swap/`, not in your context. +2. Ground every proposal in ACTUAL holdings crossed with + `dex.api.get_pools()`; recommend tick ranges from `get_slot(pool_key)` + (see `SlotView.tick_range`) instead of waiting for exact parameters. +3. **Never show raw base units to the user** — render amounts in human + units with the symbol via the token registry's `decimals` + ("0.0534 ETH", never "53,369,000,000,000 raw"). +4. Writes are slow (delegated proving + confirmation ≈ a minute or two). + Never re-submit because a call seems slow — check `status()` first. +5. Confirm, act, report ids. Errors name their own fix — read the + exception message and do what it says. ## Tier 2 — the development guide (building your own tools) diff --git a/shield-swap-sdk/codegen/gen_context.py b/shield-swap-sdk/codegen/gen_context.py index 1235950..c996a23 100644 --- a/shield-swap-sdk/codegen/gen_context.py +++ b/shield-swap-sdk/codegen/gen_context.py @@ -49,21 +49,66 @@ CONVERSATION_PATTERN = """\ ## Serving a chatting user (the conversation pattern) -1. Run `status()` first — is the account registered and funded, what does - it hold, what is pending. Never onboard an account that `status()` - shows is already set up. -2. Present tradable pairs from the user's ACTUAL balances crossed with - `dex.api.get_pools()`, and ask which they want to trade. -3. Proactively recommend minting/liquidity options: pools matching their - tokens, tick ranges around `get_slot(pool_key)` (see - `SlotView.tick_range`). Don't wait for exact parameters. -4. Confirm, act, report ids. - -The invite code (first run) is the only thing you should ever need to ask -the user for. Errors name their own fix — read the exception message and -do what it says. State (handles, counters, positions) lives in -`~/.shield-swap/`, not in your context: any fresh session re-orients with -`status()`.""" +### Before doing anything: two questions + +1. **Existing account?** When `status()` shows a brand-new, unregistered + profile, ask whether the user already has a shield-swap account before + creating anything — their funds and access live on the old key. + **NEVER ask the user to paste a private key into the conversation.** + They supply it out-of-band: `export SHIELD_SWAP_PRIVATE_KEY=...` (or + `SHIELD_SWAP_PRIVATE_KEY_FILE=path`) in their own shell before the + profile is first created. +2. **Invite code.** Access is invite-gated per account; `onboard()` stops + with `NotRedeemedError` until one is supplied. Ask the user for their + code; codes are one-time — never guess or reuse. + +### After startup: ask what's next + +When onboarding reports funded, STOP and ask the user what they want to do +— never launch into a journey unprompted. Present the options in plain +language (identities, records, and journals are your business, not the +user's). Frame the setting first — Shield Swap is a private exchange on +Aleo's test network: trading uses test tokens, and what is traded, and by +whom, stays hidden on the public chain — then offer: + +- **Swap tokens** — trade one token for another. It settles in two steps + — placing the trade, then collecting what was bought — and you do both, + so the proceeds arrive without a separate trip. The natural first move. +- **Several swaps at once** — place a handful of trades and watch them all + land (`swap_many`); the busiest way to exercise the exchange. First + show which trades are possible right now (tokens held x live pools) and + ask how many — and which — they want. +- **Open a liquidity position** — instead of trading, become the market: + deposit a pair of tokens so others can trade against them (`mint`). + The user picks the price range; while the market price sits inside it + they earn a cut of every trade passing through. +- **Add or remove liquidity** — grow a position or take some back out + (`increase_liquidity`/`decrease_liquidity`); whatever comes out becomes + earnings to collect. +- **Collect earnings** — sweep everything the account is owed (tokens + bought in earlier swaps, fees its liquidity earned) into the wallet + (`collect_all`); good after any trading session. +- **Building something?** If they are developing a dApp, bot, or agent + integration rather than trading here, use Tier 2 below. + +If the user brings their own playbook (a strategy file, notes, a memory +store), read it and treat it as the plan — it decides what to do, the +verbs here are how. + +### While acting + +1. `status()` first in any session — never onboard an account that is + already set up; state lives in `~/.shield-swap/`, not in your context. +2. Ground every proposal in ACTUAL holdings crossed with + `dex.api.get_pools()`; recommend tick ranges from `get_slot(pool_key)` + (see `SlotView.tick_range`) instead of waiting for exact parameters. +3. **Never show raw base units to the user** — render amounts in human + units with the symbol via the token registry's `decimals` + ("0.0534 ETH", never "53,369,000,000,000 raw"). +4. Writes are slow (delegated proving + confirmation ≈ a minute or two). + Never re-submit because a call seems slow — check `status()` first. +5. Confirm, act, report ids. Errors name their own fix — read the + exception message and do what it says.""" def _entry(name: str, fn: object) -> str: diff --git a/shield-swap-sdk/python/aleo_shield_swap/profile.py b/shield-swap-sdk/python/aleo_shield_swap/profile.py index 26da8dd..610e4b3 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/profile.py +++ b/shield-swap-sdk/python/aleo_shield_swap/profile.py @@ -26,11 +26,21 @@ def _write_private(path: Path, payload: dict[str, Any]) -> None: os.replace(tmp, path) -def _generate_key(network: str) -> tuple[str, str]: - """(private_key, address) for a fresh random account.""" +def _initial_key(network: str) -> tuple[str, str]: + """(private_key, address) for a new profile. + + Imports ``SHIELD_SWAP_PRIVATE_KEY`` (or a path in + ``SHIELD_SWAP_PRIVATE_KEY_FILE``) when set — users with an existing + account supply their key out-of-band, never pasted into a conversation + — and generates a fresh random account otherwise. + """ import aleo net = getattr(aleo, network) - pk = net.PrivateKey.random() + key = os.environ.get("SHIELD_SWAP_PRIVATE_KEY") + key_file = os.environ.get("SHIELD_SWAP_PRIVATE_KEY_FILE") + if not key and key_file: + key = Path(key_file).read_text().strip() + pk = net.PrivateKey.from_string(key) if key else net.PrivateKey.random() return str(pk), str(pk.address) @@ -65,7 +75,7 @@ def load_or_create(cls, home: "Path | str | None" = None, *, path.chmod(0o600) # heal a pre-existing loose mode return cls(home, json.loads(path.read_text())) home.mkdir(parents=True, exist_ok=True) - private_key, address = _generate_key(network) + private_key, address = _initial_key(network) data = {"address": address, "private_key": private_key, "network": network, "endpoint": endpoint} _write_private(path, data) diff --git a/shield-swap-sdk/tests/test_profile.py b/shield-swap-sdk/tests/test_profile.py index 3002bb5..e14bcb4 100644 --- a/shield-swap-sdk/tests/test_profile.py +++ b/shield-swap-sdk/tests/test_profile.py @@ -39,3 +39,23 @@ def test_credentials_roundtrip(tmp_path): def test_journal_path(tmp_path): p = Profile.load_or_create(tmp_path / "home") assert p.journal_path == p.home / "journal.jsonl" + + +def test_existing_key_imported_from_env(tmp_path, monkeypatch): + import aleo + pk = aleo.testnet.PrivateKey.random() + monkeypatch.setenv("SHIELD_SWAP_PRIVATE_KEY", str(pk)) + p = Profile.load_or_create(tmp_path / "home") + assert p.private_key == str(pk) + assert p.address == str(pk.address) + + +def test_existing_key_imported_from_file(tmp_path, monkeypatch): + import aleo + pk = aleo.testnet.PrivateKey.random() + key_file = tmp_path / "key.txt" + key_file.write_text(str(pk) + "\n") + monkeypatch.delenv("SHIELD_SWAP_PRIVATE_KEY", raising=False) + monkeypatch.setenv("SHIELD_SWAP_PRIVATE_KEY_FILE", str(key_file)) + p = Profile.load_or_create(tmp_path / "home") + assert p.address == str(pk.address) From c89e320366a4e58e03c7f06301cea72814b1e4c9 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 11:39:55 -0400 Subject: [PATCH 22/30] test(shield-swap): live gate self-provisions credentials, scale-aligned amounts --- .../integration/test_agent_lifecycle_live.py | 44 ++++--------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py b/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py index 9ddbcd5..d9801b8 100644 --- a/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py +++ b/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py @@ -3,16 +3,13 @@ Opt in: python -m pytest tests/integration/test_agent_lifecycle_live.py -m live Env: ALEO_E2E_PRIVATE_KEY mints a fresh invite code (or set SHIELD_SWAP_INVITE_CODE explicitly) - ALEO_E2E_API_KEY / - ALEO_E2E_CONSUMER_ID delegated-proving + scanner credentials - ALEO_E2E_ENDPOINT API origin (default https://api.provable.com) +Provable + DEX API credentials self-provision during onboarding. One ordered test: onboarding a fresh account is rate-limited and slow, so each phase asserts and feeds the next rather than re-onboarding. """ from __future__ import annotations -import json import os import time @@ -22,8 +19,6 @@ _HAS_CODE_SOURCE = bool(os.environ.get("SHIELD_SWAP_INVITE_CODE") or os.environ.get("ALEO_E2E_PRIVATE_KEY")) -_HAS_DPS = all(os.environ.get(k) - for k in ("ALEO_E2E_API_KEY", "ALEO_E2E_CONSUMER_ID")) def _invite_code() -> str: @@ -43,10 +38,10 @@ def _invite_code() -> str: @pytest.mark.skipif(not _HAS_CODE_SOURCE, reason="no invite code source (SHIELD_SWAP_INVITE_CODE " "or ALEO_E2E_PRIVATE_KEY)") -@pytest.mark.skipif(not _HAS_DPS, - reason="delegated-proving env missing " - "(ALEO_E2E_API_KEY/ALEO_E2E_CONSUMER_ID)") -def test_full_lifecycle_from_fresh_profile(tmp_path): +def test_full_lifecycle_from_fresh_profile(tmp_path, monkeypatch): + # Prove the participant path: credentials must SELF-provision. + monkeypatch.delenv("ALEO_E2E_API_KEY", raising=False) + monkeypatch.delenv("ALEO_E2E_CONSUMER_ID", raising=False) from aleo_shield_swap import ShieldSwap # ── Startup: fresh key material, full registration, airdrop ──────────── @@ -71,10 +66,12 @@ def test_full_lifecycle_from_fresh_profile(tmp_path): # Pick a pool whose tokens we actually hold (the conversation pattern). pool = next(p for p in pools if p.token0 in held or p.token1 in held) token_in = pool.token0 if pool.token0 in held else pool.token1 + state = dex.get_pool(pool.key) + scale_in = state.scale0 if token_in == pool.token0 else state.scale1 # ── Swaps: concurrent counters, journaled handles ─────────────────────── batch = dex.swap_many(pool_key=pool.key, token_in_id=token_in, - amount_in=10**4, count=2) + amount_in=10**4 * int(scale_in), count=2) assert len(batch.handles) == 2, f"swap failures: {batch.failures}" assert len({h.blinded_address for h in batch.handles}) == 2 @@ -93,28 +90,3 @@ def test_full_lifecycle_from_fresh_profile(tmp_path): st2 = fresh.status() assert st2.pending_claim_ids == [] assert st2.counter_cursor == 2 - - -@pytest.mark.skipif(not _HAS_CODE_SOURCE, - reason="no invite code source") -def test_live_registration_without_dps_creds(tmp_path, monkeypatch): - """The registration half of the lifecycle, provable without DPS creds: - fresh account authenticates, redeems a minted invite, and the - credentials stage fails with the instructive error (not a crash).""" - from aleo_shield_swap import ShieldSwap - from aleo_shield_swap.errors import CredentialsMissingError - - monkeypatch.delenv("ALEO_E2E_API_KEY", raising=False) - monkeypatch.delenv("ALEO_E2E_CONSUMER_ID", raising=False) - - dex = ShieldSwap.from_profile(tmp_path / "home") - with pytest.raises(CredentialsMissingError, match="ALEO_E2E_API_KEY"): - dex.onboard(invite_code=_invite_code()) - - st = dex.status() - assert st.authenticated and st.has_access # auth + redeem DID land - stages = {e["name"]: e["action"] for e in dex.journal.events() - if e["type"] == "stage"} - assert stages["authenticate"] == "ran" and stages["redeem"] == "ran" - # And the fresh JWT is persisted for the next session. - assert json.loads((dex.profile.home / "credentials.json").read_text())["jwt"] From 8def282550f5a7ba34cfec4d3c97265d4fe6b57b Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 11:46:47 -0400 Subject: [PATCH 23/30] fix(shield-swap): register token program with explicit records, fixed-point quotes, review cleanups The live gate caught it: swap(token_record=...) skipped registering the token program, so partitioned batches failed to prove in fresh processes. Also: canonical-decimal quotes serialize fixed-point (never '1E-8'), pick_covering_record gains exclude= and swap_many reuses it, env keys are stripped, dead agent handlers and a dead diagnostic removed. --- .../python/aleo_shield_swap/_core.py | 8 +++-- .../python/aleo_shield_swap/agent.py | 10 ------- .../python/aleo_shield_swap/client.py | 29 ++++++++++--------- .../python/aleo_shield_swap/profile.py | 2 +- 4 files changed, 21 insertions(+), 28 deletions(-) diff --git a/shield-swap-sdk/python/aleo_shield_swap/_core.py b/shield-swap-sdk/python/aleo_shield_swap/_core.py index b775e2b..c1ac735 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_core.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_core.py @@ -132,16 +132,18 @@ def normalize_mapping_value(raw: Any) -> Optional[str]: def pick_covering_record(records: Any, *, min_amount: int, - token_id: Optional[str]) -> Optional[str]: + token_id: Optional[str], + exclude: Optional[set[str]] = None) -> Optional[str]: """Smallest unspent token-record plaintext covering *min_amount*, or None. *token_id* filters registry-style records; wrapper-program records carry - no ``token_id`` and match any. + no ``token_id`` and match any. *exclude* skips records already assigned + (e.g. to earlier swaps of a concurrent batch). """ candidates: list[tuple[int, str]] = [] for rec in records: plaintext = record_plaintext(rec) - if not plaintext: + if not plaintext or (exclude and plaintext in exclude): continue info = parse_token_record_info(plaintext) if info is None or info["amount"] < min_amount: diff --git a/shield-swap-sdk/python/aleo_shield_swap/agent.py b/shield-swap-sdk/python/aleo_shield_swap/agent.py index 047da39..c0b10c4 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/agent.py +++ b/shield-swap-sdk/python/aleo_shield_swap/agent.py @@ -43,16 +43,6 @@ def _h_get_pools(dex: Any, args: dict[str, Any]) -> Any: for p in dex.api.get_pools()] -def _h_get_route(dex: Any, args: dict[str, Any]) -> Any: - return _serialize(dex.api.get_route( - token_in=args["token_in"], token_out=args["token_out"], - amount_in=args.get("amount_in"))) - - -def _h_get_slot(dex: Any, args: dict[str, Any]) -> Any: - return _serialize(dex.get_slot(args["pool_key"])) - - def _h_get_balances(dex: Any, args: dict[str, Any]) -> Any: return dex.get_balances(address=args.get("address")) diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 7873c64..9586e92 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -16,6 +16,7 @@ from ._core import ( ensure_programs, find_position_plaintext, + pick_covering_record, record_plaintext, generate_field_nonce, generate_swap_nonce, @@ -434,7 +435,10 @@ def swap( ) token_programs = [program] else: - token_programs = [token_in_program] if token_in_program else [] + # An explicit record still needs its program registered with the + # prover — resolve it unless the caller named one. + program = token_in_program or self._token_program(token_in_id) + token_programs = [program] # Dynamic dispatch: the prover cannot discover token callees # statically — register the DEX program and the involved token # programs with the process before authorization. @@ -569,9 +573,10 @@ def _quote_expected_out(self, *, token_in_id: str, token_out_id: str, if dec_in is None or dec_out is None: return None try: + canonical = Decimal(amount_in) / (10 ** dec_in) route = self.api.get_route( token_in=token_in_id, token_out=token_out_id, - amount_in=Decimal(amount_in) / (10 ** dec_in)) + amount_in=f"{canonical:f}") # fixed-point, never "1E-8" except ShieldSwapError: return None # no quotable route if not route.estimated_amount_out: @@ -616,15 +621,11 @@ def swap_many( def _fresh_record() -> Optional[str]: """An unspent covering record not already used by this batch.""" - for rec in self._aleo.record_provider.find(acct, program=program, - unspent=True): - plaintext = record_plaintext(rec) - info = parse_token_record_info(plaintext) if plaintext else None - if (plaintext and plaintext not in used_records - and info and info["amount"] >= amount_in - and info.get("token_id") in (None, token_in_id)): - return plaintext - return None + records = self._aleo.record_provider.find(acct, program=program, + unspent=True) + return pick_covering_record(records, min_amount=amount_in, + token_id=token_in_id, + exclude=used_records) handles: list[SwapHandle] = [] failures: list[dict] = [] @@ -659,6 +660,7 @@ def _fresh_record() -> Optional[str]: handle = self.swap(pool_key=pool_key, token_in_id=token_in_id, amount_in=amount_in, slippage_bps=slippage_bps, expected_out=expected_out, token_record=record, + token_in_program=program, identity=ident, account=acct ).delegate(acct, wait=False) self.journal.record_swap(handle, counter) @@ -693,11 +695,10 @@ def collect_all(self, account: Any = None) -> CollectReport: except SwapOutputNotFinalizedError: still_pending.append(handle.swap_id or "") continue - except Exception as exc: + except Exception: # Transient failure (network blip, node hiccup): the claim # was not journaled, so the next collect_all retries it. - failures_note = f"{type(exc).__name__}: {exc}" - still_pending.append(handle.swap_id or failures_note[:0]) + still_pending.append(handle.swap_id or "") continue self.journal.record_claim(handle.swap_id or "", res.transaction_id, res.amount_out) diff --git a/shield-swap-sdk/python/aleo_shield_swap/profile.py b/shield-swap-sdk/python/aleo_shield_swap/profile.py index 610e4b3..9141521 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/profile.py +++ b/shield-swap-sdk/python/aleo_shield_swap/profile.py @@ -36,7 +36,7 @@ def _initial_key(network: str) -> tuple[str, str]: """ import aleo net = getattr(aleo, network) - key = os.environ.get("SHIELD_SWAP_PRIVATE_KEY") + key = (os.environ.get("SHIELD_SWAP_PRIVATE_KEY") or "").strip() key_file = os.environ.get("SHIELD_SWAP_PRIVATE_KEY_FILE") if not key and key_file: key = Path(key_file).read_text().strip() From a70b2a52f7fb2d5ed6948631c8459f71cbf4ee30 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 11:52:54 -0400 Subject: [PATCH 24/30] feat(shield-swap): ship the agent guide and skill in the wheel AGENTS.md is generated into the package (python -m aleo_shield_swap prints it; agent_guide() returns it), the Claude skill ships under aleo_shield_swap/skills/, and the wheel smoke test proves both land. --- .github/workflows/sdk-wheels.yml | 1 + shield-swap-sdk/README.md | 13 +- shield-swap-sdk/codegen/gen_context.py | 20 +- .../python/aleo_shield_swap/AGENTS.md | 349 ++++++++++++++++++ .../python/aleo_shield_swap/__init__.py | 11 +- .../python/aleo_shield_swap/__main__.py | 4 + .../python/aleo_shield_swap/skills/SKILL.md | 18 + shield-swap-sdk/tests/test_package.py | 9 + 8 files changed, 413 insertions(+), 12 deletions(-) create mode 100644 shield-swap-sdk/python/aleo_shield_swap/AGENTS.md create mode 100644 shield-swap-sdk/python/aleo_shield_swap/__main__.py create mode 100644 shield-swap-sdk/python/aleo_shield_swap/skills/SKILL.md diff --git a/.github/workflows/sdk-wheels.yml b/.github/workflows/sdk-wheels.yml index 3ea1632..d763a05 100644 --- a/.github/workflows/sdk-wheels.yml +++ b/.github/workflows/sdk-wheels.yml @@ -261,6 +261,7 @@ jobs: run: | cd "$RUNNER_TEMP" python -c "import aleo_shield_swap; print('shield-swap-sdk', aleo_shield_swap.__version__)" + python -m aleo_shield_swap | head -1 # packaged agent guide - name: Upload wheel uses: actions/upload-artifact@v4 with: diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index 278b8f5..383ed6d 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -35,9 +35,16 @@ Requires `aleo-sdk>=0.2` (this repo's SDK; imports as `aleo`) and Python 3.10+. `AGENTS.md` (generated from the SDK's docstrings — always current) is the one page an agent needs: the five-verb lifecycle, the conversation pattern, -and the building-block reference. Claude Code users get it via the -`shield-swap` skill; any MCP client can run the same lifecycle through -`python -m aleo_shield_swap.mcp`. +and the building-block reference. It ships in the wheel: + +```bash +pip install shield-swap-sdk +python -m aleo_shield_swap # prints the agent guide +``` + +Claude Code users get it via the `shield-swap` skill (packaged under +`aleo_shield_swap/skills/`, copy into `.claude/skills/`); any MCP client +can run the same lifecycle through `python -m aleo_shield_swap.mcp`. ## How calls work diff --git a/shield-swap-sdk/codegen/gen_context.py b/shield-swap-sdk/codegen/gen_context.py index c996a23..923a2b4 100644 --- a/shield-swap-sdk/codegen/gen_context.py +++ b/shield-swap-sdk/codegen/gen_context.py @@ -21,7 +21,9 @@ from aleo_shield_swap.api import ApiClient # noqa: E402 from aleo_shield_swap.lifecycle import REGISTRATION_STAGES # noqa: E402 -OUT = _ROOT / "AGENTS.md" +# Two copies of one render: the repo root (for people browsing the repo) +# and inside the package (ships in the wheel; `python -m aleo_shield_swap`). +OUTS = [_ROOT / "AGENTS.md", _ROOT / "python" / "aleo_shield_swap" / "AGENTS.md"] TIER1 = ["from_profile", "onboard", "status", "get_positions", "swap_many", "collect_all"] @@ -190,14 +192,16 @@ def main() -> int: print(page, end="") return 0 if args.check: - current = OUT.read_text() if OUT.exists() else "" - if current != page: - print("AGENTS.md is stale — run: python codegen/gen_context.py", - file=sys.stderr) - return 1 + for out in OUTS: + current = out.read_text() if out.exists() else "" + if current != page: + print(f"{out} is stale — run: python codegen/gen_context.py", + file=sys.stderr) + return 1 return 0 - OUT.write_text(page) - print(f"wrote {OUT} ({len(page)} chars)") + for out in OUTS: + out.write_text(page) + print(f"wrote {out} ({len(page)} chars)") return 0 diff --git a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md new file mode 100644 index 0000000..49c155a --- /dev/null +++ b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md @@ -0,0 +1,349 @@ +# shield-swap — agent guide + +> GENERATED from SDK docstrings by `codegen/gen_context.py` — do not +> edit by hand; edit the docstrings and regenerate. + +Typed Python client for the shield_swap AMM on Aleo +(`pip install shield-swap-sdk`, imports as `aleo_shield_swap`). +MCP alternative: `python -m aleo_shield_swap.mcp` exposes the same +lifecycle as tools. + +## Tier 1 — the lifecycle (six lines end to end) + +```python +from aleo_shield_swap import ShieldSwap + +dex = ShieldSwap.from_profile() # key material auto-managed on disk +dex.onboard(invite_code="...") # first run only; no-op afterwards +pools = dex.api.get_pools() +report = dex.swap_many(pool_key=pools[0].key, token_in_id=pools[0].token0, + amount_in=10**6, count=5) +dex.collect_all() # any session, any time +``` + +### `from_profile(home: 'Any' = 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. + +### `onboard(self, invite_code: 'Optional[str]' = None) -> 'OnboardReport'` + +Register this profile end to end — safe to re-run any time. + +Runs only the registration stages not already satisfied (see +``lifecycle.REGISTRATION_STAGES``); a registered, funded account is +a no-op. The one thing it may need from you: *invite_code*, on the +first run. Requires a profile-bound client (``from_profile()``). + +### `status(self) -> 'SessionStatus'` + +One re-orientation call: identity, access, holdings, pending work. + +Run this first in any session — it answers "is this account already +registered, what do I hold, what is in flight" from the profile, +journal, chain, and API without changing anything. + +### `get_positions(self, account: 'Any' = None) -> 'list[PositionView]'` + +Every open position — journaled ones plus a record scan. + +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'` + +*count* private swaps of *amount_in* each, with reserved counters. + +Counters come from the journal (no probe races); every handle is +journaled as soon as its broadcast is accepted (no confirmation +wait), so a crash mid-batch loses nothing — ``collect_all()`` later +claims whatever finalized. A swap the network rejects simply never +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()``. + +### `collect_all(self, account: 'Any' = None) -> 'CollectReport'` + +Claim every finalized swap and collect owed fees on open positions. + +Safe to run any time, from any session: works off the journal, skips +swaps whose finalize hasn't landed (they stay pending for next time), +never double-claims, and requests exactly the owed amounts the chain +reports. Requires ``from_profile()``. + +## Serving a chatting user (the conversation pattern) + +### Before doing anything: two questions + +1. **Existing account?** When `status()` shows a brand-new, unregistered + profile, ask whether the user already has a shield-swap account before + creating anything — their funds and access live on the old key. + **NEVER ask the user to paste a private key into the conversation.** + They supply it out-of-band: `export SHIELD_SWAP_PRIVATE_KEY=...` (or + `SHIELD_SWAP_PRIVATE_KEY_FILE=path`) in their own shell before the + profile is first created. +2. **Invite code.** Access is invite-gated per account; `onboard()` stops + with `NotRedeemedError` until one is supplied. Ask the user for their + code; codes are one-time — never guess or reuse. + +### After startup: ask what's next + +When onboarding reports funded, STOP and ask the user what they want to do +— never launch into a journey unprompted. Present the options in plain +language (identities, records, and journals are your business, not the +user's). Frame the setting first — Shield Swap is a private exchange on +Aleo's test network: trading uses test tokens, and what is traded, and by +whom, stays hidden on the public chain — then offer: + +- **Swap tokens** — trade one token for another. It settles in two steps + — placing the trade, then collecting what was bought — and you do both, + so the proceeds arrive without a separate trip. The natural first move. +- **Several swaps at once** — place a handful of trades and watch them all + land (`swap_many`); the busiest way to exercise the exchange. First + show which trades are possible right now (tokens held x live pools) and + ask how many — and which — they want. +- **Open a liquidity position** — instead of trading, become the market: + deposit a pair of tokens so others can trade against them (`mint`). + The user picks the price range; while the market price sits inside it + they earn a cut of every trade passing through. +- **Add or remove liquidity** — grow a position or take some back out + (`increase_liquidity`/`decrease_liquidity`); whatever comes out becomes + earnings to collect. +- **Collect earnings** — sweep everything the account is owed (tokens + bought in earlier swaps, fees its liquidity earned) into the wallet + (`collect_all`); good after any trading session. +- **Building something?** If they are developing a dApp, bot, or agent + integration rather than trading here, use Tier 2 below. + +If the user brings their own playbook (a strategy file, notes, a memory +store), read it and treat it as the plan — it decides what to do, the +verbs here are how. + +### While acting + +1. `status()` first in any session — never onboard an account that is + already set up; state lives in `~/.shield-swap/`, not in your context. +2. Ground every proposal in ACTUAL holdings crossed with + `dex.api.get_pools()`; recommend tick ranges from `get_slot(pool_key)` + (see `SlotView.tick_range`) instead of waiting for exact parameters. +3. **Never show raw base units to the user** — render amounts in human + units with the symbol via the token registry's `decimals` + ("0.0534 ETH", never "53,369,000,000,000 raw"). +4. Writes are slow (delegated proving + confirmation ≈ a minute or two). + Never re-submit because a call seems slow — check `status()` first. +5. Confirm, act, report ids. Errors name their own fix — read the + exception message and do what it says. + +## Tier 2 — the development guide (building your own tools) + +Every write verb returns a prepared `DexCall`: nothing touches the +network until a terminal verb — `.simulate()` (local, free), +`.transact()` (local proving, slow), or `.delegate()` (delegated +proving — the practical path). + +### Registration, unbundled + +`onboard()` is a stage list, and the steps WILL change over time — +introspect `lifecycle.REGISTRATION_STAGES`, never hard-code the +sequence. Current stages: + +- `authenticate` +- `redeem` +- `credentials` +- `airdrop` +- `funded` + +Apps that own their onboarding call the same `dex.api` endpoints +the stages use: + +### `api.authenticate(self, address: 'str', sign: 'Any') -> 'str'` + +Challenge/verify handshake; stores and returns the JWT. + +*sign* is a callable taking the challenge message string and +returning an Aleo signature literal (``sign1…``) — e.g.:: + + pk = aleo.testnet.PrivateKey.from_string(key) + api.authenticate(str(pk.address), + lambda msg: str(pk.sign(msg.encode()))) + +### `api.access_status(self) -> 'models.AccessStatusResponse'` + +Whether this authenticated account has redeemed an invite code. + +### `api.redeem_code(self, code: 'str') -> 'models.AccessRedeemResponse'` + +Redeem an invite code; adopts the fresh token the API returns. + +### `api.request_airdrop(self, address: 'str') -> 'models.AirdropStartResult'` + +Start the test-token airdrop job for *address* (private records). + +One claim per address per 15 minutes — raises +:class:`AirdropRateLimitedError` on 429. Poll the returned +``job_id`` with :meth:`get_airdrop_job`. + +### `api.get_airdrop_job(self, job_id: 'str') -> 'models.AirdropJob'` + +Progress of an airdrop job — ``running`` until every transfer lands. + +### `api.create_api_token(self, name: 'str', expires_in_days: "'int | None'" = None) -> 'models.ApiTokenCreatedResponse'` + +Mint a long-lived DEX API token (the secret is returned ONCE). + +JWTs from :meth:`authenticate` expire in 24h; persist the returned +``.token`` for durable access. Tiering (verified live): ``ss_…`` +tokens work on data/trading endpoints; ``/access/*`` and token +management still require a session JWT. + +### `api.get_pools(self) -> 'list[PoolEntry]'` + + + +### `api.get_tokens(self) -> 'list[models.TokenDoc]'` + + + +### `api.get_route(self, *, token_in: 'str', token_out: 'str', amount_in: 'Any' = None) -> 'models.RouteResultDoc'` + +Best route between two tokens. *amount_in* is a CANONICAL +decimal amount (human units, e.g. ``1.5``) — not base units — +and the returned ``estimated_amount_out`` is decimal too. + +### Counters & blinding + +Blinded identities derive deterministically from (view key, counter, +program). Counters must NEVER be reused: reserve them via +`dex.journal.reserve_counters(n)` (what `swap_many` does), or probe +on-chain when no journal exists. Persist `SwapHandle`s — the +blinding factor is the claim secret. + +### `blinded_identity_at(aleo: 'Any', account: 'Any', program: 'str', counter: 'int') -> 'BlindedIdentity'` + +The identity at an exact *counter* — no on-chain probing. + +Use with journal-reserved counters for concurrent swaps; +:func:`next_blinded_identity` (probe-based) remains the recovery path +when no journal exists. + +### `next_blinded_identity(aleo: 'Any', account: 'Any', program: 'str' = 'shield_swap_v3.aleo', *, start_counter: 'int' = 0, max_scan: 'int' = 64) -> 'BlindedIdentity'` + +First unused single-use identity for *account*. + +Derives at ``start_counter, +1, …`` and probes the program's +``used_blinded_addresses`` mapping until one is free. ``max_scan`` fails +fast when something is systematically wrong (e.g. wrong program). + +### Chain verbs + +### `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, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` + +Request a private swap — phase one of the two-transaction flow. + +Resolves the intent against live pool state, derives a single-use +blinded identity from the signer's view key, selects an unspent token +record (or takes *token_record* verbatim), and returns a prepared +call. The terminal verb (``transact``/``delegate``) returns a +:class:`~aleo_shield_swap.types.SwapHandle` — persist it if the +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 +*deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- +proving latency; a tight deadline aborts at finalize when proving +outlives it. + +### `claim_swap_output(self, handle: 'SwapHandle', *, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[ClaimResult]'` + +Claim a private swap's output — phase two of the lifecycle. + +Reads the chain-computed result from ``swap_outputs`` (never an +off-chain service — these amounts gate money movement), proves +ownership of the blinded identity, and prepares ``claim_swap_output``. +The output and any refund arrive as private records owned by the +signer; the mapping entry is consumed. + +Raises :class:`SwapOutputNotFinalizedError` **at prepare time** when +the output is not readable yet (retry after a few blocks) or was +already claimed. + +### `create_pool(self, *, token0_id: 'str', token1_id: 'str', fee: 'int', initial_tick: 'int', tick_spacing: 'Optional[int]' = None, initial_sqrt_price: 'Optional[int]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` + +Create a pool — a single public transaction, no records involved. + +The fee tier must be registered with the program (validated before +submission); tick spacing defaults to the tier's on-chain binding and +the opening price to the tick's sqrt price. + +### `mint(self, *, pool_key: 'str', tick_lower: 'int', tick_upper: 'int', amount0_desired: 'int', amount1_desired: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, recipient: 'Optional[str]' = None, nonce: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[MintResult]'` + +Mint a concentrated-liquidity position as a private PositionNFT. + +Tick bounds are rounded to the pool's spacing; insert hints derive +from the slot's neighbors unless given explicitly. + +### `increase_liquidity(self, *, pool_key: 'str', amount0_desired: 'int', amount1_desired: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, position_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` + +Add funds to an existing position (range fixed at mint). + +### `decrease_liquidity(self, *, pool_key: 'str', liquidity_to_remove: 'int', amount0_min: 'int' = 0, amount1_min: 'int' = 0, position_record: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` + +Remove liquidity from a position; owed amounts become collectable. + +### `collect(self, *, pool_key: 'str', amount0_requested: 'int', amount1_requested: 'int', recipient: 'Optional[str]' = None, position_record: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` + +Collect owed token amounts from a position. + +### `burn(self, *, pool_key: 'str', position_record: 'Optional[str]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'` + +Burn an empty position NFT. + +### `get_pool(self, pool_key: 'str') -> 'g.PoolState'` + +Static pool configuration (token pair, fee, decimal scales). + +### `get_slot(self, pool_key: 'str') -> 'SlotView'` + +Live trading state (sqrt price, tick, in-range liquidity). + +Raises :class:`PoolNotFoundError` when the pool does not exist, or +:class:`PoolNotInitializedError` when it exists but has no slot yet. + +### `get_swap_output(self, swap: "'SwapHandle | str'") -> 'g.SwapOutput'` + +Chain-computed output of a finalized swap request. + +Accepts the :class:`SwapHandle` from ``swap()`` or a bare swap id. +Raises :class:`SwapOutputNotFinalizedError` when the entry is absent — +not finalized yet (retry after a few blocks) or already claimed. + +### `get_balances(self, address: 'Optional[str]' = None, account: 'Any' = None) -> 'dict[str, dict[str, Any]]'` + +Public + private + total per token id, joined via the API's +token registry. Defaults to the bound account's address; returns +only tokens actually held. + +Private balances can only be scanned for the bound account's view +key — when *address* names someone else, ``private`` is 0 for every +token (their records are not scannable) rather than silently mixing +in the caller's own private holdings. + +### `get_private_balances(self, programs: 'list[str]', account: 'Any' = None) -> 'dict[str, int]'` + +Sum of unspent record amounts per wrapper program (spendable +privately). Requires a configured record provider. + +### `derive_pool_key(self, token0: 'str', token1: 'str', fee: 'int') -> 'str'` + + + +### `derive_tick_key(self, pool_key: 'str', tick: 'int') -> 'str'` + + + diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index f7746cd..723ef20 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/__init__.py +++ b/shield-swap-sdk/python/aleo_shield_swap/__init__.py @@ -61,6 +61,15 @@ shield_swap_tools as shield_swap_tools, ) +def agent_guide() -> str: + """The packaged AGENTS.md — everything an agent needs to drive the DEX. + + Also printable from a shell: ``python -m aleo_shield_swap``. + """ + from importlib.resources import files + return files(__name__).joinpath("AGENTS.md").read_text() + + __version__ = "0.2.1" __all__ = [ @@ -77,6 +86,6 @@ "Profile", "Journal", "REGISTRATION_STAGES", "OnboardReport", "StageOutcome", "SessionStatus", "PositionView", "SwapBatchReport", "CollectReport", "blinded_identity_at", - "shield_swap_tools", "dispatch_tool", + "shield_swap_tools", "dispatch_tool", "agent_guide", "__version__", ] diff --git a/shield-swap-sdk/python/aleo_shield_swap/__main__.py b/shield-swap-sdk/python/aleo_shield_swap/__main__.py new file mode 100644 index 0000000..0be4bf6 --- /dev/null +++ b/shield-swap-sdk/python/aleo_shield_swap/__main__.py @@ -0,0 +1,4 @@ +"""``python -m aleo_shield_swap`` prints the packaged agent guide.""" +from . import agent_guide + +print(agent_guide(), end="") diff --git a/shield-swap-sdk/python/aleo_shield_swap/skills/SKILL.md b/shield-swap-sdk/python/aleo_shield_swap/skills/SKILL.md new file mode 100644 index 0000000..7801f39 --- /dev/null +++ b/shield-swap-sdk/python/aleo_shield_swap/skills/SKILL.md @@ -0,0 +1,18 @@ +--- +name: shield-swap +description: Use when the user wants to trade, LP, or build on the shield_swap AMM — setting up an account, redeeming an invite, getting the airdrop, swapping privately, managing liquidity positions, or collecting earnings via the aleo_shield_swap Python SDK. +--- + +Read the packaged agent guide (generated from the SDK — always matches the +installed version): + + python -m aleo_shield_swap + +Follow its Tier 1 lifecycle and conversation pattern. Write Python against +the SDK; don't re-implement flows the verbs already provide, and don't read +SDK source unless the guide genuinely lacks the answer. Preconditions are +enforced in code — on error, read the exception message; it names the verb +that fixes it. + +To install this skill into a Claude Code project, copy this directory to +`.claude/skills/shield-swap/` — or just point the agent at the guide above. diff --git a/shield-swap-sdk/tests/test_package.py b/shield-swap-sdk/tests/test_package.py index 1df211f..774532a 100644 --- a/shield-swap-sdk/tests/test_package.py +++ b/shield-swap-sdk/tests/test_package.py @@ -15,3 +15,12 @@ def test_lifecycle_exports(): "blinded_identity_at", "REGISTRATION_STAGES"): assert hasattr(pkg, name), name assert name in pkg.__all__, name + + +def test_agent_guide_ships_in_the_package(): + import aleo_shield_swap as pkg + guide = pkg.agent_guide() + assert "Tier 1" in guide and "conversation pattern" in guide + from importlib.resources import files + skill = files("aleo_shield_swap").joinpath("skills/SKILL.md").read_text() + assert "python -m aleo_shield_swap" in skill From cca7c5d1090dde05c483312105c751cd2b965603 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 11:55:12 -0400 Subject: [PATCH 25/30] docs: agent-skill instructions in the main README --- README.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/README.md b/README.md index ac0cbaa..e05e3aa 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,43 @@ The SDK ships two layers: Built with snarkvm 4.8.1 (MainnetV0). For build instructions, see [sdk/Readme.md](./sdk/Readme.md). +## Agent skills (trade on Shield Swap by chatting) + +The [`shield-swap-sdk`](./shield-swap-sdk) package ships everything an AI +agent needs to drive the shield_swap AMM — set up an account, redeem an +invite code, get the airdrop, make private swaps, manage liquidity, and +collect earnings — from a single generated guide. + +**Any agent (Claude Code, Codex, Cursor, custom):** + +```bash +pip install shield-swap-sdk +python -m aleo_shield_swap # prints the agent guide (AGENTS.md) +``` + +Point your agent at that guide (or paste it into context) and say things +like *"set up a shield-swap account and get tokens"* or *"find pools and +start swapping"*. The one thing the agent will ask you for is an invite +code; everything else — key material, API credentials, airdrop — is +handled by the SDK. Bring an existing account by exporting +`SHIELD_SWAP_PRIVATE_KEY` (or `SHIELD_SWAP_PRIVATE_KEY_FILE`) before the +first run — never paste a private key into the chat. + +**Claude Code:** working in this repo, the skill is already installed +(`.claude/skills/shield-swap/`) — just ask. In your own project, copy the +packaged skill in: + +```bash +cp -r "$(python -c 'import aleo_shield_swap, pathlib; print(pathlib.Path(aleo_shield_swap.__file__).parent / "skills")')" .claude/skills/shield-swap +``` + +**MCP clients (Claude Desktop, etc.):** + +```bash +pip install "shield-swap-sdk[mcp]" +python -m aleo_shield_swap.mcp # stdio server with the lifecycle tools +``` + ## Quick Start (facade) ```python From b394c259774187c0033a8f53d2599d863a7d1a27 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 11:56:36 -0400 Subject: [PATCH 26/30] docs: concrete AGENTS.md drop-in instruction instead of 'point the agent at it' --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e05e3aa..c4647d5 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,16 @@ collect earnings — from a single generated guide. ```bash pip install shield-swap-sdk -python -m aleo_shield_swap # prints the agent guide (AGENTS.md) +python -m aleo_shield_swap > AGENTS.md # drop the agent guide into your project ``` -Point your agent at that guide (or paste it into context) and say things -like *"set up a shield-swap account and get tokens"* or *"find pools and -start swapping"*. The one thing the agent will ask you for is an invite -code; everything else — key material, API credentials, airdrop — is -handled by the SDK. Bring an existing account by exporting +Most coding agents (Codex, Cursor, Claude Code, …) automatically read a +repo-root `AGENTS.md`, so after that one command just chat: *"set up a +shield-swap account and get tokens"*, *"find pools and start swapping"*. +(Equivalently, open with "run `python -m aleo_shield_swap` and follow that +guide", or paste the output into the agent's instructions.) The one thing +the agent will ask you for is an invite code; everything else — key +material, API credentials, airdrop — is handled by the SDK. Bring an existing account by exporting `SHIELD_SWAP_PRIVATE_KEY` (or `SHIELD_SWAP_PRIVATE_KEY_FILE`) before the first run — never paste a private key into the chat. From 1ce0c176bde673d7dd5aa8a4399ec95e41edc905 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 12:07:16 -0400 Subject: [PATCH 27/30] feat(shield-swap): liquidity flows live-proven and in the exit gate mint/increase/decrease/collect verified live on testnet (position minted + journaled, resized both directions, owed earnings collected to zero). Fixes found doing it: mint/increase/collect now always register the token programs (auto-selected records skipped registration, same class as the claim bug), mint journals its position and burn journals the closure at the client layer, and the live gate + rehearsal script gained a liquidity phase with retry tolerance for the post-resize scanner lag Veil's runbook documents. --- .../python/aleo_shield_swap/agent.py | 5 +- .../python/aleo_shield_swap/client.py | 51 +++++++++++++++---- shield-swap-sdk/scripts/rehearsal.py | 14 +++-- .../integration/test_agent_lifecycle_live.py | 33 +++++++++++- shield-swap-sdk/tests/test_agent.py | 13 +---- shield-swap-sdk/tests/test_liquidity.py | 15 ++++++ 6 files changed, 103 insertions(+), 28 deletions(-) diff --git a/shield-swap-sdk/python/aleo_shield_swap/agent.py b/shield-swap-sdk/python/aleo_shield_swap/agent.py index c0b10c4..7607e3e 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/agent.py +++ b/shield-swap-sdk/python/aleo_shield_swap/agent.py @@ -86,10 +86,7 @@ def _h_mint_position(dex: Any, args: dict[str, Any]) -> Any: amount1_desired=int(args["amount1_desired"]), token0_program=args.get("token0_program"), token1_program=args.get("token1_program")).delegate() - if dex.journal is not None and result.position_token_id: - dex.journal.record_position(result.position_token_id, - args["pool_key"], result.transaction_id) - return _serialize(result) + return _serialize(result) # the client journals the position def _h_adjust_liquidity(dex: Any, args: dict[str, Any]) -> Any: diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 9586e92..ffba529 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -843,20 +843,32 @@ def mint( tick_lower_hint=lo_hint, tick_upper_hint=hi_hint, ).to_plaintext() + program0 = token0_program or self._token_program(pool.token0) + program1 = token1_program or self._token_program(pool.token1) record0 = token0_record or select_token_record( - self._aleo, program=token0_program or self._token_program(pool.token0), + self._aleo, program=program0, min_amount=amount0_desired, token_id=pool.token0, account=acct) record1 = token1_record or select_token_record( - self._aleo, program=token1_program or self._token_program(pool.token1), + self._aleo, program=program1, min_amount=amount1_desired, token_id=pool.token1, account=acct) - self._ensure([p for p in (token0_program, token1_program) if p], imports) + self._ensure([program0, program1], imports) field_nonce = nonce if nonce is not None else generate_field_nonce() to = recipient or str(acct.address) inputs = [field_nonce, record0, record1, to, request, pool.token0, pool.token1] bound = self._aleo.programs.get(self.program).functions.mint(*inputs) - return DexCall(self._aleo, bound, self._position_result(MintResult)) + + base_build = self._position_result(MintResult) + + def build_result(tx_id: str, outputs: list[Any]) -> MintResult: + result = base_build(tx_id, outputs) + if self.journal is not None and result.position_token_id: + self.journal.record_position(result.position_token_id, + pool_key, tx_id) + return result + + return DexCall(self._aleo, bound, build_result) def increase_liquidity( self, @@ -889,13 +901,15 @@ def increase_liquidity( 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 self._token_program(pool.token0) + program1 = token1_program or self._token_program(pool.token1) record0 = token0_record or select_token_record( - self._aleo, program=token0_program or self._token_program(pool.token0), + self._aleo, program=program0, min_amount=amount0_desired, token_id=pool.token0, account=acct) record1 = token1_record or select_token_record( - self._aleo, program=token1_program or self._token_program(pool.token1), + self._aleo, program=program1, min_amount=amount1_desired, token_id=pool.token1, account=acct) - self._ensure([p for p in (token0_program, token1_program) if p], imports) + self._ensure([program0, program1], imports) inputs = [ position, record0, record1, @@ -942,7 +956,15 @@ def collect( acct = self._account(account) pool = self.get_pool(pool_key) position = position_record or self._select_position_record(pool_key, acct) - self._ensure([], imports) + # The collect transition pays out via dynamic token-program calls — + # both programs must be registered with the prover. + token_programs = [] + for token_id in (pool.token0, pool.token1): + try: + token_programs.append(self._token_program(str(token_id))) + except ValueError: + pass + self._ensure(token_programs, imports) to = recipient or str(acct.address) inputs = [position, f"{amount0_requested}u128", f"{amount1_requested}u128", pool.token0, pool.token1, to] @@ -962,8 +984,19 @@ def burn( """Burn an empty position NFT.""" acct = self._account(account) position = position_record or self._select_position_record(pool_key, acct) + decoded = parse_plaintext(position) + pid = str(decoded.get("token_id")) if isinstance(decoded, dict) else None bound = self._aleo.programs.get(self.program).functions.burn(position) - return DexCall(self._aleo, bound, self._position_result(TxResult)) + + base_build = self._position_result(TxResult) + + def build_result(tx_id: str, outputs: list[Any]) -> TxResult: + result = base_build(tx_id, outputs) + if self.journal is not None and pid: + self.journal.record_position_burned(pid, tx_id) + return result + + return DexCall(self._aleo, bound, build_result) @staticmethod def _position_result(result_cls: Any) -> Any: diff --git a/shield-swap-sdk/scripts/rehearsal.py b/shield-swap-sdk/scripts/rehearsal.py index d46449a..f36839e 100644 --- a/shield-swap-sdk/scripts/rehearsal.py +++ b/shield-swap-sdk/scripts/rehearsal.py @@ -58,9 +58,17 @@ def main() -> int: collected = dex.collect_all() results.append(("collection", f"{len(collected.claimed)} claimed, " f"{len(collected.still_pending)} pending")) - # Liquidity flow (mint/adjust) joins once ops designates a rehearsal - # pool with mintable ranges — noted rather than silently skipped. - results.append(("liquidity", "SKIPPED: no designated rehearsal pool yet")) + + state = dex.get_pool(pools[0].key) + lo, hi = dex.get_slot(pools[0].key).tick_range(width=4) + minted = dex.mint(pool_key=pools[0].key, tick_lower=lo, tick_upper=hi, + amount0_desired=100 * int(state.scale0), + amount1_desired=100 * int(state.scale1)).delegate() + pos = dex._position_state(minted.position_token_id) + dex.decrease_liquidity(pool_key=pools[0].key, + liquidity_to_remove=pos.liquidity // 2).delegate() + results.append(("liquidity", f"ok: minted {minted.position_token_id[:14]}…, " + f"resized; run collection again for earnings")) failed = False for flow, outcome in results: diff --git a/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py b/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py index d9801b8..caef7f3 100644 --- a/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py +++ b/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py @@ -6,7 +6,9 @@ Provable + DEX API credentials self-provision during onboarding. One ordered test: onboarding a fresh account is rate-limited and slow, so -each phase asserts and feeds the next rather than re-onboarding. +each phase asserts and feeds the next rather than re-onboarding. Covers +all four stress-test flows: startup, discovery+swaps, liquidity +(mint/resize/collect), and collection. """ from __future__ import annotations @@ -85,8 +87,37 @@ def test_full_lifecycle_from_fresh_profile(tmp_path, monkeypatch): time.sleep(15) assert claimed_total == 2, "swap outputs never became claimable" + # ── Liquidity: mint, resize, collect the owed earnings ───────────────── + lo, hi = dex.get_slot(pool.key).tick_range(width=4) + scale0, scale1 = int(state.scale0), int(state.scale1) + minted = dex.mint(pool_key=pool.key, tick_lower=lo, tick_upper=hi, + amount0_desired=100 * scale0, + amount1_desired=100 * scale1).delegate() + assert minted.position_token_id, "mint returned no position id" + assert any(v.position_token_id == minted.position_token_id + for v in dex.get_positions()) + + pos = dex._position_state(minted.position_token_id) + assert pos is not None and pos.liquidity > 0 + dex.decrease_liquidity(pool_key=pool.key, + liquidity_to_remove=pos.liquidity // 2).delegate() + + # The re-issued position record can lag the scanner — collect_all is + # designed to be re-run until the owed amounts drain. + deadline = time.monotonic() + 600 + fees = [] + while time.monotonic() < deadline and not fees: + try: + fees = dex.collect_all().fees + except Exception: + pass # stale record / transient — retry + if not fees: + time.sleep(20) + assert fees, "LP earnings never became collectable" + # ── Resumability: a brand-new client sees a clean, consistent state ──── fresh = ShieldSwap.from_profile(tmp_path / "home") st2 = fresh.status() assert st2.pending_claim_ids == [] assert st2.counter_cursor == 2 + assert len(st2.open_positions) == 1 # the minted position, journaled diff --git a/shield-swap-sdk/tests/test_agent.py b/shield-swap-sdk/tests/test_agent.py index 7977348..e713b78 100644 --- a/shield-swap-sdk/tests/test_agent.py +++ b/shield-swap-sdk/tests/test_agent.py @@ -84,28 +84,19 @@ def decrease_liquidity(self, **kw): assert calls[1][0] == "inc" and calls[1][1]["amount0_desired"] == 7 -def test_dispatch_mint_position_journals(): - journaled = [] - - class _Journal: - def record_position(self, pid, pool_key, tx): - journaled.append((pid, pool_key, tx)) - +def test_dispatch_mint_position_serializes(): class _Call: def delegate(self): return MintResult("11field", "txm") class _Dex: - journal = _Journal() - def mint(self, **kw): return _Call() out = dispatch_tool(_Dex(), "mint_position", {"pool_key": "pk", "tick_lower": -60, "tick_upper": 60, "amount0_desired": 1, "amount1_desired": 1}) - assert out["position_token_id"] == "11field" - assert journaled == [("11field", "pk", "txm")] + assert out["position_token_id"] == "11field" # journaling lives in client.mint def test_dispatch_request_airdrop_defaults_to_profile(): diff --git a/shield-swap-sdk/tests/test_liquidity.py b/shield-swap-sdk/tests/test_liquidity.py index b71f404..51399c7 100644 --- a/shield-swap-sdk/tests/test_liquidity.py +++ b/shield-swap-sdk/tests/test_liquidity.py @@ -139,3 +139,18 @@ def test_position_auto_select_requires_matching_pool(): stub = _stub(records=[{"record_plaintext": TOKEN0_RECORD}]) # no position with pytest.raises(InsufficientRecordsError, match="PositionNFT"): ShieldSwap(stub).burn(pool_key="5field") + + +def test_mint_journals_position(stub_aleo, tmp_path): + from aleo_shield_swap.journal import Journal + from aleo_shield_swap.client import ShieldSwap + + dex = ShieldSwap(stub_aleo) + dex.journal = Journal(tmp_path / "journal.jsonl") + result = dex.mint(pool_key="5field", tick_lower=-60, tick_upper=60, + amount0_desired=10, amount1_desired=10, + token0_program="tok.aleo", + token1_program="tok.aleo").delegate() + assert result.position_token_id + assert dex.journal.open_positions() == [ + {"position_token_id": result.position_token_id, "pool_key": "5field"}] From 3bac507bde91d1084aa88b9ed78e29880bc64bd3 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 12:14:19 -0400 Subject: [PATCH 28/30] feat(shield-swap): developer path in the intro menu + Tier 2 client-choice table; gate waits for position records to scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ask-what-next menu now routes builders (option 3) to the development guide, which opens with the client-choice table (bot/server, agent integration, browser dApp -> TS stack) and the integration checklist. The live gate and rehearsal poll for the freshly minted PositionNFT to become scannable before resizing — the mint-side twin of the post-resize scanner lag. --- shield-swap-sdk/AGENTS.md | 110 ++++++++++++----- shield-swap-sdk/codegen/gen_context.py | 114 +++++++++++++----- .../python/aleo_shield_swap/AGENTS.md | 110 ++++++++++++----- shield-swap-sdk/scripts/rehearsal.py | 9 ++ .../integration/test_agent_lifecycle_live.py | 12 ++ 5 files changed, 268 insertions(+), 87 deletions(-) diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index 49c155a..1bcdc78 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -93,35 +93,49 @@ reports. Requires ``from_profile()``. ### After startup: ask what's next When onboarding reports funded, STOP and ask the user what they want to do -— never launch into a journey unprompted. Present the options in plain -language (identities, records, and journals are your business, not the -user's). Frame the setting first — Shield Swap is a private exchange on -Aleo's test network: trading uses test tokens, and what is traded, and by -whom, stays hidden on the public chain — then offer: - -- **Swap tokens** — trade one token for another. It settles in two steps - — placing the trade, then collecting what was bought — and you do both, - so the proceeds arrive without a separate trip. The natural first move. -- **Several swaps at once** — place a handful of trades and watch them all - land (`swap_many`); the busiest way to exercise the exchange. First - show which trades are possible right now (tokens held x live pools) and - ask how many — and which — they want. -- **Open a liquidity position** — instead of trading, become the market: - deposit a pair of tokens so others can trade against them (`mint`). - The user picks the price range; while the market price sits inside it - they earn a cut of every trade passing through. -- **Add or remove liquidity** — grow a position or take some back out - (`increase_liquidity`/`decrease_liquidity`); whatever comes out becomes - earnings to collect. -- **Collect earnings** — sweep everything the account is owed (tokens - bought in earlier swaps, fees its liquidity earned) into the wallet - (`collect_all`); good after any trading session. -- **Building something?** If they are developing a dApp, bot, or agent - integration rather than trading here, use Tier 2 below. - -If the user brings their own playbook (a strategy file, notes, a memory -store), read it and treat it as the plan — it decides what to do, the -verbs here are how. +— never launch into a journey unprompted. Present the options WITH their +context, in plain language (identities, records, and journals are your +business, not the user's): + +1. **Their own playbook.** Ask whether they have instructions of their + own — a strategy file, notes, a memory store, output from a previous + session. If so, read it and treat it as the plan: their document + decides what to do, the verbs here describe how each step works. + +2. **A suggested journey.** Frame the setting first — Shield Swap is a + private exchange on Aleo's test network: trading uses test tokens, and + what is traded, and by whom, stays hidden on the public chain — then + offer: + + - *Swap tokens* — trade one token for another. It settles in two + steps — placing the trade, then collecting what was bought — and you + do both, so the proceeds arrive without a separate trip. The + natural first move. + - *Several swaps at once* — place a handful of trades and watch them + all land (`swap_many`); the busiest way to exercise the exchange. + First show which trades are possible right now (tokens held x live + pools) and ask how many — and which — they want. + - *Open a liquidity position* — instead of trading, become the market: + deposit a pair of tokens so others can trade against them (`mint`). + The user picks the price range; while the market price sits inside + it they earn a cut of every trade passing through. + - *Add or remove liquidity* — grow a position or take some back out + (`increase_liquidity`/`decrease_liquidity`); whatever comes out + becomes earnings to collect. + - *Collect earnings* — sweep everything the account is owed (tokens + bought in earlier swaps, fees its liquidity earned) into the wallet + (`collect_all`); good after any trading session. + +3. **Developing a trading application or agent?** Ask whether they are + building on Shield Swap — a dApp, a trading bot, a server or agent + integration — rather than (or besides) trading here. The chat + journeys above are one way to use the DEX; consumers also build on + the SDK directly — route builders to Tier 2 below, which opens with + the client-choice table (bot/server, agent integration, browser dApp) + and the integration checklist. + +4. **A free-form prompt.** Whatever they describe, map it onto the + verbs and journeys above before improvising against the SDK. ### While acting @@ -140,6 +154,44 @@ verbs here are how. ## Tier 2 — the development guide (building your own tools) +### Building a trading application or agent? + +Start by asking what they are building — the client choice follows from +where the signing keys live: + +| Building | Stack | Keys live | +| --- | --- | --- | +| Bot / server / CLI / notebook | `aleo-sdk` facade + `shield-swap-sdk` (this package) | A local private key (`ShieldSwap.from_profile()` manages it); delegated proving through the Provable prover — fees covered by default. | +| Agent integration | `aleo_shield_swap.agent` (Claude-shape tool schemas + `dispatch_tool`) or `python -m aleo_shield_swap.mcp` (MCP server), over the same client | Same as the underlying client; the tools bind to it. | +| Browser dApp (wallet-signed) | The TypeScript stack: `@provablehq/shield-swap-sdk` + Veil react hooks — not this package | The user's wallet signs and proves. | + +What every integration must handle (each enforced or automated by the +verbs above — this list is the review checklist for code that bypasses +them): + +- **Auth is layered**: a bearer credential (24h session JWT from the + challenge/verify handshake, or a durable `ss_…` API token — data/trading + endpoints only) AND a one-time invite redemption per account. +- **Dynamic-dispatch imports**: every record-spending write must register + the involved token programs with the prover (the verbs resolve this via + the token registry; pass `imports=`/`token_*_program=` to override). +- **Tokens are private records**: spendable balances do not appear in + public reads; one covering record funds an amount — no aggregation. +- **Amounts obey the no-dust rule** both directions (`amount % scale == 0`); + quote in canonical decimals, transact in raw base units, display human. +- **A `SwapHandle` is the only key to a swap's output** — persist before + anything else (the journal does this); claim after finalize with retry. +- **Concurrency needs partitioned blinded-identity counters AND disjoint + input records** — `swap_many` implements the recipe; copy it, don't + improvise. + +Suggested path for a new integrator: (1) `onboard()` a profile — it +doubles as a test fixture; (2) walk swap → `collect_all()` once with the +Tier 1 verbs so the mechanics are concrete; (3) read the reference below +for the surface your app needs; (4) `tests/integration/` and +`scripts/rehearsal.py` in the repo are working reference implementations +of the full journey. + Every write verb returns a prepared `DexCall`: nothing touches the network until a terminal verb — `.simulate()` (local, free), `.transact()` (local proving, slow), or `.delegate()` (delegated diff --git a/shield-swap-sdk/codegen/gen_context.py b/shield-swap-sdk/codegen/gen_context.py index 923a2b4..bdf9d42 100644 --- a/shield-swap-sdk/codegen/gen_context.py +++ b/shield-swap-sdk/codegen/gen_context.py @@ -67,35 +67,49 @@ ### After startup: ask what's next When onboarding reports funded, STOP and ask the user what they want to do -— never launch into a journey unprompted. Present the options in plain -language (identities, records, and journals are your business, not the -user's). Frame the setting first — Shield Swap is a private exchange on -Aleo's test network: trading uses test tokens, and what is traded, and by -whom, stays hidden on the public chain — then offer: - -- **Swap tokens** — trade one token for another. It settles in two steps - — placing the trade, then collecting what was bought — and you do both, - so the proceeds arrive without a separate trip. The natural first move. -- **Several swaps at once** — place a handful of trades and watch them all - land (`swap_many`); the busiest way to exercise the exchange. First - show which trades are possible right now (tokens held x live pools) and - ask how many — and which — they want. -- **Open a liquidity position** — instead of trading, become the market: - deposit a pair of tokens so others can trade against them (`mint`). - The user picks the price range; while the market price sits inside it - they earn a cut of every trade passing through. -- **Add or remove liquidity** — grow a position or take some back out - (`increase_liquidity`/`decrease_liquidity`); whatever comes out becomes - earnings to collect. -- **Collect earnings** — sweep everything the account is owed (tokens - bought in earlier swaps, fees its liquidity earned) into the wallet - (`collect_all`); good after any trading session. -- **Building something?** If they are developing a dApp, bot, or agent - integration rather than trading here, use Tier 2 below. - -If the user brings their own playbook (a strategy file, notes, a memory -store), read it and treat it as the plan — it decides what to do, the -verbs here are how. +— never launch into a journey unprompted. Present the options WITH their +context, in plain language (identities, records, and journals are your +business, not the user's): + +1. **Their own playbook.** Ask whether they have instructions of their + own — a strategy file, notes, a memory store, output from a previous + session. If so, read it and treat it as the plan: their document + decides what to do, the verbs here describe how each step works. + +2. **A suggested journey.** Frame the setting first — Shield Swap is a + private exchange on Aleo's test network: trading uses test tokens, and + what is traded, and by whom, stays hidden on the public chain — then + offer: + + - *Swap tokens* — trade one token for another. It settles in two + steps — placing the trade, then collecting what was bought — and you + do both, so the proceeds arrive without a separate trip. The + natural first move. + - *Several swaps at once* — place a handful of trades and watch them + all land (`swap_many`); the busiest way to exercise the exchange. + First show which trades are possible right now (tokens held x live + pools) and ask how many — and which — they want. + - *Open a liquidity position* — instead of trading, become the market: + deposit a pair of tokens so others can trade against them (`mint`). + The user picks the price range; while the market price sits inside + it they earn a cut of every trade passing through. + - *Add or remove liquidity* — grow a position or take some back out + (`increase_liquidity`/`decrease_liquidity`); whatever comes out + becomes earnings to collect. + - *Collect earnings* — sweep everything the account is owed (tokens + bought in earlier swaps, fees its liquidity earned) into the wallet + (`collect_all`); good after any trading session. + +3. **Developing a trading application or agent?** Ask whether they are + building on Shield Swap — a dApp, a trading bot, a server or agent + integration — rather than (or besides) trading here. The chat + journeys above are one way to use the DEX; consumers also build on + the SDK directly — route builders to Tier 2 below, which opens with + the client-choice table (bot/server, agent integration, browser dApp) + and the integration checklist. + +4. **A free-form prompt.** Whatever they describe, map it onto the + verbs and journeys above before improvising against the SDK. ### While acting @@ -113,6 +127,46 @@ exception message and do what it says.""" +DEVELOPER_OPTIONS = """\ +### Building a trading application or agent? + +Start by asking what they are building — the client choice follows from +where the signing keys live: + +| Building | Stack | Keys live | +| --- | --- | --- | +| Bot / server / CLI / notebook | `aleo-sdk` facade + `shield-swap-sdk` (this package) | A local private key (`ShieldSwap.from_profile()` manages it); delegated proving through the Provable prover — fees covered by default. | +| Agent integration | `aleo_shield_swap.agent` (Claude-shape tool schemas + `dispatch_tool`) or `python -m aleo_shield_swap.mcp` (MCP server), over the same client | Same as the underlying client; the tools bind to it. | +| Browser dApp (wallet-signed) | The TypeScript stack: `@provablehq/shield-swap-sdk` + Veil react hooks — not this package | The user's wallet signs and proves. | + +What every integration must handle (each enforced or automated by the +verbs above — this list is the review checklist for code that bypasses +them): + +- **Auth is layered**: a bearer credential (24h session JWT from the + challenge/verify handshake, or a durable `ss_…` API token — data/trading + endpoints only) AND a one-time invite redemption per account. +- **Dynamic-dispatch imports**: every record-spending write must register + the involved token programs with the prover (the verbs resolve this via + the token registry; pass `imports=`/`token_*_program=` to override). +- **Tokens are private records**: spendable balances do not appear in + public reads; one covering record funds an amount — no aggregation. +- **Amounts obey the no-dust rule** both directions (`amount % scale == 0`); + quote in canonical decimals, transact in raw base units, display human. +- **A `SwapHandle` is the only key to a swap's output** — persist before + anything else (the journal does this); claim after finalize with retry. +- **Concurrency needs partitioned blinded-identity counters AND disjoint + input records** — `swap_many` implements the recipe; copy it, don't + improvise. + +Suggested path for a new integrator: (1) `onboard()` a profile — it +doubles as a test fixture; (2) walk swap → `collect_all()` once with the +Tier 1 verbs so the mechanics are concrete; (3) read the reference below +for the surface your app needs; (4) `tests/integration/` and +`scripts/rehearsal.py` in the repo are working reference implementations +of the full journey.""" + + def _entry(name: str, fn: object) -> str: try: sig = str(inspect.signature(fn)) # type: ignore[arg-type] @@ -145,6 +199,8 @@ def render() -> str: "", "## Tier 2 — the development guide (building your own tools)", "", + DEVELOPER_OPTIONS, + "", "Every write verb returns a prepared `DexCall`: nothing touches the", "network until a terminal verb — `.simulate()` (local, free),", "`.transact()` (local proving, slow), or `.delegate()` (delegated", diff --git a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md index 49c155a..1bcdc78 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md +++ b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md @@ -93,35 +93,49 @@ reports. Requires ``from_profile()``. ### After startup: ask what's next When onboarding reports funded, STOP and ask the user what they want to do -— never launch into a journey unprompted. Present the options in plain -language (identities, records, and journals are your business, not the -user's). Frame the setting first — Shield Swap is a private exchange on -Aleo's test network: trading uses test tokens, and what is traded, and by -whom, stays hidden on the public chain — then offer: - -- **Swap tokens** — trade one token for another. It settles in two steps - — placing the trade, then collecting what was bought — and you do both, - so the proceeds arrive without a separate trip. The natural first move. -- **Several swaps at once** — place a handful of trades and watch them all - land (`swap_many`); the busiest way to exercise the exchange. First - show which trades are possible right now (tokens held x live pools) and - ask how many — and which — they want. -- **Open a liquidity position** — instead of trading, become the market: - deposit a pair of tokens so others can trade against them (`mint`). - The user picks the price range; while the market price sits inside it - they earn a cut of every trade passing through. -- **Add or remove liquidity** — grow a position or take some back out - (`increase_liquidity`/`decrease_liquidity`); whatever comes out becomes - earnings to collect. -- **Collect earnings** — sweep everything the account is owed (tokens - bought in earlier swaps, fees its liquidity earned) into the wallet - (`collect_all`); good after any trading session. -- **Building something?** If they are developing a dApp, bot, or agent - integration rather than trading here, use Tier 2 below. - -If the user brings their own playbook (a strategy file, notes, a memory -store), read it and treat it as the plan — it decides what to do, the -verbs here are how. +— never launch into a journey unprompted. Present the options WITH their +context, in plain language (identities, records, and journals are your +business, not the user's): + +1. **Their own playbook.** Ask whether they have instructions of their + own — a strategy file, notes, a memory store, output from a previous + session. If so, read it and treat it as the plan: their document + decides what to do, the verbs here describe how each step works. + +2. **A suggested journey.** Frame the setting first — Shield Swap is a + private exchange on Aleo's test network: trading uses test tokens, and + what is traded, and by whom, stays hidden on the public chain — then + offer: + + - *Swap tokens* — trade one token for another. It settles in two + steps — placing the trade, then collecting what was bought — and you + do both, so the proceeds arrive without a separate trip. The + natural first move. + - *Several swaps at once* — place a handful of trades and watch them + all land (`swap_many`); the busiest way to exercise the exchange. + First show which trades are possible right now (tokens held x live + pools) and ask how many — and which — they want. + - *Open a liquidity position* — instead of trading, become the market: + deposit a pair of tokens so others can trade against them (`mint`). + The user picks the price range; while the market price sits inside + it they earn a cut of every trade passing through. + - *Add or remove liquidity* — grow a position or take some back out + (`increase_liquidity`/`decrease_liquidity`); whatever comes out + becomes earnings to collect. + - *Collect earnings* — sweep everything the account is owed (tokens + bought in earlier swaps, fees its liquidity earned) into the wallet + (`collect_all`); good after any trading session. + +3. **Developing a trading application or agent?** Ask whether they are + building on Shield Swap — a dApp, a trading bot, a server or agent + integration — rather than (or besides) trading here. The chat + journeys above are one way to use the DEX; consumers also build on + the SDK directly — route builders to Tier 2 below, which opens with + the client-choice table (bot/server, agent integration, browser dApp) + and the integration checklist. + +4. **A free-form prompt.** Whatever they describe, map it onto the + verbs and journeys above before improvising against the SDK. ### While acting @@ -140,6 +154,44 @@ verbs here are how. ## Tier 2 — the development guide (building your own tools) +### Building a trading application or agent? + +Start by asking what they are building — the client choice follows from +where the signing keys live: + +| Building | Stack | Keys live | +| --- | --- | --- | +| Bot / server / CLI / notebook | `aleo-sdk` facade + `shield-swap-sdk` (this package) | A local private key (`ShieldSwap.from_profile()` manages it); delegated proving through the Provable prover — fees covered by default. | +| Agent integration | `aleo_shield_swap.agent` (Claude-shape tool schemas + `dispatch_tool`) or `python -m aleo_shield_swap.mcp` (MCP server), over the same client | Same as the underlying client; the tools bind to it. | +| Browser dApp (wallet-signed) | The TypeScript stack: `@provablehq/shield-swap-sdk` + Veil react hooks — not this package | The user's wallet signs and proves. | + +What every integration must handle (each enforced or automated by the +verbs above — this list is the review checklist for code that bypasses +them): + +- **Auth is layered**: a bearer credential (24h session JWT from the + challenge/verify handshake, or a durable `ss_…` API token — data/trading + endpoints only) AND a one-time invite redemption per account. +- **Dynamic-dispatch imports**: every record-spending write must register + the involved token programs with the prover (the verbs resolve this via + the token registry; pass `imports=`/`token_*_program=` to override). +- **Tokens are private records**: spendable balances do not appear in + public reads; one covering record funds an amount — no aggregation. +- **Amounts obey the no-dust rule** both directions (`amount % scale == 0`); + quote in canonical decimals, transact in raw base units, display human. +- **A `SwapHandle` is the only key to a swap's output** — persist before + anything else (the journal does this); claim after finalize with retry. +- **Concurrency needs partitioned blinded-identity counters AND disjoint + input records** — `swap_many` implements the recipe; copy it, don't + improvise. + +Suggested path for a new integrator: (1) `onboard()` a profile — it +doubles as a test fixture; (2) walk swap → `collect_all()` once with the +Tier 1 verbs so the mechanics are concrete; (3) read the reference below +for the surface your app needs; (4) `tests/integration/` and +`scripts/rehearsal.py` in the repo are working reference implementations +of the full journey. + Every write verb returns a prepared `DexCall`: nothing touches the network until a terminal verb — `.simulate()` (local, free), `.transact()` (local proving, slow), or `.delegate()` (delegated diff --git a/shield-swap-sdk/scripts/rehearsal.py b/shield-swap-sdk/scripts/rehearsal.py index f36839e..e7b9046 100644 --- a/shield-swap-sdk/scripts/rehearsal.py +++ b/shield-swap-sdk/scripts/rehearsal.py @@ -65,6 +65,15 @@ def main() -> int: amount0_desired=100 * int(state.scale0), amount1_desired=100 * int(state.scale1)).delegate() pos = dex._position_state(minted.position_token_id) + from aleo_shield_swap._core import find_position_plaintext + import time + deadline = time.monotonic() + 600 + while time.monotonic() < deadline: # wait for the record to scan + records = dex._aleo.record_provider.find( + dex._aleo.default_account, program=dex.program, unspent=True) + if find_position_plaintext(records, pools[0].key): + break + time.sleep(15) dex.decrease_liquidity(pool_key=pools[0].key, liquidity_to_remove=pos.liquidity // 2).delegate() results.append(("liquidity", f"ok: minted {minted.position_token_id[:14]}…, " diff --git a/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py b/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py index caef7f3..f283312 100644 --- a/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py +++ b/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py @@ -99,6 +99,18 @@ def test_full_lifecycle_from_fresh_profile(tmp_path, monkeypatch): pos = dex._position_state(minted.position_token_id) assert pos is not None and pos.liquidity > 0 + + # The freshly minted PositionNFT record must reach the scanner before a + # resize can spend it — poll instead of failing on the immediate read. + from aleo_shield_swap._core import find_position_plaintext + deadline = time.monotonic() + 600 + while time.monotonic() < deadline: + records = dex._aleo.record_provider.find( + dex._aleo.default_account, program=dex.program, unspent=True) + if find_position_plaintext(records, pool.key): + break + time.sleep(15) + dex.decrease_liquidity(pool_key=pool.key, liquidity_to_remove=pos.liquidity // 2).delegate() From 2385dbbdcd94cfcf70e568dd454aa17081719d75 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 12:17:13 -0400 Subject: [PATCH 29/30] chore: bump SDK versions to 0.2.2 --- sdk-abi/Cargo.lock | 2 +- sdk-abi/Cargo.toml | 2 +- sdk-abi/pyproject.toml | 2 +- sdk/Cargo.lock | 2 +- sdk/Cargo.toml | 2 +- sdk/docs/source/conf.py | 2 +- sdk/pyproject.toml | 2 +- shield-swap-sdk/pyproject.toml | 2 +- shield-swap-sdk/python/aleo_shield_swap/__init__.py | 2 +- shield-swap-sdk/tests/test_package.py | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/sdk-abi/Cargo.lock b/sdk-abi/Cargo.lock index 55de0e3..550a81d 100644 --- a/sdk-abi/Cargo.lock +++ b/sdk-abi/Cargo.lock @@ -19,7 +19,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aleo-abi" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "leo-abi", diff --git a/sdk-abi/Cargo.toml b/sdk-abi/Cargo.toml index 621fb48..5bc1351 100644 --- a/sdk-abi/Cargo.toml +++ b/sdk-abi/Cargo.toml @@ -5,7 +5,7 @@ [package] name = "aleo-abi" -version = "0.2.1" +version = "0.2.2" 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 c8b77f1..343d426 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.2.1" +version = "0.2.2" description = "Python bindings for ABI generation from Aleo bytecode" readme = "README.md" license = {text = "GPL-3.0-or-later"} diff --git a/sdk/Cargo.lock b/sdk/Cargo.lock index c329375..61dc886 100644 --- a/sdk/Cargo.lock +++ b/sdk/Cargo.lock @@ -25,7 +25,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aleo" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "hex", diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index 015963b..c94107f 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.2.1" +version = "0.2.2" description = "A Python sdk for zero-knowledge cryptography based on Aleo" edition = "2021" license = "GPL-3.0-or-later" diff --git a/sdk/docs/source/conf.py b/sdk/docs/source/conf.py index c39cedd..9a2afdc 100644 --- a/sdk/docs/source/conf.py +++ b/sdk/docs/source/conf.py @@ -11,7 +11,7 @@ copyright = '2019-2026, Provable Inc.' author = 'kpp' -version = '0.2.1' +version = '0.2.2' # -- General configuration diff --git a/sdk/pyproject.toml b/sdk/pyproject.toml index bd6e2ca..540fa8c 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.2.1" +version = "0.2.2" readme = "Readme.md" license = {file = "LICENSE.md"} authors = [ diff --git a/shield-swap-sdk/pyproject.toml b/shield-swap-sdk/pyproject.toml index 9cb1082..9026f28 100644 --- a/shield-swap-sdk/pyproject.toml +++ b/shield-swap-sdk/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shield-swap-sdk" -version = "0.2.1" +version = "0.2.2" description = "Python SDK for the shield swap AMM Dex on Aleo" readme = "README.md" requires-python = ">=3.10" diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index 723ef20..c9e4bc8 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/__init__.py +++ b/shield-swap-sdk/python/aleo_shield_swap/__init__.py @@ -70,7 +70,7 @@ def agent_guide() -> str: return files(__name__).joinpath("AGENTS.md").read_text() -__version__ = "0.2.1" +__version__ = "0.2.2" __all__ = [ "ShieldSwap", "AsyncShieldSwap", "ApiClient", "AsyncApiClient", diff --git a/shield-swap-sdk/tests/test_package.py b/shield-swap-sdk/tests/test_package.py index 774532a..472ac0b 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.2.1" + assert aleo_shield_swap.__version__ == "0.2.2" def test_lifecycle_exports(): From bd174826d5831f35bdf3f6d8eeef96ad06e8b77c Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Thu, 16 Jul 2026 12:21:19 -0400 Subject: [PATCH 30/30] test(shield-swap): gate retries a dropped mint after scanner refresh --- .../integration/test_agent_lifecycle_live.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py b/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py index f283312..7af1b52 100644 --- a/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py +++ b/shield-swap-sdk/tests/integration/test_agent_lifecycle_live.py @@ -90,10 +90,22 @@ def test_full_lifecycle_from_fresh_profile(tmp_path, monkeypatch): # ── Liquidity: mint, resize, collect the owed earnings ───────────────── lo, hi = dex.get_slot(pool.key).tick_range(width=4) scale0, scale1 = int(state.scale0), int(state.scale1) - minted = dex.mint(pool_key=pool.key, tick_lower=lo, tick_upper=hi, - amount0_desired=100 * scale0, - amount1_desired=100 * scale1).delegate() - assert minted.position_token_id, "mint returned no position id" + + # Right after claims, the scanner can still serve just-spent records; a + # mint built on one is silently dropped. Model the careful client: + # verify the drop, let the scanner refresh, re-select records, retry. + minted = None + for attempt in range(3): + try: + minted = dex.mint(pool_key=pool.key, tick_lower=lo, tick_upper=hi, + amount0_desired=100 * scale0, + amount1_desired=100 * scale1).delegate() + break + except Exception: + if attempt == 2: + raise + time.sleep(60) # scanner catches up; records re-scan + assert minted and minted.position_token_id, "mint returned no position id" assert any(v.position_token_id == minted.position_token_id for v in dex.get_positions())