From 379854e78ac685fe3b844f6df75f1a320a38ab44 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 25 Sep 2026 13:37:36 -0400 Subject: [PATCH 01/13] [Feat] Include a runnable first-swap example in the Python SDK --- .github/workflows/first-swap-example.yml | 41 +++++ .gitignore | 3 + shield-swap-sdk/README.md | 13 ++ .../examples/first-swap/.gitignore | 3 + shield-swap-sdk/examples/first-swap/README.md | 93 +++++++++++ shield-swap-sdk/examples/first-swap/swap.py | 150 ++++++++++++++++++ .../examples/first-swap/test_swap.py | 88 ++++++++++ shield-swap-sdk/pyproject.toml | 5 + 8 files changed, 396 insertions(+) create mode 100644 .github/workflows/first-swap-example.yml create mode 100644 shield-swap-sdk/examples/first-swap/.gitignore create mode 100644 shield-swap-sdk/examples/first-swap/README.md create mode 100644 shield-swap-sdk/examples/first-swap/swap.py create mode 100644 shield-swap-sdk/examples/first-swap/test_swap.py diff --git a/.github/workflows/first-swap-example.yml b/.github/workflows/first-swap-example.yml new file mode 100644 index 0000000..ec778d8 --- /dev/null +++ b/.github/workflows/first-swap-example.yml @@ -0,0 +1,41 @@ +name: First swap example +on: + pull_request: + paths: + - 'shield-swap-sdk/examples/first-swap/**' + - 'shield-swap-sdk/pyproject.toml' + - 'shield-swap-sdk/python/**' + - 'sdk/python/**' + - '.github/workflows/first-swap-example.yml' + push: + paths: + - 'shield-swap-sdk/examples/first-swap/**' + - 'shield-swap-sdk/pyproject.toml' + - 'shield-swap-sdk/python/**' + - 'sdk/python/**' + - '.github/workflows/first-swap-example.yml' +jobs: + offline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - run: python -m unittest -v test_swap + working-directory: shield-swap-sdk/examples/first-swap + - run: python -m pip wheel --no-deps ./shield-swap-sdk -w /tmp/first-swap-wheel + - name: Check packaged example and exclude state + run: | + python - <<'PY' + from pathlib import Path + from zipfile import ZipFile + wheel = next(Path('/tmp/first-swap-wheel').glob('*.whl')) + with ZipFile(wheel) as archive: + examples = sorted(name for name in archive.namelist() if '/examples/' in name) + assert examples == [ + 'aleo_shield_swap/examples/first_swap/README.md', + 'aleo_shield_swap/examples/first_swap/swap.py', + ], examples + compile(archive.read(examples[1]), examples[1], 'exec') + PY diff --git a/.gitignore b/.gitignore index 6db8505..2dd4e86 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ tmp/ .superpowers/ docs/superpowers/ .worktrees/ + +# Private state for the runnable first-swap example. +.shield-first-swap/ diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index 6c841ee..d7f87d9 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -41,6 +41,19 @@ pip install -e "shield-swap-sdk[mcp]" # + the MCP server Requires `aleo-sdk>=0.3` (this repo's SDK; imports as `aleo`) and Python 3.10+. +## First swap example + +Create a testnet account, request tokens, swap 1.5 USDCx for ETH, and collect +the output with the [runnable first-swap example](examples/first-swap/README.md). +The wheel includes the same example: + +```bash +python -m aleo_shield_swap.examples.first_swap.swap +``` + +Run from a private working directory; account and recovery state are saved in +`.shield-first-swap/`. Use `--claim` from the same directory to resume collection. + ## Agents `AGENTS.md` (generated from the SDK's docstrings — always current) is the diff --git a/shield-swap-sdk/examples/first-swap/.gitignore b/shield-swap-sdk/examples/first-swap/.gitignore new file mode 100644 index 0000000..267bcba --- /dev/null +++ b/shield-swap-sdk/examples/first-swap/.gitignore @@ -0,0 +1,3 @@ +.state/ +.venv/ +__pycache__/ diff --git a/shield-swap-sdk/examples/first-swap/README.md b/shield-swap-sdk/examples/first-swap/README.md new file mode 100644 index 0000000..f3bd976 --- /dev/null +++ b/shield-swap-sdk/examples/first-swap/README.md @@ -0,0 +1,93 @@ +# First Shield Swap in Python + +Create an account, request test tokens, trade 1.5 USDCx for ETH, and collect +the purchased ETH. This example uses the SDK's persistent profile and swap +journal. It runs only on testnet and requires a direct USDCx/ETH pool. + +## Run from the SDK checkout + +Use Python 3.10 or later on macOS or Linux (the SDK journal uses `fcntl`). +From the root of a checkout containing this example: + +```bash +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -e ./shield-swap-sdk +python shield-swap-sdk/examples/first-swap/swap.py +``` + +Installation requires `aleo-sdk>=0.5.0` with the testnet bindings. If a +compatible wheel is unavailable for the platform, build this checkout's +`sdk/` package using its build instructions, then install `shield-swap-sdk`. +The script calls `ShieldSwap.from_profile`, `onboard`, `swap_many(count=1)`, +and `collect_all`; the SDK handles authentication, funding, quoting, +record selection, delegated proving, and recovery handles. + +The run can take several minutes. It writes no console logs. Exit code zero +means a positive output was recorded by a confirmed claim in the SDK journal. +Inspect `.shield-first-swap/result.json` in the invoking directory +for the swap and claim transaction IDs and the exact received ETH amount. +The result also retains base units and decimals for machine consumers. +A nonzero exit leaves a sanitized `error.json`; the journal contains further +local diagnostic information. A concurrent run exits nonzero without changing +another run's error file. + +## Run from an installed release + +The wheel also includes the same source and README. After installing a release +containing this example, run it from a private working directory: + +```bash +python -m aleo_shield_swap.examples.first_swap.swap +python -m aleo_shield_swap.examples.first_swap.swap --claim +``` + +Both commands use `.shield-first-swap/` in the current directory. Run recovery +from that same directory. Source-checkout commands use the same state location. + +## Account and recovery + +On the first run, the SDK generates an account and saves it under `.shield-first-swap/` in the invoking directory. To import an account instead, set the optional +`SHIELD_SWAP_PRIVATE_KEY` in the invoking shell before the first run. +The SDK stores imported keys in the profile too. An existing profile always +wins over this variable; use a separate working directory for another account. +No `.env` file is loaded. The example takes no other environment configuration; +SDK API, key-file, and onboarding credential overrides are disabled. + +Keep `.shield-first-swap/` private and retain its profile and journal: they contain the +private key, credentials, and secrets needed to collect purchased tokens. +The script creates owner-only state under a restrictive umask. The directory contains a `.gitignore` excluding all its contents; never force-add it to source control. Hosted scanning shares the view key with the scanner, +which can decrypt account records. Delegated proving shares transaction +authorizations with the prover, without sharing the private key. + +Before submitting, the example saves `submission.json`. Any later normal run +refuses another trade, including after a lost submission response. Recover with: + +```bash +python shield-swap-sdk/examples/first-swap/swap.py --claim +``` + +Recovery reuses the saved profile and journal; it neither requests funding nor +submits another swap. It also reconstructs `result.json` if the process stopped +after a successful claim. Do not delete the submission marker to retry. +An empty journal or missing claim is not proof that submission failed. A crash +between broadcast and journal persistence, an incomplete handle, or a rejected +transaction may require manual SDK/chain inspection. Preserve all state. +The process lock releases automatically when the process exits. + +A funding failure before submission can be retried normally. The balance scan +may lag behind faucet delivery; one unspent record must cover 1.5 USDCx even +when several smaller records add up to enough. Missing pools or route quotes +stop the run; the example never relaxes slippage to force a swap. + +## Offline checks + +```bash +cd shield-swap-sdk/examples/first-swap +python -m unittest -v test_swap +``` + +These checks use no network, installed SDK, account, or funds. They exercise +lost-response protection, recovery after a confirmed claim, absent handles, +existing journals, and testnet enforcement. They do not establish live service +availability; only a completed live run verifies the funding-to-claim journey. diff --git a/shield-swap-sdk/examples/first-swap/swap.py b/shield-swap-sdk/examples/first-swap/swap.py new file mode 100644 index 0000000..42d38df --- /dev/null +++ b/shield-swap-sdk/examples/first-swap/swap.py @@ -0,0 +1,150 @@ +"""Trade 1.5 USDCx for ETH on testnet and retain recovery state.""" +from __future__ import annotations + +import argparse +import fcntl +import json +import logging +import os +from pathlib import Path +import time +from typing import Any + +HOME = Path.cwd() / ".shield-first-swap" +ENDPOINT = "https://edge.provable.com/api" + + +class ExampleError(RuntimeError): + """Describe a known example failure without including SDK response data.""" + + +def format_amount(amount: int, decimals: int) -> str: + """Render integer token units exactly, without floating-point rounding.""" + if decimals < 0 or amount < 0: + raise ExampleError("Invalid output amount or token decimals; inspect the saved journal") + whole, fraction = divmod(amount, 10**decimals) + suffix = str(fraction).zfill(decimals).rstrip("0") if decimals else "" + return f"{whole}.{suffix}" if suffix else str(whole) + + +def error_details(error: Exception) -> dict[str, str]: + """Retain actionable example errors while withholding arbitrary SDK messages.""" + return {"error_type": type(error).__name__, + "action": str(error) if isinstance(error, ExampleError) else + "Preserve .shield-first-swap; inspect journal locally and use --claim after a submission"} + + +def save(path: Path, data: dict[str, Any]) -> None: + """Replace an outcome or submission file after flushing its complete contents.""" + temporary = path.with_suffix(".tmp") + with temporary.open("w") as stream: + json.dump(data, stream, indent=2) + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(path) + + +def finish(dex: Any, home: Path, attempts: int = 40) -> None: + """Collect the journaled swap and rebuild a result after a restart.""" + for attempt in range(attempts): + events = dex.journal.events() + swaps = [event for event in events if event["type"] == "swap"] + claims = [event for event in events if event["type"] == "claim"] + for swap in swaps: + claim = next((c for c in claims if c["swap_id"] == swap["swap_id"]), None) + if claim is not None and claim["amount_out"] > 0: + intent = json.loads((home / "submission.json").read_text()) + save(home / "result.json", { + "network": "testnet", "address": dex.profile.address, + "swap_transaction_id": swap["transaction_id"], + "claim_transaction_id": claim["transaction_id"], + "received": {"symbol": "ETH", + "amount": format_amount(claim["amount_out"], intent["output_decimals"]), + "amount_base_units": str(claim["amount_out"]), + "decimals": intent["output_decimals"]}, + }) + return + if not swaps: + raise ExampleError("Submission outcome unknown; preserve state and inspect the journal before any new trade") + if attempt + 1 < attempts: + dex.collect_all() + time.sleep(15) + raise ExampleError("Claim not confirmed; preserve state and rerun with --claim") + + +def run(dex: Any, home: Path, claim_only: bool = False) -> None: + """Fund and submit one testnet trade, or recover its existing journaled claim.""" + if dex.profile.network != "testnet" or dex.profile.endpoint != ENDPOINT: + raise ExampleError("This example requires its own testnet profile at the default endpoint") + marker = home / "submission.json" + if claim_only: + if not marker.exists(): + raise ExampleError("No example submission exists") + finish(dex, home) + return + if marker.exists(): + raise ExampleError("A swap was already attempted; inspect result.json or use --claim") + if dex.journal.events(): + # Onboarding events are harmless; existing trades belong to another run. + if any(e["type"] in {"swap", "swap_failed", "counters_reserved", "position"} + for e in dex.journal.events()): + raise ExampleError("Existing trading journal requires recovery, not another swap") + onboard = dex.onboard() + if not onboard.funded: + raise ExampleError("Funding is not ready; rerun later with the same state") + tokens = dex.api.get_tokens() + source = next(t for t in tokens if t.symbol == "USDCx") + target = next(t for t in tokens if t.symbol == "ETH") + amount = 15 * 10**source.decimals // 10 + pools = sorted((p for p in dex.api.get_pools() + if {p.token0, p.token1} == {source.id, target.id}), key=lambda p: p.key) + if not pools: + raise ExampleError("No direct USDCx/ETH pool exists; no swap submitted") + for attempt in range(40): + if dex.get_balances().get(source.id, {}).get("private", 0) >= amount: + break + if attempt == 39: + raise ExampleError("Spendable USDCx is not ready; retry later") + time.sleep(15) + # Persist intent before a request that could broadcast, even if its response is lost. + save(marker, {"network": "testnet", "pool_key": pools[0].key, + "token_in_id": source.id, "amount_in": str(amount), + "output_decimals": target.decimals}) + report = dex.swap_many(pool_key=pools[0].key, token_in_id=source.id, + amount_in=amount, count=1, slippage_bps=50) + if report.failures or len(report.handles) != 1: + raise ExampleError("Swap submission did not return one handle; preserve the journal and use --claim") + finish(dex, home) + + +def main() -> int: + """Lock private state and save outcomes without logging account or SDK data.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--claim", action="store_true", help="Recover the existing swap without funding or trading again") + args = parser.parse_args() + os.umask(0o077) + logging.disable(logging.CRITICAL) + HOME.mkdir(mode=0o700, exist_ok=True) + HOME.chmod(0o700) + (HOME / ".gitignore").write_text("*\n") + with (HOME / "run.lock").open("a") as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return 1 + try: + # These SDK overrides are deliberately unsupported by this fixed testnet example. + for name in ("SHIELD_SWAP_PRIVATE_KEY_FILE", "SHIELD_SWAP_API_URL", + "ALEO_E2E_API_KEY", "ALEO_E2E_CONSUMER_ID"): + os.environ.pop(name, None) + from aleo_shield_swap import ShieldSwap + dex = ShieldSwap.from_profile(HOME, network="testnet", endpoint=ENDPOINT) + run(dex, HOME, args.claim) + return 0 + except Exception as error: + save(HOME / "error.json", error_details(error)) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shield-swap-sdk/examples/first-swap/test_swap.py b/shield-swap-sdk/examples/first-swap/test_swap.py new file mode 100644 index 0000000..7bfe2f7 --- /dev/null +++ b/shield-swap-sdk/examples/first-swap/test_swap.py @@ -0,0 +1,88 @@ +"""Offline checks for avoiding another trade after uncertain submission.""" +import json +from pathlib import Path +from types import SimpleNamespace as Obj +import tempfile +import unittest +from unittest.mock import Mock, patch + +import swap + + +class FirstSwapTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) + self.dex = Mock() + self.dex.profile = Obj(network="testnet", endpoint=swap.ENDPOINT, address="test-address") + self.dex.journal.events.return_value = [] + self.dex.onboard.return_value = Obj(funded=True) + self.dex.api.get_tokens.return_value = [Obj(symbol="USDCx", id="in", decimals=6), Obj(symbol="ETH", id="out", decimals=18)] + self.dex.api.get_pools.return_value = [Obj(key="pool", token0="in", token1="out")] + self.dex.get_balances.return_value = {"in": {"private": 2_000_000}} + + def test_amount_formatting_preserves_large_integer_precision(self): + self.assertEqual(swap.format_amount(123456789012345678901234567890, 18), + "123456789012.34567890123456789") + self.assertEqual(swap.format_amount(123, 18), "0.000000000000000123") + self.assertEqual(swap.format_amount(10**18, 18), "1") + self.assertEqual(swap.format_amount(123, 0), "123") + + def test_error_messages_only_expose_known_example_errors(self): + self.assertEqual(swap.error_details(swap.ExampleError("Use --claim"))["action"], "Use --claim") + self.assertNotIn("secret-request-body", str(swap.error_details(ValueError("secret-request-body")))) + + def test_marker_survives_lost_submission_response(self): + self.dex.swap_many.side_effect = TimeoutError() + with self.assertRaises(TimeoutError): + swap.run(self.dex, self.home) + self.assertTrue((self.home / "submission.json").exists()) + with self.assertRaises(RuntimeError): + swap.run(self.dex, self.home) + self.dex.swap_many.assert_called_once_with(pool_key="pool", token_in_id="in", amount_in=1_500_000, count=1, slippage_bps=50) + self.dex.onboard.assert_called_once() + + def test_claim_rebuilds_result_without_new_funding_or_swap(self): + swap.save(self.home / "submission.json", {"output_decimals": 18}) + self.dex.journal.events.return_value = [ + {"type": "swap", "swap_id": "id", "transaction_id": "swap-tx", "blinding_factor": "secret"}, + {"type": "claim", "swap_id": "id", "transaction_id": "claim-tx", "amount_out": 123}, + ] + swap.run(self.dex, self.home, claim_only=True) + result = (self.home / "result.json").read_text() + self.assertNotIn("secret", result) + self.assertEqual(json.loads(result)["claim_transaction_id"], "claim-tx") + self.dex.onboard.assert_not_called() + self.dex.swap_many.assert_not_called() + + def test_missing_handle_never_reports_success(self): + swap.save(self.home / "submission.json", {"output_decimals": 18}) + with self.assertRaises(RuntimeError): + swap.run(self.dex, self.home, claim_only=True) + self.assertFalse((self.home / "result.json").exists()) + + def test_refuses_existing_trading_journal(self): + self.dex.journal.events.return_value = [{"type": "counters_reserved"}] + with self.assertRaises(RuntimeError): + swap.run(self.dex, self.home) + self.dex.onboard.assert_not_called() + + def test_refuses_mainnet(self): + self.dex.profile.network = "mainnet" + with self.assertRaises(RuntimeError): + swap.run(self.dex, self.home) + self.dex.onboard.assert_not_called() + + def test_collects_then_checks_journal_before_success(self): + swap.save(self.home / "submission.json", {"output_decimals": 18}) + request = {"type": "swap", "swap_id": "id", "transaction_id": "swap-tx"} + self.dex.journal.events.side_effect = [[request], [request, {"type": "claim", "swap_id": "id", "transaction_id": "claim-tx", "amount_out": 1}]] + with patch.object(swap.time, "sleep"): + swap.finish(self.dex, self.home, attempts=2) + self.dex.collect_all.assert_called_once() + self.assertTrue((self.home / "result.json").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/shield-swap-sdk/pyproject.toml b/shield-swap-sdk/pyproject.toml index 5fae354..04ca422 100644 --- a/shield-swap-sdk/pyproject.toml +++ b/shield-swap-sdk/pyproject.toml @@ -19,3 +19,8 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["python/aleo_shield_swap"] + +# Ship the runnable example without copying local profiles or test artifacts. +[tool.hatch.build.targets.wheel.force-include] +"examples/first-swap/swap.py" = "aleo_shield_swap/examples/first_swap/swap.py" +"examples/first-swap/README.md" = "aleo_shield_swap/examples/first_swap/README.md" From 1754701f96bdf02d33f7132c4bfdaf5032da9681 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 25 Sep 2026 13:58:39 -0400 Subject: [PATCH 02/13] [Docs] Simplify first-swap example to direct SDK calls --- .github/workflows/first-swap-example.yml | 2 - .gitignore | 3 - shield-swap-sdk/README.md | 15 +- .../examples/first-swap/.gitignore | 1 - shield-swap-sdk/examples/first-swap/README.md | 103 ++++------ shield-swap-sdk/examples/first-swap/swap.py | 177 ++++-------------- .../examples/first-swap/test_swap.py | 88 --------- 7 files changed, 77 insertions(+), 312 deletions(-) delete mode 100644 shield-swap-sdk/examples/first-swap/test_swap.py diff --git a/.github/workflows/first-swap-example.yml b/.github/workflows/first-swap-example.yml index ec778d8..c3097d7 100644 --- a/.github/workflows/first-swap-example.yml +++ b/.github/workflows/first-swap-example.yml @@ -22,8 +22,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.10' - - run: python -m unittest -v test_swap - working-directory: shield-swap-sdk/examples/first-swap - run: python -m pip wheel --no-deps ./shield-swap-sdk -w /tmp/first-swap-wheel - name: Check packaged example and exclude state run: | diff --git a/.gitignore b/.gitignore index 2dd4e86..6db8505 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,3 @@ tmp/ .superpowers/ docs/superpowers/ .worktrees/ - -# Private state for the runnable first-swap example. -.shield-first-swap/ diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index d7f87d9..d6bebd6 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -43,16 +43,11 @@ Requires `aleo-sdk>=0.3` (this repo's SDK; imports as `aleo`) and Python 3.10+. ## First swap example -Create a testnet account, request tokens, swap 1.5 USDCx for ETH, and collect -the output with the [runnable first-swap example](examples/first-swap/README.md). -The wheel includes the same example: - -```bash -python -m aleo_shield_swap.examples.first_swap.swap -``` - -Run from a private working directory; account and recovery state are saved in -`.shield-first-swap/`. Use `--claim` from the same directory to resume collection. +The [first-swap example](./examples/first-swap) creates and funds a testnet +account, swaps USDCx for ETH, and claims the output using SDK calls directly. +It uses the SDK's default profile and journal for storage and recovery. +Releases containing the example support +`python -m aleo_shield_swap.examples.first_swap.swap`. ## Agents diff --git a/shield-swap-sdk/examples/first-swap/.gitignore b/shield-swap-sdk/examples/first-swap/.gitignore index 267bcba..a230a78 100644 --- a/shield-swap-sdk/examples/first-swap/.gitignore +++ b/shield-swap-sdk/examples/first-swap/.gitignore @@ -1,3 +1,2 @@ -.state/ .venv/ __pycache__/ diff --git a/shield-swap-sdk/examples/first-swap/README.md b/shield-swap-sdk/examples/first-swap/README.md index f3bd976..83b2c3d 100644 --- a/shield-swap-sdk/examples/first-swap/README.md +++ b/shield-swap-sdk/examples/first-swap/README.md @@ -1,93 +1,60 @@ # First Shield Swap in Python -Create an account, request test tokens, trade 1.5 USDCx for ETH, and collect -the purchased ETH. This example uses the SDK's persistent profile and swap -journal. It runs only on testnet and requires a direct USDCx/ETH pool. +Create a testnet account, request tokens, trade 1.5 USDCx for ETH, and claim +the output. [swap.py](./swap.py) calls the Python SDK directly and uses its +existing profile and journal. -## Run from the SDK checkout +## Run -Use Python 3.10 or later on macOS or Linux (the SDK journal uses `fcntl`). -From the root of a checkout containing this example: +Use Python 3.10 or later on macOS or Linux. From a Python SDK checkout: ```bash -python3 -m venv .venv -. .venv/bin/activate -python -m pip install -e ./shield-swap-sdk +python -m pip install ./shield-swap-sdk python shield-swap-sdk/examples/first-swap/swap.py ``` -Installation requires `aleo-sdk>=0.5.0` with the testnet bindings. If a -compatible wheel is unavailable for the platform, build this checkout's -`sdk/` package using its build instructions, then install `shield-swap-sdk`. -The script calls `ShieldSwap.from_profile`, `onboard`, `swap_many(count=1)`, -and `collect_all`; the SDK handles authentication, funding, quoting, -record selection, delegated proving, and recovery handles. +Installation requires `aleo-sdk>=0.5.0` with testnet bindings. If no compatible +wheel is available, follow the `sdk/` package's build instructions first. -The run can take several minutes. It writes no console logs. Exit code zero -means a positive output was recorded by a confirmed claim in the SDK journal. -Inspect `.shield-first-swap/result.json` in the invoking directory -for the swap and claim transaction IDs and the exact received ETH amount. -The result also retains base units and decimals for machine consumers. -A nonzero exit leaves a sanitized `error.json`; the journal contains further -local diagnostic information. A concurrent run exits nonzero without changing -another run's error file. - -## Run from an installed release - -The wheel also includes the same source and README. After installing a release -containing this example, run it from a private working directory: +Releases containing the example also support: ```bash python -m aleo_shield_swap.examples.first_swap.swap -python -m aleo_shield_swap.examples.first_swap.swap --claim ``` -Both commands use `.shield-first-swap/` in the current directory. Run recovery -from that same directory. Source-checkout commands use the same state location. - -## Account and recovery +## Account and funding -On the first run, the SDK generates an account and saves it under `.shield-first-swap/` in the invoking directory. To import an account instead, set the optional -`SHIELD_SWAP_PRIVATE_KEY` in the invoking shell before the first run. -The SDK stores imported keys in the profile too. An existing profile always -wins over this variable; use a separate working directory for another account. -No `.env` file is loaded. The example takes no other environment configuration; -SDK API, key-file, and onboarding credential overrides are disabled. +`ShieldSwap.from_profile(network="testnet")` creates or loads the SDK profile +at its default location, `~/.shield-swap/`. The SDK retains the account and +swap journal there. The example does not create a separate storage layout. -Keep `.shield-first-swap/` private and retain its profile and journal: they contain the -private key, credentials, and secrets needed to collect purchased tokens. -The script creates owner-only state under a restrictive umask. The directory contains a `.gitignore` excluding all its contents; never force-add it to source control. Hosted scanning shares the view key with the scanner, -which can decrypt account records. Delegated proving shares transaction -authorizations with the prover, without sharing the private key. +To import an existing account into a new profile, set `SHIELD_SWAP_PRIVATE_KEY` +before the first run. An existing profile keeps its saved account and network; +the example stops if that network is mainnet. Keep the profile private and +retain it for recovery. -Before submitting, the example saves `submission.json`. Any later normal run -refuses another trade, including after a lost submission response. Recover with: +`dex.onboard()` handles authentication and testnet funding. The example finds +a direct USDCx/ETH pool and calls `swap_many(count=1)`, which quotes the trade, +selects a token record, and records the submitted handle in the SDK journal. +One unspent record must cover 1.5 USDCx. -```bash -python shield-swap-sdk/examples/first-swap/swap.py --claim -``` +## Completion and recovery -Recovery reuses the saved profile and journal; it neither requests funding nor -submits another swap. It also reconstructs `result.json` if the process stopped -after a successful claim. Do not delete the submission marker to retry. -An empty journal or missing claim is not proof that submission failed. A crash -between broadcast and journal persistence, an incomplete handle, or a rejected -transaction may require manual SDK/chain inspection. Preserve all state. -The process lock releases automatically when the process exits. +A successful run ends after `collect_all()` reports the swap's claim. +`claim["transaction_id"]` identifies the claim and `claim["amount_out"]` +contains the received ETH in base units. The example writes no console logs +or additional result files. -A funding failure before submission can be retried normally. The balance scan -may lag behind faucet delivery; one unspent record must cover 1.5 USDCx even -when several smaller records add up to enough. Missing pools or route quotes -stop the run; the example never relaxes slippage to force a swap. +Each run submits a new trade. To recover an interrupted run, load the same +profile and collect pending outputs instead of rerunning the swap: -## Offline checks +```python +from aleo_shield_swap import ShieldSwap -```bash -cd shield-swap-sdk/examples/first-swap -python -m unittest -v test_swap +dex = ShieldSwap.from_profile() +claims = dex.collect_all() ``` -These checks use no network, installed SDK, account, or funds. They exercise -lost-response protection, recovery after a confirmed claim, absent handles, -existing journals, and testnet enforcement. They do not establish live service -availability; only a completed live run verifies the funding-to-claim journey. +`claims.still_pending` lists swaps whose outputs remain pending. Check the +journal and transaction status before submitting another trade. `collect_all` +also collects owed fees from journaled liquidity positions. diff --git a/shield-swap-sdk/examples/first-swap/swap.py b/shield-swap-sdk/examples/first-swap/swap.py index 42d38df..151df2a 100644 --- a/shield-swap-sdk/examples/first-swap/swap.py +++ b/shield-swap-sdk/examples/first-swap/swap.py @@ -1,150 +1,47 @@ -"""Trade 1.5 USDCx for ETH on testnet and retain recovery state.""" -from __future__ import annotations - -import argparse -import fcntl -import json -import logging -import os -from pathlib import Path +"""Create and fund a testnet account, swap 1.5 USDCx for ETH, and claim the output.""" import time -from typing import Any - -HOME = Path.cwd() / ".shield-first-swap" -ENDPOINT = "https://edge.provable.com/api" - - -class ExampleError(RuntimeError): - """Describe a known example failure without including SDK response data.""" - - -def format_amount(amount: int, decimals: int) -> str: - """Render integer token units exactly, without floating-point rounding.""" - if decimals < 0 or amount < 0: - raise ExampleError("Invalid output amount or token decimals; inspect the saved journal") - whole, fraction = divmod(amount, 10**decimals) - suffix = str(fraction).zfill(decimals).rstrip("0") if decimals else "" - return f"{whole}.{suffix}" if suffix else str(whole) - - -def error_details(error: Exception) -> dict[str, str]: - """Retain actionable example errors while withholding arbitrary SDK messages.""" - return {"error_type": type(error).__name__, - "action": str(error) if isinstance(error, ExampleError) else - "Preserve .shield-first-swap; inspect journal locally and use --claim after a submission"} +from aleo_shield_swap import ShieldSwap -def save(path: Path, data: dict[str, Any]) -> None: - """Replace an outcome or submission file after flushing its complete contents.""" - temporary = path.with_suffix(".tmp") - with temporary.open("w") as stream: - json.dump(data, stream, indent=2) - stream.flush() - os.fsync(stream.fileno()) - temporary.replace(path) +if __name__ == "__main__": + # Load the SDK profile or create one. It retains the account and swap journal. + dex = ShieldSwap.from_profile(network="testnet") + if dex.profile.network != "testnet": + raise RuntimeError("This example requires a testnet profile") -def finish(dex: Any, home: Path, attempts: int = 40) -> None: - """Collect the journaled swap and rebuild a result after a restart.""" - for attempt in range(attempts): - events = dex.journal.events() - swaps = [event for event in events if event["type"] == "swap"] - claims = [event for event in events if event["type"] == "claim"] - for swap in swaps: - claim = next((c for c in claims if c["swap_id"] == swap["swap_id"]), None) - if claim is not None and claim["amount_out"] > 0: - intent = json.loads((home / "submission.json").read_text()) - save(home / "result.json", { - "network": "testnet", "address": dex.profile.address, - "swap_transaction_id": swap["transaction_id"], - "claim_transaction_id": claim["transaction_id"], - "received": {"symbol": "ETH", - "amount": format_amount(claim["amount_out"], intent["output_decimals"]), - "amount_base_units": str(claim["amount_out"]), - "decimals": intent["output_decimals"]}, - }) - return - if not swaps: - raise ExampleError("Submission outcome unknown; preserve state and inspect the journal before any new trade") - if attempt + 1 < attempts: - dex.collect_all() - time.sleep(15) - raise ExampleError("Claim not confirmed; preserve state and rerun with --claim") - + # Authenticate and fund the account through the SDK's onboarding flow. + funding = dex.onboard() + if not funding.funded: + raise RuntimeError("Funding is not ready; wait before continuing") -def run(dex: Any, home: Path, claim_only: bool = False) -> None: - """Fund and submit one testnet trade, or recover its existing journaled claim.""" - if dex.profile.network != "testnet" or dex.profile.endpoint != ENDPOINT: - raise ExampleError("This example requires its own testnet profile at the default endpoint") - marker = home / "submission.json" - if claim_only: - if not marker.exists(): - raise ExampleError("No example submission exists") - finish(dex, home) - return - if marker.exists(): - raise ExampleError("A swap was already attempted; inspect result.json or use --claim") - if dex.journal.events(): - # Onboarding events are harmless; existing trades belong to another run. - if any(e["type"] in {"swap", "swap_failed", "counters_reserved", "position"} - for e in dex.journal.events()): - raise ExampleError("Existing trading journal requires recovery, not another swap") - onboard = dex.onboard() - if not onboard.funded: - raise ExampleError("Funding is not ready; rerun later with the same state") + # Find a direct USDCx/ETH pool and convert 1.5 USDCx to base units. tokens = dex.api.get_tokens() - source = next(t for t in tokens if t.symbol == "USDCx") - target = next(t for t in tokens if t.symbol == "ETH") - amount = 15 * 10**source.decimals // 10 - pools = sorted((p for p in dex.api.get_pools() - if {p.token0, p.token1} == {source.id, target.id}), key=lambda p: p.key) - if not pools: - raise ExampleError("No direct USDCx/ETH pool exists; no swap submitted") + source = next(token for token in tokens if token.symbol == "USDCx") + target = next(token for token in tokens if token.symbol == "ETH") + pool = next(pool for pool in dex.api.get_pools() + if {pool.token0, pool.token1} == {source.id, target.id}) + + # Submit one swap with a 0.5% slippage limit. The SDK journals its handle. + swaps = dex.swap_many( + pool_key=pool.key, + token_in_id=source.id, + amount_in=15 * 10**source.decimals // 10, + count=1, + slippage_bps=50, + ) + if swaps.failures or len(swaps.handles) != 1: + raise RuntimeError("Swap submission needs inspection; use the SDK journal before retrying") + + # Collect the output after confirmation, without submitting another swap. for attempt in range(40): - if dex.get_balances().get(source.id, {}).get("private", 0) >= amount: + claims = dex.collect_all() + claim = next((item for item in claims.claimed + if item["swap_id"] == swaps.handles[0].swap_id), None) + if claim is not None: + if claim["amount_out"] <= 0: + raise RuntimeError("The claim returned no ETH") break - if attempt == 39: - raise ExampleError("Spendable USDCx is not ready; retry later") time.sleep(15) - # Persist intent before a request that could broadcast, even if its response is lost. - save(marker, {"network": "testnet", "pool_key": pools[0].key, - "token_in_id": source.id, "amount_in": str(amount), - "output_decimals": target.decimals}) - report = dex.swap_many(pool_key=pools[0].key, token_in_id=source.id, - amount_in=amount, count=1, slippage_bps=50) - if report.failures or len(report.handles) != 1: - raise ExampleError("Swap submission did not return one handle; preserve the journal and use --claim") - finish(dex, home) - - -def main() -> int: - """Lock private state and save outcomes without logging account or SDK data.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--claim", action="store_true", help="Recover the existing swap without funding or trading again") - args = parser.parse_args() - os.umask(0o077) - logging.disable(logging.CRITICAL) - HOME.mkdir(mode=0o700, exist_ok=True) - HOME.chmod(0o700) - (HOME / ".gitignore").write_text("*\n") - with (HOME / "run.lock").open("a") as lock: - try: - fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - return 1 - try: - # These SDK overrides are deliberately unsupported by this fixed testnet example. - for name in ("SHIELD_SWAP_PRIVATE_KEY_FILE", "SHIELD_SWAP_API_URL", - "ALEO_E2E_API_KEY", "ALEO_E2E_CONSUMER_ID"): - os.environ.pop(name, None) - from aleo_shield_swap import ShieldSwap - dex = ShieldSwap.from_profile(HOME, network="testnet", endpoint=ENDPOINT) - run(dex, HOME, args.claim) - return 0 - except Exception as error: - save(HOME / "error.json", error_details(error)) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) + else: + raise RuntimeError("Claim is still pending; resume with dex.collect_all()") diff --git a/shield-swap-sdk/examples/first-swap/test_swap.py b/shield-swap-sdk/examples/first-swap/test_swap.py deleted file mode 100644 index 7bfe2f7..0000000 --- a/shield-swap-sdk/examples/first-swap/test_swap.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Offline checks for avoiding another trade after uncertain submission.""" -import json -from pathlib import Path -from types import SimpleNamespace as Obj -import tempfile -import unittest -from unittest.mock import Mock, patch - -import swap - - -class FirstSwapTests(unittest.TestCase): - def setUp(self): - self.temp = tempfile.TemporaryDirectory() - self.addCleanup(self.temp.cleanup) - self.home = Path(self.temp.name) - self.dex = Mock() - self.dex.profile = Obj(network="testnet", endpoint=swap.ENDPOINT, address="test-address") - self.dex.journal.events.return_value = [] - self.dex.onboard.return_value = Obj(funded=True) - self.dex.api.get_tokens.return_value = [Obj(symbol="USDCx", id="in", decimals=6), Obj(symbol="ETH", id="out", decimals=18)] - self.dex.api.get_pools.return_value = [Obj(key="pool", token0="in", token1="out")] - self.dex.get_balances.return_value = {"in": {"private": 2_000_000}} - - def test_amount_formatting_preserves_large_integer_precision(self): - self.assertEqual(swap.format_amount(123456789012345678901234567890, 18), - "123456789012.34567890123456789") - self.assertEqual(swap.format_amount(123, 18), "0.000000000000000123") - self.assertEqual(swap.format_amount(10**18, 18), "1") - self.assertEqual(swap.format_amount(123, 0), "123") - - def test_error_messages_only_expose_known_example_errors(self): - self.assertEqual(swap.error_details(swap.ExampleError("Use --claim"))["action"], "Use --claim") - self.assertNotIn("secret-request-body", str(swap.error_details(ValueError("secret-request-body")))) - - def test_marker_survives_lost_submission_response(self): - self.dex.swap_many.side_effect = TimeoutError() - with self.assertRaises(TimeoutError): - swap.run(self.dex, self.home) - self.assertTrue((self.home / "submission.json").exists()) - with self.assertRaises(RuntimeError): - swap.run(self.dex, self.home) - self.dex.swap_many.assert_called_once_with(pool_key="pool", token_in_id="in", amount_in=1_500_000, count=1, slippage_bps=50) - self.dex.onboard.assert_called_once() - - def test_claim_rebuilds_result_without_new_funding_or_swap(self): - swap.save(self.home / "submission.json", {"output_decimals": 18}) - self.dex.journal.events.return_value = [ - {"type": "swap", "swap_id": "id", "transaction_id": "swap-tx", "blinding_factor": "secret"}, - {"type": "claim", "swap_id": "id", "transaction_id": "claim-tx", "amount_out": 123}, - ] - swap.run(self.dex, self.home, claim_only=True) - result = (self.home / "result.json").read_text() - self.assertNotIn("secret", result) - self.assertEqual(json.loads(result)["claim_transaction_id"], "claim-tx") - self.dex.onboard.assert_not_called() - self.dex.swap_many.assert_not_called() - - def test_missing_handle_never_reports_success(self): - swap.save(self.home / "submission.json", {"output_decimals": 18}) - with self.assertRaises(RuntimeError): - swap.run(self.dex, self.home, claim_only=True) - self.assertFalse((self.home / "result.json").exists()) - - def test_refuses_existing_trading_journal(self): - self.dex.journal.events.return_value = [{"type": "counters_reserved"}] - with self.assertRaises(RuntimeError): - swap.run(self.dex, self.home) - self.dex.onboard.assert_not_called() - - def test_refuses_mainnet(self): - self.dex.profile.network = "mainnet" - with self.assertRaises(RuntimeError): - swap.run(self.dex, self.home) - self.dex.onboard.assert_not_called() - - def test_collects_then_checks_journal_before_success(self): - swap.save(self.home / "submission.json", {"output_decimals": 18}) - request = {"type": "swap", "swap_id": "id", "transaction_id": "swap-tx"} - self.dex.journal.events.side_effect = [[request], [request, {"type": "claim", "swap_id": "id", "transaction_id": "claim-tx", "amount_out": 1}]] - with patch.object(swap.time, "sleep"): - swap.finish(self.dex, self.home, attempts=2) - self.dex.collect_all.assert_called_once() - self.assertTrue((self.home / "result.json").exists()) - - -if __name__ == "__main__": - unittest.main() From f64612f83f1d4b77a1ea5ad22c6794f0e18f169e Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 25 Sep 2026 14:10:07 -0400 Subject: [PATCH 03/13] [Docs] Show the Python airdrop flow explicitly --- shield-swap-sdk/examples/first-swap/README.md | 10 +++++- shield-swap-sdk/examples/first-swap/swap.py | 32 ++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/shield-swap-sdk/examples/first-swap/README.md b/shield-swap-sdk/examples/first-swap/README.md index 83b2c3d..2f82708 100644 --- a/shield-swap-sdk/examples/first-swap/README.md +++ b/shield-swap-sdk/examples/first-swap/README.md @@ -33,7 +33,15 @@ before the first run. An existing profile keeps its saved account and network; the example stops if that network is mainnet. Keep the profile private and retain it for recovery. -`dex.onboard()` handles authentication and testnet funding. The example finds +`dex.api.authenticate()` signs the API challenge with the profile's account. +`request_airdrop()` starts the testnet faucet job; `get_airdrop_job()` polls it +until completion. `funding.results` contains each token's outcome and transaction +ID. Job completion does not guarantee every token transfer succeeded. + +The faucet allows one request per address per 15 minutes. A rate-limit error +stops the example; inspect the existing funding before requesting again. +The example waits for the scanner to report at least 1.5 USDCx before trading. +It finds a direct USDCx/ETH pool and calls `swap_many(count=1)`, which quotes the trade, selects a token record, and records the submitted handle in the SDK journal. One unspent record must cover 1.5 USDCx. diff --git a/shield-swap-sdk/examples/first-swap/swap.py b/shield-swap-sdk/examples/first-swap/swap.py index 151df2a..0e695ea 100644 --- a/shield-swap-sdk/examples/first-swap/swap.py +++ b/shield-swap-sdk/examples/first-swap/swap.py @@ -1,6 +1,8 @@ """Create and fund a testnet account, swap 1.5 USDCx for ETH, and claim the output.""" import time +from aleo import testnet + from aleo_shield_swap import ShieldSwap @@ -10,10 +12,19 @@ if dex.profile.network != "testnet": raise RuntimeError("This example requires a testnet profile") - # Authenticate and fund the account through the SDK's onboarding flow. - funding = dex.onboard() - if not funding.funded: - raise RuntimeError("Funding is not ready; wait before continuing") + # Sign the API challenge with the account saved in the SDK profile. + private_key = testnet.PrivateKey.from_string(dex.profile.private_key) + dex.api.authenticate(dex.profile.address, lambda message: str(private_key.sign(message.encode()))) + + # Request testnet tokens, then wait for the faucet job to finish. + airdrop = dex.api.request_airdrop(dex.profile.address) + for attempt in range(120): + funding = dex.api.get_airdrop_job(airdrop.job_id) + if funding.status == "complete": + break + time.sleep(5) + else: + raise RuntimeError(f"Airdrop is still pending; inspect job {airdrop.job_id}") # Find a direct USDCx/ETH pool and convert 1.5 USDCx to base units. tokens = dex.api.get_tokens() @@ -22,11 +33,22 @@ pool = next(pool for pool in dex.api.get_pools() if {pool.token0, pool.token1} == {source.id, target.id}) + # Wait for the scanner to report enough USDCx for the swap. + amount_in = 15 * 10**source.decimals // 10 + token_program = source.underlying_program or source.amm_token_program + for attempt in range(40): + balances = dex.get_private_balances([token_program]) + if balances.get(token_program, 0) >= amount_in: + break + time.sleep(15) + else: + raise RuntimeError("USDCx is not available; inspect funding.results and the account balance") + # Submit one swap with a 0.5% slippage limit. The SDK journals its handle. swaps = dex.swap_many( pool_key=pool.key, token_in_id=source.id, - amount_in=15 * 10**source.decimals // 10, + amount_in=amount_in, count=1, slippage_bps=50, ) From f2983478560595b29bbd21ef4ac7766a107eabe6 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 25 Sep 2026 14:11:35 -0400 Subject: [PATCH 04/13] [Fix] Use a single swap and claim in the Python example --- shield-swap-sdk/examples/first-swap/README.md | 13 ++++--- shield-swap-sdk/examples/first-swap/swap.py | 38 ++++++++++--------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/shield-swap-sdk/examples/first-swap/README.md b/shield-swap-sdk/examples/first-swap/README.md index 2f82708..a4ace7e 100644 --- a/shield-swap-sdk/examples/first-swap/README.md +++ b/shield-swap-sdk/examples/first-swap/README.md @@ -41,16 +41,17 @@ ID. Job completion does not guarantee every token transfer succeeded. The faucet allows one request per address per 15 minutes. A rate-limit error stops the example; inspect the existing funding before requesting again. The example waits for the scanner to report at least 1.5 USDCx before trading. -It finds -a direct USDCx/ETH pool and calls `swap_many(count=1)`, which quotes the trade, -selects a token record, and records the submitted handle in the SDK journal. +It quotes a direct USDCx/ETH pool with `get_route()`, then calls +`swap(...).delegate(wait=True)` with that quote and a 0.5% slippage limit. +The SDK selects a token record and saves the swap handle in its journal. One unspent record must cover 1.5 USDCx. ## Completion and recovery -A successful run ends after `collect_all()` reports the swap's claim. -`claim["transaction_id"]` identifies the claim and `claim["amount_out"]` -contains the received ETH in base units. The example writes no console logs +After the swap confirms, `claim_swap_output(handle).delegate(wait=True)` +submits one claim and waits for confirmation. The example records the claim +in the SDK journal. `claim.transaction_id` identifies the claim and +`claim.amount_out` contains the received ETH in base units. The example writes no console logs or additional result files. Each run submits a new trade. To recover an interrupted run, load the same diff --git a/shield-swap-sdk/examples/first-swap/swap.py b/shield-swap-sdk/examples/first-swap/swap.py index 0e695ea..5325a74 100644 --- a/shield-swap-sdk/examples/first-swap/swap.py +++ b/shield-swap-sdk/examples/first-swap/swap.py @@ -1,5 +1,6 @@ """Create and fund a testnet account, swap 1.5 USDCx for ETH, and claim the output.""" import time +from decimal import Decimal from aleo import testnet @@ -44,26 +45,27 @@ else: raise RuntimeError("USDCx is not available; inspect funding.results and the account balance") - # Submit one swap with a 0.5% slippage limit. The SDK journals its handle. - swaps = dex.swap_many( + # Quote the selected pool and convert the expected ETH output to base units. + quote = dex.api.get_route( + token_in=source.id, token_out=target.id, amount_in="1.5", pool_key=pool.key, + ) + if not quote.estimated_amount_out: + raise RuntimeError("The selected pool returned no quote") + expected_out = int(Decimal(quote.estimated_amount_out) * 10**target.decimals) + if expected_out <= 0: + raise RuntimeError("The quote returned no ETH") + + # Submit one swap and wait for confirmation. The SDK journals its handle. + handle = dex.swap( pool_key=pool.key, token_in_id=source.id, amount_in=amount_in, - count=1, + expected_out=expected_out, slippage_bps=50, - ) - if swaps.failures or len(swaps.handles) != 1: - raise RuntimeError("Swap submission needs inspection; use the SDK journal before retrying") + ).delegate(wait=True) - # Collect the output after confirmation, without submitting another swap. - for attempt in range(40): - claims = dex.collect_all() - claim = next((item for item in claims.claimed - if item["swap_id"] == swaps.handles[0].swap_id), None) - if claim is not None: - if claim["amount_out"] <= 0: - raise RuntimeError("The claim returned no ETH") - break - time.sleep(15) - else: - raise RuntimeError("Claim is still pending; resume with dex.collect_all()") + # Claim the confirmed swap's output once, then update the SDK journal. + claim = dex.claim_swap_output(handle).delegate(wait=True) + dex.journal.record_claim(handle.swap_id, claim.transaction_id, claim.amount_out) + if claim.amount_out <= 0: + raise RuntimeError("The claim returned no ETH") From abe9ea500688df48105c631bd5bc62eef7bea4df Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 25 Sep 2026 14:12:49 -0400 Subject: [PATCH 05/13] [Docs] Show swaps without a profile or journal --- shield-swap-sdk/README.md | 2 +- shield-swap-sdk/examples/first-swap/README.md | 53 +++++++++---------- shield-swap-sdk/examples/first-swap/swap.py | 28 +++++----- 3 files changed, 41 insertions(+), 42 deletions(-) diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index d6bebd6..6cd4436 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -45,7 +45,7 @@ Requires `aleo-sdk>=0.3` (this repo's SDK; imports as `aleo`) and Python 3.10+. The [first-swap example](./examples/first-swap) creates and funds a testnet account, swaps USDCx for ETH, and claims the output using SDK calls directly. -It uses the SDK's default profile and journal for storage and recovery. +It uses an in-memory account and swap handle; no profile or journal is required. Releases containing the example support `python -m aleo_shield_swap.examples.first_swap.swap`. diff --git a/shield-swap-sdk/examples/first-swap/README.md b/shield-swap-sdk/examples/first-swap/README.md index a4ace7e..682a901 100644 --- a/shield-swap-sdk/examples/first-swap/README.md +++ b/shield-swap-sdk/examples/first-swap/README.md @@ -2,7 +2,7 @@ Create a testnet account, request tokens, trade 1.5 USDCx for ETH, and claim the output. [swap.py](./swap.py) calls the Python SDK directly and uses its -existing profile and journal. +in-memory account and swap handle. ## Run @@ -24,16 +24,11 @@ python -m aleo_shield_swap.examples.first_swap.swap ## Account and funding -`ShieldSwap.from_profile(network="testnet")` creates or loads the SDK profile -at its default location, `~/.shield-swap/`. The SDK retains the account and -swap journal there. The example does not create a separate storage layout. +`SHIELD_SWAP_PRIVATE_KEY` optionally supplies an existing testnet account. +Otherwise the example generates a private key in memory. `ShieldSwap(aleo)` +uses the configured Aleo client without creating a profile or swap journal. -To import an existing account into a new profile, set `SHIELD_SWAP_PRIVATE_KEY` -before the first run. An existing profile keeps its saved account and network; -the example stops if that network is mainnet. Keep the profile private and -retain it for recovery. - -`dex.api.authenticate()` signs the API challenge with the profile's account. +`dex.api.authenticate()` signs the API challenge with the account's key. `request_airdrop()` starts the testnet faucet job; `get_airdrop_job()` polls it until completion. `funding.results` contains each token's outcome and transaction ID. Job completion does not guarantee every token transfer succeeded. @@ -43,27 +38,27 @@ stops the example; inspect the existing funding before requesting again. The example waits for the scanner to report at least 1.5 USDCx before trading. It quotes a direct USDCx/ETH pool with `get_route()`, then calls `swap(...).delegate(wait=True)` with that quote and a 0.5% slippage limit. -The SDK selects a token record and saves the swap handle in its journal. +The SDK selects a token record and returns the handle needed to claim. One unspent record must cover 1.5 USDCx. ## Completion and recovery After the swap confirms, `claim_swap_output(handle).delegate(wait=True)` -submits one claim and waits for confirmation. The example records the claim -in the SDK journal. `claim.transaction_id` identifies the claim and -`claim.amount_out` contains the received ETH in base units. The example writes no console logs -or additional result files. - -Each run submits a new trade. To recover an interrupted run, load the same -profile and collect pending outputs instead of rerunning the swap: - -```python -from aleo_shield_swap import ShieldSwap - -dex = ShieldSwap.from_profile() -claims = dex.collect_all() -``` - -`claims.still_pending` lists swaps whose outputs remain pending. Check the -journal and transaction status before submitting another trade. `collect_all` -also collects owed fees from journaled liquidity positions. +submits one claim and waits for confirmation. `claim.transaction_id` identifies +the claim and `claim.amount_out` contains the received ETH in base units. +The example writes no console logs or local account files. + +The private key and swap handle remain in memory. **Retain both before ending +an interrupted session.** The handle contains the blinding information needed +to claim; `handle.to_json()` serializes it. Neither value is saved automatically. +A newly generated key is lost when the process exits unless retained separately. + +Each run submits a new trade. To resume a pending claim, recreate the client +with the same private key, restore the retained handle with +`SwapHandle.from_json(...)`, and claim after confirming the swap transaction. +Do not rerun the whole script to recover a swap. + +For automatic account storage and swap journaling, use +`ShieldSwap.from_profile(network="testnet")` instead of constructing +`ShieldSwap(aleo)`. That optional path stores the profile under `~/.shield-swap`; +it is not required for `swap()` or `claim_swap_output()`. diff --git a/shield-swap-sdk/examples/first-swap/swap.py b/shield-swap-sdk/examples/first-swap/swap.py index 5325a74..d7ee5ba 100644 --- a/shield-swap-sdk/examples/first-swap/swap.py +++ b/shield-swap-sdk/examples/first-swap/swap.py @@ -1,24 +1,29 @@ """Create and fund a testnet account, swap 1.5 USDCx for ETH, and claim the output.""" +import os import time from decimal import Decimal -from aleo import testnet +from aleo import Aleo, HTTPProvider, testnet from aleo_shield_swap import ShieldSwap if __name__ == "__main__": - # Load the SDK profile or create one. It retains the account and swap journal. - dex = ShieldSwap.from_profile(network="testnet") - if dex.profile.network != "testnet": - raise RuntimeError("This example requires a testnet profile") + # Create a testnet account or use an existing private key. + key = os.environ.get("SHIELD_SWAP_PRIVATE_KEY") + private_key = testnet.PrivateKey.from_string(key) if key else testnet.PrivateKey.random() + aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="testnet")) + account = aleo.account.from_private_key(private_key) + aleo.default_account = account + aleo.records.register(account) + dex = ShieldSwap(aleo) - # Sign the API challenge with the account saved in the SDK profile. - private_key = testnet.PrivateKey.from_string(dex.profile.private_key) - dex.api.authenticate(dex.profile.address, lambda message: str(private_key.sign(message.encode()))) + # Sign the API challenge with the account's private key. + address = str(account.address) + dex.api.authenticate(address, lambda message: str(private_key.sign(message.encode()))) # Request testnet tokens, then wait for the faucet job to finish. - airdrop = dex.api.request_airdrop(dex.profile.address) + airdrop = dex.api.request_airdrop(address) for attempt in range(120): funding = dex.api.get_airdrop_job(airdrop.job_id) if funding.status == "complete": @@ -55,7 +60,7 @@ if expected_out <= 0: raise RuntimeError("The quote returned no ETH") - # Submit one swap and wait for confirmation. The SDK journals its handle. + # Submit one swap and wait for confirmation. Keep the returned handle for the claim. handle = dex.swap( pool_key=pool.key, token_in_id=source.id, @@ -64,8 +69,7 @@ slippage_bps=50, ).delegate(wait=True) - # Claim the confirmed swap's output once, then update the SDK journal. + # Claim the confirmed swap's output once using its returned handle. claim = dex.claim_swap_output(handle).delegate(wait=True) - dex.journal.record_claim(handle.swap_id, claim.transaction_id, claim.amount_out) if claim.amount_out <= 0: raise RuntimeError("The claim returned no ETH") From f84cfc1528cae22348395fc196f0a3c19bc7ff2d Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 25 Sep 2026 14:13:42 -0400 Subject: [PATCH 06/13] [Docs] Make journaling configurable in the swap example --- shield-swap-sdk/README.md | 2 +- shield-swap-sdk/examples/first-swap/README.md | 14 +++++---- shield-swap-sdk/examples/first-swap/swap.py | 30 +++++++++++++------ 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index 6cd4436..bbbad37 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -45,7 +45,7 @@ Requires `aleo-sdk>=0.3` (this repo's SDK; imports as `aleo`) and Python 3.10+. The [first-swap example](./examples/first-swap) creates and funds a testnet account, swaps USDCx for ETH, and claims the output using SDK calls directly. -It uses an in-memory account and swap handle; no profile or journal is required. +It uses an in-memory account and swap handle by default; `ENABLE_JOURNAL` opts into SDK profile storage and journaling. Releases containing the example support `python -m aleo_shield_swap.examples.first_swap.swap`. diff --git a/shield-swap-sdk/examples/first-swap/README.md b/shield-swap-sdk/examples/first-swap/README.md index 682a901..37124d9 100644 --- a/shield-swap-sdk/examples/first-swap/README.md +++ b/shield-swap-sdk/examples/first-swap/README.md @@ -46,7 +46,7 @@ One unspent record must cover 1.5 USDCx. After the swap confirms, `claim_swap_output(handle).delegate(wait=True)` submits one claim and waits for confirmation. `claim.transaction_id` identifies the claim and `claim.amount_out` contains the received ETH in base units. -The example writes no console logs or local account files. +The example writes no console logs. Local account storage is disabled by default. The private key and swap handle remain in memory. **Retain both before ending an interrupted session.** The handle contains the blinding information needed @@ -58,7 +58,11 @@ with the same private key, restore the retained handle with `SwapHandle.from_json(...)`, and claim after confirming the swap transaction. Do not rerun the whole script to recover a swap. -For automatic account storage and swap journaling, use -`ShieldSwap.from_profile(network="testnet")` instead of constructing -`ShieldSwap(aleo)`. That optional path stores the profile under `~/.shield-swap`; -it is not required for `swap()` or `claim_swap_output()`. +Set `ENABLE_JOURNAL = True` in `swap.py` to enable the SDK's saved profile +and swap journal under `~/.shield-swap`. This calls +`ShieldSwap.from_profile(network="testnet")`; the SDK retains the account and +swap handle, and the example records the confirmed claim. An existing profile +keeps its saved account; `SHIELD_SWAP_PRIVATE_KEY` applies when creating a new +profile. A mainnet profile stops the example before funding or trading. + +With `ENABLE_JOURNAL = False` (the default), no profile or journal is created. diff --git a/shield-swap-sdk/examples/first-swap/swap.py b/shield-swap-sdk/examples/first-swap/swap.py index d7ee5ba..55579e9 100644 --- a/shield-swap-sdk/examples/first-swap/swap.py +++ b/shield-swap-sdk/examples/first-swap/swap.py @@ -8,18 +8,28 @@ from aleo_shield_swap import ShieldSwap +ENABLE_JOURNAL = False + + if __name__ == "__main__": - # Create a testnet account or use an existing private key. - key = os.environ.get("SHIELD_SWAP_PRIVATE_KEY") - private_key = testnet.PrivateKey.from_string(key) if key else testnet.PrivateKey.random() - aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="testnet")) - account = aleo.account.from_private_key(private_key) - aleo.default_account = account - aleo.records.register(account) - dex = ShieldSwap(aleo) + # Optionally retain the account and swap journal in the SDK's default profile. + if ENABLE_JOURNAL: + dex = ShieldSwap.from_profile(network="testnet") + if dex.profile.network != "testnet": + raise RuntimeError("This example requires a testnet profile") + private_key = testnet.PrivateKey.from_string(dex.profile.private_key) + else: + # Create an in-memory account or use an existing private key. + key = os.environ.get("SHIELD_SWAP_PRIVATE_KEY") + private_key = testnet.PrivateKey.from_string(key) if key else testnet.PrivateKey.random() + aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="testnet")) + account = aleo.account.from_private_key(private_key) + aleo.default_account = account + aleo.records.register(account) + dex = ShieldSwap(aleo) # Sign the API challenge with the account's private key. - address = str(account.address) + address = str(private_key.address) dex.api.authenticate(address, lambda message: str(private_key.sign(message.encode()))) # Request testnet tokens, then wait for the faucet job to finish. @@ -71,5 +81,7 @@ # Claim the confirmed swap's output once using its returned handle. claim = dex.claim_swap_output(handle).delegate(wait=True) + if dex.journal is not None: + dex.journal.record_claim(handle.swap_id, claim.transaction_id, claim.amount_out) if claim.amount_out <= 0: raise RuntimeError("The claim returned no ETH") From 2334171d06b81630bfa329b37dc1226456f18a13 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 25 Sep 2026 14:23:30 -0400 Subject: [PATCH 07/13] [Fix] Share account and scanner setup across journal settings --- shield-swap-sdk/README.md | 2 +- .../examples/first-swap/.gitignore | 2 ++ shield-swap-sdk/examples/first-swap/README.md | 20 +++++++------ shield-swap-sdk/examples/first-swap/swap.py | 29 +++++++++---------- 4 files changed, 27 insertions(+), 26 deletions(-) diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index bbbad37..559763d 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -45,7 +45,7 @@ Requires `aleo-sdk>=0.3` (this repo's SDK; imports as `aleo`) and Python 3.10+. The [first-swap example](./examples/first-swap) creates and funds a testnet account, swaps USDCx for ETH, and claims the output using SDK calls directly. -It uses an in-memory account and swap handle by default; `ENABLE_JOURNAL` opts into SDK profile storage and journaling. +It uses an in-memory account and swap handle by default; `ENABLE_JOURNAL` optionally attaches the SDK journal. Releases containing the example support `python -m aleo_shield_swap.examples.first_swap.swap`. diff --git a/shield-swap-sdk/examples/first-swap/.gitignore b/shield-swap-sdk/examples/first-swap/.gitignore index a230a78..c24dd6d 100644 --- a/shield-swap-sdk/examples/first-swap/.gitignore +++ b/shield-swap-sdk/examples/first-swap/.gitignore @@ -1,2 +1,4 @@ .venv/ __pycache__/ +testnet-aleo*.jsonl +testnet-aleo*.lock diff --git a/shield-swap-sdk/examples/first-swap/README.md b/shield-swap-sdk/examples/first-swap/README.md index 37124d9..274eb68 100644 --- a/shield-swap-sdk/examples/first-swap/README.md +++ b/shield-swap-sdk/examples/first-swap/README.md @@ -26,7 +26,8 @@ python -m aleo_shield_swap.examples.first_swap.swap `SHIELD_SWAP_PRIVATE_KEY` optionally supplies an existing testnet account. Otherwise the example generates a private key in memory. `ShieldSwap(aleo)` -uses the configured Aleo client without creating a profile or swap journal. +uses the configured Aleo client. Both journal settings use the same account +and register it with the record scanner. `dex.api.authenticate()` signs the API challenge with the account's key. `request_airdrop()` starts the testnet faucet job; `get_airdrop_job()` polls it @@ -48,7 +49,7 @@ submits one claim and waits for confirmation. `claim.transaction_id` identifies the claim and `claim.amount_out` contains the received ETH in base units. The example writes no console logs. Local account storage is disabled by default. -The private key and swap handle remain in memory. **Retain both before ending +With journaling disabled, the private key and swap handle remain in memory. **Retain both before ending an interrupted session.** The handle contains the blinding information needed to claim; `handle.to_json()` serializes it. Neither value is saved automatically. A newly generated key is lost when the process exits unless retained separately. @@ -58,11 +59,12 @@ with the same private key, restore the retained handle with `SwapHandle.from_json(...)`, and claim after confirming the swap transaction. Do not rerun the whole script to recover a swap. -Set `ENABLE_JOURNAL = True` in `swap.py` to enable the SDK's saved profile -and swap journal under `~/.shield-swap`. This calls -`ShieldSwap.from_profile(network="testnet")`; the SDK retains the account and -swap handle, and the example records the confirmed claim. An existing profile -keeps its saved account; `SHIELD_SWAP_PRIVATE_KEY` applies when creating a new -profile. A mainnet profile stops the example before funding or trading. +Set `ENABLE_JOURNAL = True` in `swap.py` to attach the SDK's `Journal` at +`testnet-.jsonl` in the working directory. The SDK retains swap +handles there, and the example records the confirmed claim. Keep the file +private: it contains the blinding information needed to claim. -With `ENABLE_JOURNAL = False` (the default), no profile or journal is created. +The flag only controls journal attachment. It does not change the account, +client, network, or record scanning, and it does not save the private key. +Use the same private key and journal to recover a pending swap. +With `ENABLE_JOURNAL = False` (the default), no journal is created. diff --git a/shield-swap-sdk/examples/first-swap/swap.py b/shield-swap-sdk/examples/first-swap/swap.py index 55579e9..f44654a 100644 --- a/shield-swap-sdk/examples/first-swap/swap.py +++ b/shield-swap-sdk/examples/first-swap/swap.py @@ -5,31 +5,28 @@ from aleo import Aleo, HTTPProvider, testnet -from aleo_shield_swap import ShieldSwap +from aleo_shield_swap import Journal, ShieldSwap ENABLE_JOURNAL = False if __name__ == "__main__": - # Optionally retain the account and swap journal in the SDK's default profile. + # Create an in-memory account or use an existing private key. + key = os.environ.get("SHIELD_SWAP_PRIVATE_KEY") + private_key = testnet.PrivateKey.from_string(key) if key else testnet.PrivateKey.random() + aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="testnet")) + account = aleo.account.from_private_key(private_key) + aleo.default_account = account + aleo.records.register(account) + dex = ShieldSwap(aleo) + address = str(account.address) + + # Optionally retain this account's swap handles in the SDK journal. if ENABLE_JOURNAL: - dex = ShieldSwap.from_profile(network="testnet") - if dex.profile.network != "testnet": - raise RuntimeError("This example requires a testnet profile") - private_key = testnet.PrivateKey.from_string(dex.profile.private_key) - else: - # Create an in-memory account or use an existing private key. - key = os.environ.get("SHIELD_SWAP_PRIVATE_KEY") - private_key = testnet.PrivateKey.from_string(key) if key else testnet.PrivateKey.random() - aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="testnet")) - account = aleo.account.from_private_key(private_key) - aleo.default_account = account - aleo.records.register(account) - dex = ShieldSwap(aleo) + dex.journal = Journal(f"testnet-{address}.jsonl") # Sign the API challenge with the account's private key. - address = str(private_key.address) dex.api.authenticate(address, lambda message: str(private_key.sign(message.encode()))) # Request testnet tokens, then wait for the faucet job to finish. From d9d3094ceb431390a7655278d5266740fa1a09df Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 25 Sep 2026 14:31:13 -0400 Subject: [PATCH 08/13] [Feat] Default the first imported account and confirm airdrops --- sdk/python/aleo/facade/account.py | 8 +- sdk/python/tests/test_facade_account.py | 38 ++++++++ sdk/python/tests/test_facade_call.py | 3 +- shield-swap-sdk/README.md | 2 +- shield-swap-sdk/examples/first-swap/README.md | 19 ++-- shield-swap-sdk/examples/first-swap/swap.py | 14 +-- shield-swap-sdk/pyproject.toml | 2 +- .../python/aleo_shield_swap/__init__.py | 3 +- .../python/aleo_shield_swap/api.py | 86 +++++++++++++++++++ shield-swap-sdk/tests/test_confirm_airdrop.py | 75 ++++++++++++++++ 10 files changed, 226 insertions(+), 24 deletions(-) create mode 100644 shield-swap-sdk/tests/test_confirm_airdrop.py diff --git a/sdk/python/aleo/facade/account.py b/sdk/python/aleo/facade/account.py index bec9407..66a9dba 100644 --- a/sdk/python/aleo/facade/account.py +++ b/sdk/python/aleo/facade/account.py @@ -75,6 +75,9 @@ def create(self) -> Any: def from_private_key(self, private_key: str | Any) -> Any: """Derive an :class:`Account` from an existing private key. + Sets ``aleo.default_account`` when no default is configured. Later + imports leave an existing default unchanged. Does not contact the network. + Parameters ---------- private_key: @@ -96,7 +99,10 @@ def from_private_key(self, private_key: str | Any) -> Any: pk: Any = net.PrivateKey.from_string(private_key) else: pk = private_key - return net.Account.from_private_key(pk) + account = net.Account.from_private_key(pk) + if self._client.default_account is None: + self._client.default_account = account + return account def from_seed(self, seed: str | Any) -> Any: """Derive an :class:`Account` from a seed :class:`Field` element. diff --git a/sdk/python/tests/test_facade_account.py b/sdk/python/tests/test_facade_account.py index 32459e8..1c578b6 100644 --- a/sdk/python/tests/test_facade_account.py +++ b/sdk/python/tests/test_facade_account.py @@ -370,3 +370,41 @@ def test_account_module_repr() -> None: r = repr(a.account) assert "AccountModule" in r assert "mainnet" in r + + +@pytest.mark.parametrize("async_client", [False, True]) +@pytest.mark.parametrize("key_object", [False, True]) +def test_first_private_key_sets_default_account(async_client: bool, key_object: bool) -> None: + """The first imported key supplies the signer when a call omits one.""" + from aleo import AsyncAleo + from aleo.mainnet import PrivateKey + client = AsyncAleo(HTTPProvider(BASE)) if async_client else make_client() + key = PrivateKey.from_string(KAT["private_key"]) if key_object else KAT["private_key"] + account = client.account.from_private_key(key) + assert client.default_account is account + assert client.account.verify(account.address, b"default signer", client.account.sign(b"default signer")) + + +def test_later_private_key_preserves_default_account() -> None: + """Importing another key leaves the first signer selected.""" + client = make_client() + first = client.account.from_private_key(KAT["private_key"]) + client.account.from_private_key(client.account.create().private_key) + assert client.default_account is first + + +def test_private_key_preserves_explicit_default_account() -> None: + """An explicitly selected signer takes precedence over imported keys.""" + client = make_client() + chosen = client.account.create() + client.default_account = chosen + client.account.from_private_key(KAT["private_key"]) + assert client.default_account is chosen + + +def test_invalid_private_key_leaves_default_unset() -> None: + """A failed import does not select a default signer.""" + client = make_client() + with pytest.raises((ValueError, RuntimeError)): + client.account.from_private_key("invalid") + assert client.default_account is None diff --git a/sdk/python/tests/test_facade_call.py b/sdk/python/tests/test_facade_call.py index 0e95af1..abc96b4 100644 --- a/sdk/python/tests/test_facade_call.py +++ b/sdk/python/tests/test_facade_call.py @@ -140,7 +140,8 @@ def test_authorize_uses_default_account() -> None: def test_authorize_errors_without_account() -> None: a = _client() acct = _account(a) - bc = _bound(a, acct) # note: a.default_account is None + a.default_account = None + bc = _bound(a, acct) with pytest.raises(ValueError, match="default_account is not set"): bc.authorize() diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index 559763d..53978c6 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -39,7 +39,7 @@ pip install -e "shield-swap-sdk[async]" # + AsyncShieldSwap (httpx) pip install -e "shield-swap-sdk[mcp]" # + the MCP server ``` -Requires `aleo-sdk>=0.3` (this repo's SDK; imports as `aleo`) and Python 3.10+. +Requires `aleo-sdk>=0.5.1` (this repo's SDK; imports as `aleo`) and Python 3.10+. ## First swap example diff --git a/shield-swap-sdk/examples/first-swap/README.md b/shield-swap-sdk/examples/first-swap/README.md index 274eb68..33dd54c 100644 --- a/shield-swap-sdk/examples/first-swap/README.md +++ b/shield-swap-sdk/examples/first-swap/README.md @@ -13,7 +13,7 @@ python -m pip install ./shield-swap-sdk python shield-swap-sdk/examples/first-swap/swap.py ``` -Installation requires `aleo-sdk>=0.5.0` with testnet bindings. If no compatible +Installation requires `aleo-sdk>=0.5.1` with testnet bindings. If no compatible wheel is available, follow the `sdk/` package's build instructions first. Releases containing the example also support: @@ -27,15 +27,18 @@ python -m aleo_shield_swap.examples.first_swap.swap `SHIELD_SWAP_PRIVATE_KEY` optionally supplies an existing testnet account. Otherwise the example generates a private key in memory. `ShieldSwap(aleo)` uses the configured Aleo client. Both journal settings use the same account -and register it with the record scanner. +and register it with the record scanner. The first `from_private_key()` call +sets the default account; later imports preserve an existing default. `dex.api.authenticate()` signs the API challenge with the account's key. -`request_airdrop()` starts the testnet faucet job; `get_airdrop_job()` polls it -until completion. `funding.results` contains each token's outcome and transaction -ID. Job completion does not guarantee every token transfer succeeded. - -The faucet allows one request per address per 15 minutes. A rate-limit error -stops the example; inspect the existing funding before requesting again. +`confirm_airdrop()` requests tokens and waits for the faucet job to settle. +It returns `funding.status == "settled"` with per-token outcomes in +`funding.job.results`, or `"rate_limited"` with the faucet's explanation in +`funding.message`. A settled job can contain failed token transfers. + +The helper polls every 5 seconds and times out after 10 minutes by default. +`AirdropPendingError.job_id` identifies a timed-out job for further status reads. +A rate-limited account can continue if it already holds enough USDCx. The example waits for the scanner to report at least 1.5 USDCx before trading. It quotes a direct USDCx/ETH pool with `get_route()`, then calls `swap(...).delegate(wait=True)` with that quote and a 0.5% slippage limit. diff --git a/shield-swap-sdk/examples/first-swap/swap.py b/shield-swap-sdk/examples/first-swap/swap.py index f44654a..f2aad54 100644 --- a/shield-swap-sdk/examples/first-swap/swap.py +++ b/shield-swap-sdk/examples/first-swap/swap.py @@ -17,7 +17,6 @@ private_key = testnet.PrivateKey.from_string(key) if key else testnet.PrivateKey.random() aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="testnet")) account = aleo.account.from_private_key(private_key) - aleo.default_account = account aleo.records.register(account) dex = ShieldSwap(aleo) address = str(account.address) @@ -29,15 +28,8 @@ # Sign the API challenge with the account's private key. dex.api.authenticate(address, lambda message: str(private_key.sign(message.encode()))) - # Request testnet tokens, then wait for the faucet job to finish. - airdrop = dex.api.request_airdrop(address) - for attempt in range(120): - funding = dex.api.get_airdrop_job(airdrop.job_id) - if funding.status == "complete": - break - time.sleep(5) - else: - raise RuntimeError(f"Airdrop is still pending; inspect job {airdrop.job_id}") + # Request testnet tokens and wait for the faucet job to settle. + funding = dex.api.confirm_airdrop(address) # Find a direct USDCx/ETH pool and convert 1.5 USDCx to base units. tokens = dex.api.get_tokens() @@ -55,7 +47,7 @@ break time.sleep(15) else: - raise RuntimeError("USDCx is not available; inspect funding.results and the account balance") + raise RuntimeError("USDCx is not available; inspect funding.job and funding.message and the account balance") # Quote the selected pool and convert the expected ETH output to base units. quote = dex.api.get_route( diff --git a/shield-swap-sdk/pyproject.toml b/shield-swap-sdk/pyproject.toml index 04ca422..d8a8b2d 100644 --- a/shield-swap-sdk/pyproject.toml +++ b/shield-swap-sdk/pyproject.toml @@ -4,7 +4,7 @@ version = "0.5.1" description = "Python SDK for the shield swap AMM Dex on Aleo" readme = "README.md" requires-python = ">=3.10" -dependencies = ["aleo-sdk>=0.5.0", "requests>=2"] # 0.5.0: codegen runtime fmt_array/dec_array +dependencies = ["aleo-sdk>=0.5.1", "requests>=2"] # 0.5.1: first imported account becomes the default signer [project.optional-dependencies] async = ["httpx>=0.27"] diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index 33f1af9..58b916b 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/__init__.py +++ b/shield-swap-sdk/python/aleo_shield_swap/__init__.py @@ -17,6 +17,7 @@ from .client import ShieldSwap as ShieldSwap from .async_client import AsyncShieldSwap as AsyncShieldSwap from .api import ApiClient as ApiClient, AsyncApiClient as AsyncApiClient +from .api import ConfirmAirdropResult as ConfirmAirdropResult from .rebalance import ( RebalancePlan as RebalancePlan, RebalanceResult as RebalanceResult, @@ -92,7 +93,7 @@ def agent_guide() -> str: "CredentialsMissingError", "OwnedPosition", "OwnedPositionState", "HopFill", "SwapExecution", "RebalancePlan", "RebalanceResult", - "Profile", "Journal", "REGISTRATION_STAGES", + "ConfirmAirdropResult", "Profile", "Journal", "REGISTRATION_STAGES", "OnboardReport", "StageOutcome", "SessionStatus", "PositionView", "SwapBatchReport", "CollectReport", "blinded_identity_at", "shield_swap_tools", "dispatch_tool", "agent_guide", diff --git a/shield-swap-sdk/python/aleo_shield_swap/api.py b/shield-swap-sdk/python/aleo_shield_swap/api.py index 8a2609e..dc64148 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/api.py +++ b/shield-swap-sdk/python/aleo_shield_swap/api.py @@ -10,6 +10,8 @@ """ from __future__ import annotations +import asyncio +import time import dataclasses import enum import functools @@ -23,12 +25,36 @@ from . import _api_models as models from .errors import ( + AirdropPendingError, AirdropRateLimitedError, DexApiError, NotAuthenticatedError, ) +@dataclass +class ConfirmAirdropResult: + """Faucet outcome with per-token results or the request's rate-limit reason. + + ``status`` is ``settled`` with ``job`` populated, or ``rate_limited`` with + ``message`` populated. A settled job can still contain failed token transfers. + """ + + status: typing.Literal["settled", "rate_limited"] + job: Optional[models.AirdropJob] = None + message: Optional[str] = None + + +def _airdrop_outcome(job: models.AirdropJob, job_id: str, + deadline: float) -> Optional[ConfirmAirdropResult]: + """Return a settled job or raise when a running job has timed out.""" + if job.status != "running": + return ConfirmAirdropResult("settled", job=job) + if time.monotonic() >= deadline: + raise AirdropPendingError(job_id) + return None + + def _check(resp: Any) -> None: """Map DEX API failures to the lifecycle taxonomy; DexApiError otherwise. @@ -448,6 +474,47 @@ def get_airdrop_job(self, job_id: str) -> models.AirdropJob: for r in (data.get("results") or [])] return _build(models.AirdropJob, {**data, "results": results}) + def confirm_airdrop(self, address: str, *, poll_interval: float = 5.0, + timeout: float = 600.0) -> ConfirmAirdropResult: + """Request testnet tokens and wait for the faucet job to settle. + + Calls ``request_airdrop`` once, then polls ``get_airdrop_job``. Returns + ``status="rate_limited"`` if the initial request receives HTTP 429; + no job starts in that case. Other API errors propagate. A settled job + can contain failed transfers; inspect ``result.job.results`` for each + token's outcome. This does not wait for record-scanner indexing. + + Args: + address: Receiving Aleo account address. + poll_interval: Seconds between status reads; defaults to 5. + timeout: Seconds to wait after starting the job; defaults to 600. + + Returns: + The settled job or the faucet's rate-limit explanation. + + Raises: + AirdropPendingError: The job is still running at the timeout; + ``job_id`` identifies the job to resume polling. + DexApiError: The request or status read fails, except an initial 429. + + Example:: + + funding = api.confirm_airdrop(address) + if funding.status == "settled": + results = funding.job.results + """ + try: + started = self.request_airdrop(address) + except AirdropRateLimitedError as error: + return ConfirmAirdropResult("rate_limited", message=str(error)) + deadline = time.monotonic() + timeout + while True: + job = self.get_airdrop_job(started.job_id) + result = _airdrop_outcome(job, started.job_id, deadline) + if result is not None: + return result + time.sleep(poll_interval) + def create_api_token(self, name: str, expires_in_days: "int | None" = None ) -> models.ApiTokenCreatedResponse: @@ -916,6 +983,25 @@ async def get_airdrop_job(self, job_id: str) -> models.AirdropJob: for r in (data.get("results") or [])] return _build(models.AirdropJob, {**data, "results": results}) + async def confirm_airdrop(self, address: str, *, poll_interval: float = 5.0, + timeout: float = 600.0) -> ConfirmAirdropResult: + """Request and await testnet funding; see :meth:`ApiClient.confirm_airdrop`. + + Uses nonblocking waits with the same timeout, rate-limit result, and + per-token outcomes as the synchronous method. + """ + try: + started = await self.request_airdrop(address) + except AirdropRateLimitedError as error: + return ConfirmAirdropResult("rate_limited", message=str(error)) + deadline = time.monotonic() + timeout + while True: + job = await self.get_airdrop_job(started.job_id) + result = _airdrop_outcome(job, started.job_id, deadline) + if result is not None: + return result + await asyncio.sleep(poll_interval) + async def create_api_token(self, name: str, expires_in_days: "int | None" = None ) -> models.ApiTokenCreatedResponse: diff --git a/shield-swap-sdk/tests/test_confirm_airdrop.py b/shield-swap-sdk/tests/test_confirm_airdrop.py new file mode 100644 index 0000000..144f8d5 --- /dev/null +++ b/shield-swap-sdk/tests/test_confirm_airdrop.py @@ -0,0 +1,75 @@ +"""Faucet confirmation behavior shared by sync and async clients.""" +import asyncio +from unittest.mock import AsyncMock, Mock +import pytest +from aleo_shield_swap.api import ApiClient, AsyncApiClient +from aleo_shield_swap._api_models import AirdropJob, AirdropResult, AirdropStartResult +from aleo_shield_swap.errors import AirdropPendingError, AirdropRateLimitedError, DexApiError + + +@pytest.fixture(params=[False, True], ids=["sync", "async"]) +def faucet(request, monkeypatch): + async_mode = request.param + client = object.__new__(AsyncApiClient if async_mode else ApiClient) + mock = AsyncMock if async_mode else Mock + client.request_airdrop = mock(return_value=AirdropStartResult("job-1", "running")) + client.get_airdrop_job = mock() + monkeypatch.setattr("time.sleep", Mock()) + monkeypatch.setattr("asyncio.sleep", AsyncMock()) + def confirm(**kwargs): + result = client.confirm_airdrop("aleo1test", **kwargs) + return asyncio.run(result) if async_mode else result + return client, confirm + + +def test_waits_for_settlement_and_preserves_token_failures(faucet): + client, confirm = faucet + failed = AirdropResult("token.aleo", "0", "failed", "USDCx", error="rejected") + job = AirdropJob([failed], "complete", 1) + client.get_airdrop_job.side_effect = [AirdropJob([], "running", 1), job] + result = confirm(poll_interval=0) + assert result.status == "settled" and result.job is job + assert result.job.results[0].error == "rejected" + client.request_airdrop.assert_called_once_with("aleo1test") + assert client.get_airdrop_job.call_count == 2 + + +def test_rate_limit_does_not_poll(faucet): + client, confirm = faucet + client.request_airdrop.side_effect = AirdropRateLimitedError("wait") + result = confirm() + assert result.status == "rate_limited" and result.message + assert result.job is None + client.get_airdrop_job.assert_not_called() + + +def test_timeout_retains_job_id(faucet): + client, confirm = faucet + client.get_airdrop_job.return_value = AirdropJob([], "running", 1) + with pytest.raises(AirdropPendingError) as caught: + confirm(timeout=0) + assert caught.value.job_id == "job-1" + + +def test_already_settled_job_returns_immediately(faucet): + client, confirm = faucet + client.get_airdrop_job.return_value = AirdropJob([], "complete", 0) + assert confirm(timeout=0).status == "settled" + client.get_airdrop_job.assert_called_once_with("job-1") + + +@pytest.mark.parametrize("stage", ["request_airdrop", "get_airdrop_job"]) +def test_other_api_errors_propagate(faucet, stage): + client, confirm = faucet + error = DexApiError(503, "unavailable") + getattr(client, stage).side_effect = error + with pytest.raises(DexApiError) as caught: + confirm() + assert caught.value is error + + +def test_poll_rate_limit_propagates(faucet): + client, confirm = faucet + client.get_airdrop_job.side_effect = AirdropRateLimitedError("poll limit") + with pytest.raises(AirdropRateLimitedError): + confirm() From f8d134748cdc5d0424b038775bc5ccf5d149d209 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 25 Sep 2026 14:35:24 -0400 Subject: [PATCH 09/13] [Feat] Look up tokens by symbol in the Python SDK --- shield-swap-sdk/examples/first-swap/README.md | 3 +- shield-swap-sdk/examples/first-swap/swap.py | 5 +- .../python/aleo_shield_swap/api.py | 41 ++++++++++++ shield-swap-sdk/tests/test_token_lookup.py | 64 +++++++++++++++++++ 4 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 shield-swap-sdk/tests/test_token_lookup.py diff --git a/shield-swap-sdk/examples/first-swap/README.md b/shield-swap-sdk/examples/first-swap/README.md index 33dd54c..f1b8a36 100644 --- a/shield-swap-sdk/examples/first-swap/README.md +++ b/shield-swap-sdk/examples/first-swap/README.md @@ -40,7 +40,8 @@ The helper polls every 5 seconds and times out after 10 minutes by default. `AirdropPendingError.job_id` identifies a timed-out job for further status reads. A rate-limited account can continue if it already holds enough USDCx. The example waits for the scanner to report at least 1.5 USDCx before trading. -It quotes a direct USDCx/ETH pool with `get_route()`, then calls +It looks up USDCx and ETH with `get_token(symbol)`, quotes a direct pool +with `get_route()`, then calls `swap(...).delegate(wait=True)` with that quote and a 0.5% slippage limit. The SDK selects a token record and returns the handle needed to claim. One unspent record must cover 1.5 USDCx. diff --git a/shield-swap-sdk/examples/first-swap/swap.py b/shield-swap-sdk/examples/first-swap/swap.py index f2aad54..b320a48 100644 --- a/shield-swap-sdk/examples/first-swap/swap.py +++ b/shield-swap-sdk/examples/first-swap/swap.py @@ -32,9 +32,8 @@ funding = dex.api.confirm_airdrop(address) # Find a direct USDCx/ETH pool and convert 1.5 USDCx to base units. - tokens = dex.api.get_tokens() - source = next(token for token in tokens if token.symbol == "USDCx") - target = next(token for token in tokens if token.symbol == "ETH") + source = dex.api.get_token("USDCx") + target = dex.api.get_token("ETH") pool = next(pool for pool in dex.api.get_pools() if {pool.token0, pool.token1} == {source.id, target.id}) diff --git a/shield-swap-sdk/python/aleo_shield_swap/api.py b/shield-swap-sdk/python/aleo_shield_swap/api.py index dc64148..bb5351c 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/api.py +++ b/shield-swap-sdk/python/aleo_shield_swap/api.py @@ -55,6 +55,16 @@ def _airdrop_outcome(job: models.AirdropJob, job_id: str, return None +def _token_by_symbol(tokens: list[models.TokenDoc], symbol: str) -> models.TokenDoc: + """Find one exact symbol match without choosing between duplicate symbols.""" + matches = [token for token in tokens if token.symbol == symbol] + if not matches: + raise ValueError(f"Unknown token symbol: {symbol}") + if len(matches) > 1: + raise ValueError(f"Ambiguous token symbol: {symbol}") + return matches[0] + + def _check(resp: Any) -> None: """Map DEX API failures to the lifecycle taxonomy; DexApiError otherwise. @@ -636,6 +646,29 @@ def get_tokens(self) -> list[models.TokenDoc]: """ return [_build(models.TokenDoc, t) for t in self._get("/tokens")["data"]] + def get_token(self, symbol: str) -> models.TokenDoc: + """Look up one listed token by its exact, case-sensitive symbol. + + Fetches the token registry through ``get_tokens()`` and returns the + matching token, including its ID, decimals, and token programs. + + Args: + symbol: Listed token symbol, such as ``"ETH"`` or ``"USDCx"``. + + Returns: + Metadata for the single matching token. + + Raises: + ValueError: The symbol is unknown or matches more than one token. + DexApiError: Fetching the token registry fails. + + Example:: + + source = api.get_token("USDCx") + target = api.get_token("ETH") + """ + return _token_by_symbol(self.get_tokens(), symbol) + def get_pool(self, pool_key: str) -> models.PoolWithStatsDoc: """One pool with its token metadata, reserves, display orientation, and rolling stats. Network read; 404 for an unknown key.""" @@ -1114,6 +1147,14 @@ async def report_referral_address_batch(self, *, code: str, blinded_addresses: l (await self._post("/referral/address-batches", {"code": code, "blinded_addresses": blinded_addresses}))["data"]) + async def get_token(self, symbol: str) -> models.TokenDoc: + """Look up a token by exact symbol; see :meth:`ApiClient.get_token`. + + Fetches the registry asynchronously. Raises ``ValueError`` for an + unknown or ambiguous symbol and propagates API errors. + """ + return _token_by_symbol(await self.get_tokens(), symbol) + async def get_pool(self, pool_key: str) -> models.PoolWithStatsDoc: """One pool with stats — see :meth:`ApiClient.get_pool`.""" return _build(models.PoolWithStatsDoc, (await self._get(f"/pools/{pool_key}"))["data"]) diff --git a/shield-swap-sdk/tests/test_token_lookup.py b/shield-swap-sdk/tests/test_token_lookup.py new file mode 100644 index 0000000..4d258e3 --- /dev/null +++ b/shield-swap-sdk/tests/test_token_lookup.py @@ -0,0 +1,64 @@ +"""Symbol lookup returns token metadata without choosing ambiguous matches.""" +import asyncio +from unittest.mock import AsyncMock, Mock + +import pytest + +from aleo_shield_swap.api import ApiClient, AsyncApiClient +from aleo_shield_swap._api_models import TokenDoc +from aleo_shield_swap.errors import DexApiError + + +@pytest.fixture(params=[False, True], ids=["sync", "async"]) +def lookup(request): + asynchronous = request.param + client = object.__new__(AsyncApiClient if asynchronous else ApiClient) + client.get_tokens = AsyncMock() if asynchronous else Mock() + + def get(symbol): + result = client.get_token(symbol) + return asyncio.run(result) if asynchronous else result + + return client, get + + +def test_lookup_returns_matching_metadata(lookup): + client, get = lookup + usdc = TokenDoc("usdc.aleo", 6, "1field", "USD Coin", "USDCx") + eth = TokenDoc("eth.aleo", 8, "2field", "Ether", "ETH") + client.get_tokens.return_value = [usdc, eth] + assert get("ETH") is eth + assert get("USDCx") is usdc + + +def test_unknown_symbol_raises_clear_error(lookup): + client, get = lookup + client.get_tokens.return_value = [] + with pytest.raises(ValueError, match="Unknown token symbol: ETH"): + get("ETH") + + +def test_ambiguous_symbol_does_not_choose_first(lookup): + client, get = lookup + client.get_tokens.return_value = [ + TokenDoc("a.aleo", 8, "1field", "Ether", "ETH"), + TokenDoc("b.aleo", 8, "2field", "Other Ether", "ETH"), + ] + with pytest.raises(ValueError, match="Ambiguous token symbol: ETH"): + get("ETH") + + +def test_symbol_matching_is_exact(lookup): + client, get = lookup + client.get_tokens.return_value = [TokenDoc("a.aleo", 8, "1field", "Ether", "ETH")] + with pytest.raises(ValueError, match="Unknown token symbol: eth"): + get("eth") + + +def test_api_failure_propagates(lookup): + client, get = lookup + error = DexApiError(503, "unavailable") + client.get_tokens.side_effect = error + with pytest.raises(DexApiError) as caught: + get("ETH") + assert caught.value is error From e9aa7c89ac5cb8ad00c60506450ff0c673f95f64 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 25 Sep 2026 14:49:56 -0400 Subject: [PATCH 10/13] [Feat] Convert decimal swap amounts inside the SDK --- shield-swap-sdk/AGENTS.md | 53 +++++++--------- shield-swap-sdk/README.md | 9 ++- shield-swap-sdk/examples/first-swap/README.md | 10 ++- shield-swap-sdk/examples/first-swap/swap.py | 25 ++------ .../python/aleo_shield_swap/AGENTS.md | 53 +++++++--------- .../python/aleo_shield_swap/_core.py | 41 ++++++++++++ .../python/aleo_shield_swap/api.py | 30 +++------ .../python/aleo_shield_swap/async_client.py | 19 +++++- .../python/aleo_shield_swap/client.py | 63 ++++++++++--------- shield-swap-sdk/tests/test_amounts.py | 54 ++++++++++++++++ shield-swap-sdk/tests/test_async_client.py | 17 +++++ 11 files changed, 239 insertions(+), 135 deletions(-) create mode 100644 shield-swap-sdk/tests/test_amounts.py diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index c416b3d..e068389 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -405,35 +405,30 @@ exhausting it raises — the fail-fast for a systematically wrong program. ### Chain methods -### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int', slippage_bps: 'int' = 50, expected_out: 'Optional[int]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` - -Request a private swap — phase one of the two-transaction flow. - -Wrapped inputs route via the swap router automatically; fund them -with UNDERLYING records — the deposit happens in-transaction. - -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 method (``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. -**Building is not free with a journal.** The blinded address is a -transition input, so a counter is reserved *here*, not at the terminal -method — discarding the call, or only simulating, still spends it. That -reservation is what makes concurrent swaps safe: it serializes under a -file lock where the probe it replaces could hand two callers the same -counter. The handle is journaled once the broadcast is accepted, so a -crash before the claim keeps the blinding factor. ``track=False`` builds -on the racing probe instead; *identity* supplies your own. - -The default -*deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- -proving latency; a tight deadline aborts at finalize when proving -outlives it. +### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int | str | Decimal', slippage_bps: 'int' = 50, expected_out: 'Optional[int | str | Decimal]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` + +Prepare one private swap; submit with ``transact()`` or ``delegate()``. + +Integer ``amount_in`` and ``expected_out`` values are base units. +Strings and ``Decimal`` values are token units (``"1.5"`` means 1.5 +tokens), converted exactly with registry metadata. Floats, excess +precision, non-finite values, and amounts outside u128 are rejected. +Returned handle amounts remain in base units. + +Quote with ``api.get_route`` and pass ``expected_out``. Without a quote, +the spot estimate ignores fees and price impact. Wrapped inputs use +underlying token records and route through the swap router automatically. +The SDK selects a covering record unless ``token_record`` is supplied. + +Preparing a call reserves a blinding counter when a journal is attached, +even if the call is discarded or only simulated. The journal retains the +handle after submission. Without a journal, retain the returned handle +for claiming and avoid concurrent swaps: chain probing cannot reserve +counters atomically. ``identity`` supplies an explicit identity; +``track=False`` bypasses journal reservation. + +``deadline_offset_blocks`` defaults to 10,000 (~8 hours at 3s/block) +to allow delegated proving; an expired deadline rejects at finalize. ### `claim_swap_output(self, handle: 'SwapHandle', *, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[ClaimResult]'` diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index 53978c6..50340f5 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -21,8 +21,12 @@ handle = dex.swap(pool_key=pools[0].key, out = dex.claim_swap_output(handle).delegate() # broadcasts; spends funds ``` -Targets the deployed `shield_swap.aleo` stack on testnet. Amounts are raw +Targets the deployed `shield_swap.aleo` stack on testnet. Integer amounts are raw native token units (the AMM does no decimal scaling); prices are Q128.128. +`swap()` also accepts strings or `Decimal` values for `amount_in` and +`expected_out` in token units, converting them internally with registry metadata. +For example, `amount_in="1.5"` means 1.5 input tokens; `amount_in=1500000` +means 1,500,000 base units. Returned amounts remain in base units. Wrapped assets (ALEO/USAD/USDCx) **route automatically** through the swap/LP routers — fund them with *underlying* records (`credits.aleo` / stablecoin); you never handle wrapper records. `mint` stores an immutable `withdrawal` @@ -127,7 +131,8 @@ Quote before you swap: pass `expected_out` from `dex.api.get_route(...)` — without it a spot estimate is used, which ignores fees and price impact. On busy pools leave slippage headroom: prices move between quote and finalize, and a too-tight `amount_out_min` rejects safely at finalize. -Amounts are `u128` base units of the token; fees are microcredits. +On-chain amounts are `u128` base units; fees are microcredits. `swap()` converts +string and `Decimal` token amounts internally; integer inputs remain base units. Two liquidity behaviors worth knowing (both verified live): `mint` walks the pool's on-chain tick list to compute its insertion hints diff --git a/shield-swap-sdk/examples/first-swap/README.md b/shield-swap-sdk/examples/first-swap/README.md index f1b8a36..68c3d94 100644 --- a/shield-swap-sdk/examples/first-swap/README.md +++ b/shield-swap-sdk/examples/first-swap/README.md @@ -39,12 +39,18 @@ It returns `funding.status == "settled"` with per-token outcomes in The helper polls every 5 seconds and times out after 10 minutes by default. `AirdropPendingError.job_id` identifies a timed-out job for further status reads. A rate-limited account can continue if it already holds enough USDCx. -The example waits for the scanner to report at least 1.5 USDCx before trading. It looks up USDCx and ETH with `get_token(symbol)`, quotes a direct pool with `get_route()`, then calls `swap(...).delegate(wait=True)` with that quote and a 0.5% slippage limit. +The example passes `amount_in="1.5"` and the quote's decimal output directly +to `swap()`. The SDK converts both using token metadata; no unit conversion +is needed in the example. Strings and `Decimal` values represent token units; +integers retain their existing base-unit meaning. Excess precision is rejected. + The SDK selects a token record and returns the handle needed to claim. -One unspent record must cover 1.5 USDCx. +One unspent record must cover 1.5 USDCx. If the scanner has not indexed a +covering record yet, preparation raises `InsufficientRecordsError` before +proving or submitting a swap. ## Completion and recovery diff --git a/shield-swap-sdk/examples/first-swap/swap.py b/shield-swap-sdk/examples/first-swap/swap.py index b320a48..ba10297 100644 --- a/shield-swap-sdk/examples/first-swap/swap.py +++ b/shield-swap-sdk/examples/first-swap/swap.py @@ -1,7 +1,5 @@ """Create and fund a testnet account, swap 1.5 USDCx for ETH, and claim the output.""" import os -import time -from decimal import Decimal from aleo import Aleo, HTTPProvider, testnet @@ -31,39 +29,26 @@ # Request testnet tokens and wait for the faucet job to settle. funding = dex.api.confirm_airdrop(address) - # Find a direct USDCx/ETH pool and convert 1.5 USDCx to base units. + # Look up USDCx and ETH, then find a direct pool. source = dex.api.get_token("USDCx") target = dex.api.get_token("ETH") pool = next(pool for pool in dex.api.get_pools() if {pool.token0, pool.token1} == {source.id, target.id}) - # Wait for the scanner to report enough USDCx for the swap. - amount_in = 15 * 10**source.decimals // 10 - token_program = source.underlying_program or source.amm_token_program - for attempt in range(40): - balances = dex.get_private_balances([token_program]) - if balances.get(token_program, 0) >= amount_in: - break - time.sleep(15) - else: - raise RuntimeError("USDCx is not available; inspect funding.job and funding.message and the account balance") - - # Quote the selected pool and convert the expected ETH output to base units. + # Quote the selected pool in token units. + amount_in = "1.5" quote = dex.api.get_route( - token_in=source.id, token_out=target.id, amount_in="1.5", pool_key=pool.key, + token_in=source.id, token_out=target.id, amount_in=amount_in, pool_key=pool.key, ) if not quote.estimated_amount_out: raise RuntimeError("The selected pool returned no quote") - expected_out = int(Decimal(quote.estimated_amount_out) * 10**target.decimals) - if expected_out <= 0: - raise RuntimeError("The quote returned no ETH") # Submit one swap and wait for confirmation. Keep the returned handle for the claim. handle = dex.swap( pool_key=pool.key, token_in_id=source.id, amount_in=amount_in, - expected_out=expected_out, + expected_out=quote.estimated_amount_out, slippage_bps=50, ).delegate(wait=True) diff --git a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md index c416b3d..e068389 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md +++ b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md @@ -405,35 +405,30 @@ exhausting it raises — the fail-fast for a systematically wrong program. ### Chain methods -### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int', slippage_bps: 'int' = 50, expected_out: 'Optional[int]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` - -Request a private swap — phase one of the two-transaction flow. - -Wrapped inputs route via the swap router automatically; fund them -with UNDERLYING records — the deposit happens in-transaction. - -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 method (``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. -**Building is not free with a journal.** The blinded address is a -transition input, so a counter is reserved *here*, not at the terminal -method — discarding the call, or only simulating, still spends it. That -reservation is what makes concurrent swaps safe: it serializes under a -file lock where the probe it replaces could hand two callers the same -counter. The handle is journaled once the broadcast is accepted, so a -crash before the claim keeps the blinding factor. ``track=False`` builds -on the racing probe instead; *identity* supplies your own. - -The default -*deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- -proving latency; a tight deadline aborts at finalize when proving -outlives it. +### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int | str | Decimal', slippage_bps: 'int' = 50, expected_out: 'Optional[int | str | Decimal]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` + +Prepare one private swap; submit with ``transact()`` or ``delegate()``. + +Integer ``amount_in`` and ``expected_out`` values are base units. +Strings and ``Decimal`` values are token units (``"1.5"`` means 1.5 +tokens), converted exactly with registry metadata. Floats, excess +precision, non-finite values, and amounts outside u128 are rejected. +Returned handle amounts remain in base units. + +Quote with ``api.get_route`` and pass ``expected_out``. Without a quote, +the spot estimate ignores fees and price impact. Wrapped inputs use +underlying token records and route through the swap router automatically. +The SDK selects a covering record unless ``token_record`` is supplied. + +Preparing a call reserves a blinding counter when a journal is attached, +even if the call is discarded or only simulated. The journal retains the +handle after submission. Without a journal, retain the returned handle +for claiming and avoid concurrent swaps: chain probing cannot reserve +counters atomically. ``identity`` supplies an explicit identity; +``track=False`` bypasses journal reservation. + +``deadline_offset_blocks`` defaults to 10,000 (~8 hours at 3s/block) +to allow delegated proving; an expired deadline rejects at finalize. ### `claim_swap_output(self, handle: 'SwapHandle', *, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[ClaimResult]'` diff --git a/shield-swap-sdk/python/aleo_shield_swap/_core.py b/shield-swap-sdk/python/aleo_shield_swap/_core.py index b3924fe..9b5c94a 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_core.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_core.py @@ -11,6 +11,7 @@ import re import secrets from dataclasses import dataclass +from decimal import Decimal, InvalidOperation from typing import Any, Optional from aleo.codegen.runtime import parse_plaintext @@ -19,6 +20,46 @@ from .tick_math import MAX_SQRT_RATIO_X128, MIN_SQRT_RATIO_X128, u256_to_int +def _amount_to_base_units(amount: int | str | Decimal, token_id: str, + tokens: list[Any]) -> int: + """Convert decimal token units exactly; retain integer base-unit inputs.""" + if isinstance(amount, bool) or not isinstance(amount, (int, str, Decimal)): + raise TypeError("Use an integer for base units or a string/Decimal for token units") + if isinstance(amount, int): + result = amount + else: + matches = [token for token in tokens + if token.id == token_id or token.address == token_id] + if len(matches) != 1: + raise ValueError(f"Missing or ambiguous token metadata: {token_id}") + decimals = matches[0].decimals + if not isinstance(decimals, int) or isinstance(decimals, bool) or decimals < 0: + raise ValueError(f"Invalid token decimals: {token_id}") + try: + value = Decimal(amount) + except InvalidOperation as error: + raise ValueError("Invalid decimal token amount") from error + if not value.is_finite() or value < 0: + raise ValueError("Token amount must be finite and nonnegative") + if value.is_zero(): + return 0 + _, digits, exponent = value.as_tuple() + shift = int(exponent) + decimals + # Operate on decimal digits so the caller's Decimal context cannot round. + if shift < 0: + places = -shift + if places >= len(digits) or any(digits[-places:]): + raise ValueError(f"Amount exceeds {decimals} decimal places") + digits = digits[:-places] + shift = 0 + if len(digits) + shift > 39: + raise ValueError("Amount exceeds the u128 range") + result = int("".join(str(digit) for digit in digits)) * 10**shift + if not 0 <= result < 2**128: + raise ValueError("Amount must fit the nonnegative u128 range") + return result + + @dataclass(frozen=True) class ResolvedSwap: """A friendly swap intent resolved into the contract's raw arguments.""" diff --git a/shield-swap-sdk/python/aleo_shield_swap/api.py b/shield-swap-sdk/python/aleo_shield_swap/api.py index bb5351c..5a002ce 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/api.py +++ b/shield-swap-sdk/python/aleo_shield_swap/api.py @@ -486,32 +486,20 @@ def get_airdrop_job(self, job_id: str) -> models.AirdropJob: def confirm_airdrop(self, address: str, *, poll_interval: float = 5.0, timeout: float = 600.0) -> ConfirmAirdropResult: - """Request testnet tokens and wait for the faucet job to settle. + """Request testnet tokens and poll until the faucet job settles. - Calls ``request_airdrop`` once, then polls ``get_airdrop_job``. Returns - ``status="rate_limited"`` if the initial request receives HTTP 429; - no job starts in that case. Other API errors propagate. A settled job - can contain failed transfers; inspect ``result.job.results`` for each - token's outcome. This does not wait for record-scanner indexing. + Returns ``settled`` with ``job.results`` (including failed transfers), + or ``rate_limited`` with a message for an initial HTTP 429. Other API + errors propagate. Does not wait for record-scanner indexing. Args: - address: Receiving Aleo account address. - poll_interval: Seconds between status reads; defaults to 5. - timeout: Seconds to wait after starting the job; defaults to 600. - - Returns: - The settled job or the faucet's rate-limit explanation. + address: Receiving Aleo address. + poll_interval: Seconds between reads; defaults to 5. + timeout: Seconds after starting the job; defaults to 600. Raises: - AirdropPendingError: The job is still running at the timeout; - ``job_id`` identifies the job to resume polling. - DexApiError: The request or status read fails, except an initial 429. - - Example:: - - funding = api.confirm_airdrop(address) - if funding.status == "settled": - results = funding.job.results + AirdropPendingError: Timeout; ``job_id`` identifies the pending job. + DexApiError: Request/status failure, except an initial 429. """ try: started = self.request_airdrop(address) diff --git a/shield-swap-sdk/python/aleo_shield_swap/async_client.py b/shield-swap-sdk/python/aleo_shield_swap/async_client.py index 1514439..5b839ee 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/async_client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/async_client.py @@ -8,6 +8,7 @@ from __future__ import annotations import logging +from decimal import Decimal from typing import Any, Callable, Generic, Optional, TypeVar from aleo import AleoNetworkError, ProgramNotFound @@ -15,6 +16,7 @@ from . import _generated as g from ._calls import extract_tx_id, root_outputs from ._core import ( + _amount_to_base_units, decode_position_record, default_merkle_proofs, find_position_plaintext, @@ -583,8 +585,8 @@ def entry_for(tok: Any) -> dict[str, Any]: # ── Writes ─────────────────────────────────────────────────────────────── - async def swap(self, *, pool_key: str, token_in_id: str, amount_in: int, - slippage_bps: int = 50, expected_out: Optional[int] = None, + async def swap(self, *, pool_key: str, token_in_id: str, amount_in: int | str | Decimal, + slippage_bps: int = 50, expected_out: Optional[int | str | Decimal] = None, sqrt_price_limit: Optional[int] = None, deadline_offset_blocks: int = 10_000, nonce: Optional[int] = None, @@ -606,6 +608,12 @@ async def swap(self, *, pool_key: str, token_in_id: str, amount_in: int, :class:`~aleo_shield_swap.types.SwapHandle` — persist it if the process might die before the claim. + Integer ``amount_in`` and ``expected_out`` values are base units. + Strings and ``Decimal`` values are token units (``"1.5"`` means 1.5 + tokens), converted using registry decimals. Floats, excess precision, + non-finite values, and amounts outside u128 are rejected before proving. + Returned handle amounts remain in base units. + Quote first (``dex.api.get_route``) and pass *expected_out*: without it a spot estimate is used, which ignores fees and price impact. **This client has no journal**, so it cannot reserve blinding counters: @@ -620,6 +628,13 @@ async def swap(self, *, pool_key: str, token_in_id: str, amount_in: int, """ acct = self._account(account) pool = await self.get_pool(pool_key) + tokens = [] + if isinstance(amount_in, (str, Decimal)) or isinstance(expected_out, (str, Decimal)): + tokens = await self.api.get_tokens() + amount_in = _amount_to_base_units(amount_in, token_in_id, tokens) + if expected_out is not None: + token_out_id = str(pool.token1) if token_in_id == str(pool.token0) else str(pool.token0) + expected_out = _amount_to_base_units(expected_out, token_out_id, tokens) slot = await self.get_slot(pool_key) resolved = resolve_swap_params( pool=pool, slot=slot, token_in_id=token_in_id, amount_in=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 86cf342..acdfe69 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -9,6 +9,7 @@ from __future__ import annotations import logging +from decimal import Decimal import time from typing import Any, Optional @@ -17,6 +18,7 @@ from . import _generated as g from ._core import ( + _amount_to_base_units, decode_position_record, default_merkle_proofs, ensure_programs, @@ -761,9 +763,9 @@ def swap( *, pool_key: str, token_in_id: str, - amount_in: int, + amount_in: int | str | Decimal, slippage_bps: int = 50, - expected_out: Optional[int] = None, + expected_out: Optional[int | str | Decimal] = None, sqrt_price_limit: Optional[int] = None, deadline_offset_blocks: int = 10_000, nonce: Optional[int] = None, @@ -775,36 +777,38 @@ def swap( imports: Optional[dict[str, str]] = None, account: Any = None, ) -> DexCall[SwapHandle]: - """Request a private swap — phase one of the two-transaction flow. - - Wrapped inputs route via the swap router automatically; fund them - with UNDERLYING records — the deposit happens in-transaction. - - 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 method (``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. - **Building is not free with a journal.** The blinded address is a - transition input, so a counter is reserved *here*, not at the terminal - method — discarding the call, or only simulating, still spends it. That - reservation is what makes concurrent swaps safe: it serializes under a - file lock where the probe it replaces could hand two callers the same - counter. The handle is journaled once the broadcast is accepted, so a - crash before the claim keeps the blinding factor. ``track=False`` builds - on the racing probe instead; *identity* supplies your own. - - The default - *deadline_offset_blocks* (~8h at ~3s blocks) absorbs delegated- - proving latency; a tight deadline aborts at finalize when proving - outlives it. + """Prepare one private swap; submit with ``transact()`` or ``delegate()``. + + Integer ``amount_in`` and ``expected_out`` values are base units. + Strings and ``Decimal`` values are token units (``"1.5"`` means 1.5 + tokens), converted exactly with registry metadata. Floats, excess + precision, non-finite values, and amounts outside u128 are rejected. + Returned handle amounts remain in base units. + + Quote with ``api.get_route`` and pass ``expected_out``. Without a quote, + the spot estimate ignores fees and price impact. Wrapped inputs use + underlying token records and route through the swap router automatically. + The SDK selects a covering record unless ``token_record`` is supplied. + + Preparing a call reserves a blinding counter when a journal is attached, + even if the call is discarded or only simulated. The journal retains the + handle after submission. Without a journal, retain the returned handle + for claiming and avoid concurrent swaps: chain probing cannot reserve + counters atomically. ``identity`` supplies an explicit identity; + ``track=False`` bypasses journal reservation. + + ``deadline_offset_blocks`` defaults to 10,000 (~8 hours at 3s/block) + to allow delegated proving; an expired deadline rejects at finalize. """ acct = self._account(account) pool = self.get_pool(pool_key) + tokens = [] + if isinstance(amount_in, (str, Decimal)) or isinstance(expected_out, (str, Decimal)): + tokens = self.api.get_tokens() + amount_in = _amount_to_base_units(amount_in, token_in_id, tokens) + if expected_out is not None: + token_out_id = str(pool.token1) if token_in_id == str(pool.token0) else str(pool.token0) + expected_out = _amount_to_base_units(expected_out, token_out_id, tokens) slot = self.get_slot(pool_key) resolved = resolve_swap_params( pool=pool, slot=slot, token_in_id=token_in_id, amount_in=amount_in, @@ -1128,7 +1132,6 @@ def _quote_expected_out(self, *, token_in_id: str, token_out_id: str, the caller pays for a proof the finalize then rejects. Auth failures propagate for that reason. """ - from decimal import Decimal dec_in = self._token_decimals(token_in_id) dec_out = self._token_decimals(token_out_id) if dec_in is None or dec_out is None: diff --git a/shield-swap-sdk/tests/test_amounts.py b/shield-swap-sdk/tests/test_amounts.py new file mode 100644 index 0000000..06057ca --- /dev/null +++ b/shield-swap-sdk/tests/test_amounts.py @@ -0,0 +1,54 @@ +"""Exact conversion inside swap preparation; integer inputs retain base units.""" +from decimal import Decimal, localcontext +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from aleo_shield_swap import ShieldSwap +from aleo_shield_swap._core import _amount_to_base_units +from .test_swap import _swap_call_on + +TOKENS = [SimpleNamespace(id="1field", address="a.aleo", decimals=6), + SimpleNamespace(id="2field", address="b.aleo", decimals=8)] + + +@pytest.mark.parametrize("amount", ["1.5", Decimal("1.5")]) +def test_swap_converts_input_and_quote_internally(stub_aleo, amount): + dex = ShieldSwap(stub_aleo) + dex.api.get_tokens = Mock(return_value=TOKENS) + handle = _swap_call_on(dex, amount_in=amount, expected_out="0.0005").transact() + assert stub_aleo.last_call[1][5] == "1500000u128" + assert stub_aleo.last_call[1][6] == "49750u128" + assert handle.amount_in == 1500000 + + +def test_integer_amounts_do_not_need_metadata(): + assert _amount_to_base_units(1500000, "unknown", []) == 1500000 + + +def test_decimal_conversion_ignores_context_precision(): + with localcontext() as context: + context.prec = 2 + assert _amount_to_base_units("123456789.123456", "1field", TOKENS) == 123456789123456 + + +@pytest.mark.parametrize("amount", ["0.0000001", "NaN", "Infinity", "-1", "garbage", str(2**128)]) +def test_invalid_decimal_amounts_are_rejected(amount): + with pytest.raises(ValueError): + _amount_to_base_units(amount, "1field", TOKENS) + + +@pytest.mark.parametrize("amount", [1.5, True]) +def test_float_and_boolean_amounts_are_rejected(amount): + with pytest.raises(TypeError): + _amount_to_base_units(amount, "1field", TOKENS) + + +def test_unknown_token_is_not_assigned_default_decimals(): + with pytest.raises(ValueError, match="metadata"): + _amount_to_base_units("1.5", "unknown", TOKENS) + + +def test_trailing_zeroes_are_exact(): + assert _amount_to_base_units("1.5000000", "1field", TOKENS) == 1500000 diff --git a/shield-swap-sdk/tests/test_async_client.py b/shield-swap-sdk/tests/test_async_client.py index 5a676ce..c3d7ce1 100644 --- a/shield-swap-sdk/tests/test_async_client.py +++ b/shield-swap-sdk/tests/test_async_client.py @@ -367,3 +367,20 @@ async def test_async_public_balances_isolate_one_bad_program(caplog): with caplog.at_level("WARNING"): out = await AsyncShieldSwap(aleo).get_public_balances(["a.aleo", "gone.aleo"], address="aleo1x") assert out == {"a.aleo": 9} and "gone.aleo" in caplog.text + + +async def test_async_swap_converts_token_units(astub): + from types import SimpleNamespace + from unittest.mock import AsyncMock + from decimal import Decimal + dex = AsyncShieldSwap(astub) + dex.api.get_tokens = AsyncMock(return_value=[ + SimpleNamespace(id="1field", address="a.aleo", decimals=6), + SimpleNamespace(id="2field", address="b.aleo", decimals=8), + ]) + call = await dex.swap(pool_key="5field", token_in_id="1field", + amount_in=Decimal("1.5"), expected_out="0.0005", + token_in_program="tok.aleo") + assert astub.last_call[1][5:7] == ["1500000u128", "49750u128"] + handle = await call.transact() + assert handle.amount_in == 1500000 From b011369a7218d84d4d95345e0044402bb8d1ed7a Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 25 Sep 2026 15:11:07 -0400 Subject: [PATCH 11/13] [Chore] Upgrade crates.io snarkVM to 4.10.0 --- .github/workflows/sdk-wheels.yml | 4 +- AGENTS.md | 2 +- README.md | 2 +- sdk-abi/Cargo.lock | 233 ++++++++++++++++--------------- sdk-abi/Cargo.toml | 7 +- sdk/Cargo.lock | 233 ++++++++++++++++--------------- sdk/Cargo.toml | 2 +- sdk/Readme.md | 2 +- 8 files changed, 243 insertions(+), 242 deletions(-) diff --git a/.github/workflows/sdk-wheels.yml b/.github/workflows/sdk-wheels.yml index 7509957..0fbb2a9 100644 --- a/.github/workflows/sdk-wheels.yml +++ b/.github/workflows/sdk-wheels.yml @@ -75,8 +75,8 @@ jobs: args: --release --out dist --features mainnet sccache: 'true' manylinux: ${{ startsWith(matrix.platform.name, 'linux') && '2_28' || 'auto' }} - # The manylinux container needs git (snarkvm is a git dependency) - # and cmake + a C toolchain (aws-lc-sys builds C sources). + # Install build tools in the manylinux container. + # aws-lc-sys needs cmake and a C toolchain. before-script-linux: | if command -v dnf &> /dev/null; then dnf install -y git cmake clang diff --git a/AGENTS.md b/AGENTS.md index 7a64a6c..57914db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Aleo Python SDK — Agent Guide -Python SDK for Aleo: PyO3 bindings over **snarkvm v4.9.1** plus pure-Python +Python SDK for Aleo: PyO3 bindings over **snarkvm v4.10.0** plus pure-Python client / record-scanner / delegated-proving layers and a Web3.py-style facade. Two shipped packages: diff --git a/README.md b/README.md index daf58cd..caf9eb9 100644 --- a/README.md +++ b/README.md @@ -434,6 +434,6 @@ assert signature.verify(key.address, b"hello") `aleo.testnet` provides the corresponding types when the testnet extension is built. Match the types and client to the intended network. -The SDK uses snarkVM 4.9.1. The repository also contains [zkML tooling](zkml/) +The SDK uses snarkVM 4.10.0. The repository also contains [zkML tooling](zkml/) for translating Python machine-learning models into Leo and [zkML research](zkml-research/) on model accuracy and constraint costs. diff --git a/sdk-abi/Cargo.lock b/sdk-abi/Cargo.lock index 770de41..4abe9b2 100644 --- a/sdk-abi/Cargo.lock +++ b/sdk-abi/Cargo.lock @@ -2447,9 +2447,9 @@ dependencies = [ [[package]] name = "snarkvm" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c2f6032cf3f2ca6f444e94e0fe6caaa68c829dab15801d253c65950e2642029" +checksum = "f05c7cf42f0131c5253e0747a918d82c6c88a0ced749101e823599e8d9f900eb" dependencies = [ "anyhow", "dotenvy", @@ -2468,9 +2468,9 @@ dependencies = [ [[package]] name = "snarkvm-algorithms" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9c3f8be02db85192a1fcffa4467d895542b502a74b7b30c077bff2e2d0ac737" +checksum = "fa2490fe7b3e286fb34e570717c8d3cdcdba6da7969cf348632f8a304ddccac9" dependencies = [ "aleo-std", "anyhow", @@ -2496,9 +2496,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c961201bbbe8053a75b456df71d8644c3d5757279bf4975d597bb44937a5762" +checksum = "d36e9ac80e11ff9bfc1e06b1fb2f051126fdb2c49a026e0a520dc8425a255c66" dependencies = [ "snarkvm-circuit-account", "snarkvm-circuit-algorithms", @@ -2511,9 +2511,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-account" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "596c40a8e487a2118c6ebf4d03b918c99586bd5cbfb7da6d36a3f0f03fe3eaae" +checksum = "5dc5561b1ed39d8a84bbd331933ed1b0b5c63592f21c7dabfe989decd1c12318" dependencies = [ "snarkvm-circuit-network", "snarkvm-circuit-types", @@ -2522,9 +2522,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-algorithms" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790c670f1f00bbdbc3e332b571b3dd1746cdd3c5c3a9ac690b25a9c1c3cf4995" +checksum = "83fc99c6b9ca5e3ade081abd80a61673d89bc1d1bfa3bffe5a55417bdb5a8efc" dependencies = [ "snarkvm-circuit-types", "snarkvm-console-algorithms", @@ -2533,9 +2533,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-collections" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "768461e7a8ac6a4bcd562fc8fe8aea24ae904a99c95ba86e49e7e5d4470f2c68" +checksum = "fdbabd8ded67214edf9577f7035e42bd4362e5d830fb7cbe7a380a0474becbcd" dependencies = [ "snarkvm-circuit-algorithms", "snarkvm-circuit-types", @@ -2544,9 +2544,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-environment" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05228ded870682c0fc0df687e984fba764366b53c54144bfba5b9b44a8c324a" +checksum = "49677add293aeabf271a85b1cd29e9ac76ee91dd0a419c899ac50eb059e99fa6" dependencies = [ "anyhow", "indexmap", @@ -2565,15 +2565,15 @@ dependencies = [ [[package]] name = "snarkvm-circuit-environment-witness" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ed2e035d30e909af0071c6a926e649a06337f4af1f122524ec5c95dff78846" +checksum = "94fffbddd0d5be8518eda15e9082a446071ab3262b80d77ec33e32724e0c7c58" [[package]] name = "snarkvm-circuit-network" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aba3a4f0ed5830e07c49d5b4c0bb367871878a2cfe1fbc4d43dcc0b626423e55" +checksum = "f9c81fc70976ce1bef8006408514249add426bfd2444ba9db184890735ffc44b" dependencies = [ "snarkvm-circuit-algorithms", "snarkvm-circuit-collections", @@ -2583,9 +2583,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-program" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c27c84f37c2cc2b30e47665924afb779b7337cb23e46af6374641bf1c5b68664" +checksum = "56be4b69ffaf58118ab1db9b7c0f569c6e70803d24e146630cda36ffaf9b46c7" dependencies = [ "snarkvm-circuit-account", "snarkvm-circuit-algorithms", @@ -2598,9 +2598,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc3adfbef58d5fa344e1e2002a03d48719cf09445dbc40b6deeba54395e88da" +checksum = "d953b14559130f888b6816a7a425553f556a9606f039424ba217b063dd2d7c99" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-address", @@ -2614,9 +2614,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-address" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "787dfd4937f9034605e9a9910873aa36d952e453909f97c5e4aa38ecc6dd6726" +checksum = "bfb0fda84c23646866b703718d6df591414ffcdeb9089d5de988a85e47dc899b" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2628,9 +2628,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-boolean" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9441ea0a19b2bf2998ee4a5a2d9879da07832e1f65cb863f117039880d4b6b4d" +checksum = "ffe8a5b98ae72d7b6ba037e7c1cc72c318704b09569618801acc71c83fd80ea0" dependencies = [ "snarkvm-circuit-environment", "snarkvm-console-types-boolean", @@ -2638,9 +2638,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-field" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d50b54f96df663b3f8a460b718972094a2307f8c55e43549f98e8bffed8c1bc" +checksum = "31b460c0acda02b70833b75c621bd90130b3183ecce38df4fcac99b22527ba86" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2649,9 +2649,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-group" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd96bdc1f5cec2329fba5344d544457e13bba984ad4d5c910d763cc2503a7225" +checksum = "88a294692ebf3082c5830b6892f97240a71c88f22de54a0b7caed7695db9b892" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2662,9 +2662,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-integers" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06eda084ac7b329f49d4ea221d3f29a17ad0966941c274445d5fc19df322e85f" +checksum = "684c413ddb7e3c0bdcde26ddd484f7880703c6804e63a2453d4e8bb1974c81d5" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2675,9 +2675,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-scalar" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a17c07cb5cb7dcfabcbce865b6791172436254b8c53fca97c29d296544ee99" +checksum = "557309a2eac33907c8bd1328397b4124af469c2952f8fc92a526ac4937a6b3fe" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2687,9 +2687,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-string" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5143bb104889abe9d35e622072ac308e68565ea77433ee11ac74333313127064" +checksum = "9fd29d7bda45f9468f111125284abcbf2ddc8d6c052ca30e00cfa80d7ec5d3d5" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2700,9 +2700,9 @@ dependencies = [ [[package]] name = "snarkvm-console" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7052e5549a0d9c558603e6476d44c6d711ea02696331fa4b70948d0c2fe0831f" +checksum = "8c07e08e84d862607c1684be9dfcba23acc405786720aafa56753260f4517d12" dependencies = [ "snarkvm-console-account", "snarkvm-console-algorithms", @@ -2714,9 +2714,9 @@ dependencies = [ [[package]] name = "snarkvm-console-account" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f41da8b52475643a01b452b25d0a1af7f8b64465bc71946c1da4b37bb311a902" +checksum = "8ad1a4722dba991eb73ab049aea9da82ab24e4c533cd1370c0ee477f5d89b618" dependencies = [ "bs58", "snarkvm-console-network", @@ -2726,9 +2726,9 @@ dependencies = [ [[package]] name = "snarkvm-console-algorithms" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e59305ab59e72d95a430d3855556f07c2381e483143bd38a831b0cde64c6b508" +checksum = "9aced93d7edadb952b03e0599e679f6fe0f2a3adedfbf208c06400b2ab6b999f" dependencies = [ "blake2s_simd", "hex", @@ -2744,9 +2744,9 @@ dependencies = [ [[package]] name = "snarkvm-console-collections" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceceed14f7e91076939027fcd11532d79f430dff2e5ef98b0729c142cd937c86" +checksum = "7b8123f3ce5cc9a5ee45675dc81e0978851431a5bba27d99e59024cb374917de" dependencies = [ "aleo-std", "parking_lot", @@ -2758,9 +2758,9 @@ dependencies = [ [[package]] name = "snarkvm-console-network" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928ba6f859f4912b93fdfba087a02839d9427b74aec9f84722b47ef246b2ddc7" +checksum = "cc3f59916d97c225d5a4af4059f9fe7c646250e8c32a39922f768c3c5701bf23" dependencies = [ "anyhow", "enum-iterator", @@ -2779,9 +2779,9 @@ dependencies = [ [[package]] name = "snarkvm-console-network-environment" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c80ff70962c3d937f88dc6469abe3346f781b9fe1ee80d2fc7951d2cea59c0d" +checksum = "84400508e5600b580b610975fa0d55da722db4f950fc6daebb9b219f64441086" dependencies = [ "anyhow", "bech32", @@ -2798,9 +2798,9 @@ dependencies = [ [[package]] name = "snarkvm-console-program" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "069ec04c458f89f95e7948841cb6ef33c0678725286f455bd392ff22679115fb" +checksum = "2330aa1374a8a39aa5730758978d39658dda26f908cbeda2bd9f0c94331c9a4f" dependencies = [ "enum-iterator", "enum_index", @@ -2820,9 +2820,9 @@ dependencies = [ [[package]] name = "snarkvm-console-types" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b4f6bfc7dfaa57cf94588f620fe2a52905284e050b30caca94afd4929218dcb" +checksum = "350c823beeb7b8b8dc66010cebacd3c1016f4b10eced16e211a255a8015a2c0d" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-address", @@ -2836,9 +2836,9 @@ dependencies = [ [[package]] name = "snarkvm-console-types-address" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6095d44cff37de19d492b772b00b90c15fe866b759820ecc957b8955dc3e7c3d" +checksum = "bdc01722e25b87cf776c2f2601faa292887aefd576c89620d0ea4fac4e791417" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2848,19 +2848,20 @@ dependencies = [ [[package]] name = "snarkvm-console-types-boolean" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f97bf64eca0432940c232f024671bf672b6068e3d34c7a5dd4c6d1969d2ac911" +checksum = "6f588960020a2ed3e18be8deaef560ae3f3f3b8a15a774aeb2fd9a4e2f247d19" dependencies = [ "snarkvm-console-network-environment", ] [[package]] name = "snarkvm-console-types-field" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23dc4780540a3b010ba9b46c3e16ee1fc0af074c77569d0e3686ed489fb8fc1" +checksum = "ba714ce14f2b0d2de941235e7445d8cb1926873131970521a6292f22bc5d6414" dependencies = [ + "smallvec", "snarkvm-console-network-environment", "snarkvm-console-types-boolean", "zeroize", @@ -2868,9 +2869,9 @@ dependencies = [ [[package]] name = "snarkvm-console-types-group" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3797a367e1e3f5164835c70d2f6ac4a5c4fadfa28b191bd18c208496242aed2" +checksum = "a09eb22837fe723141ce3be6af39c5a9215f5d2e21b98b97381bd078b20d7fb8" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2880,9 +2881,9 @@ dependencies = [ [[package]] name = "snarkvm-console-types-integers" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11b86bc9faf94e36d3c7ccd2a2ba9020220ccd1fb4d4c89ed2ed1436da15e936" +checksum = "75cfaf7ddc03b7ab658301f506d9c65b58ab54b180728b8a5e43ecd6b4d09667" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2892,9 +2893,9 @@ dependencies = [ [[package]] name = "snarkvm-console-types-scalar" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46cadad75feaad8bb6022e53d9516b48d61b63f026968a8538fd4a47c89a935b" +checksum = "2517699bd3997aceb3b4840cc9023c74901a685112cde51e9c807d452cbaca48" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2904,9 +2905,9 @@ dependencies = [ [[package]] name = "snarkvm-console-types-string" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de20b3ca0ccadac63dc15a4c6575e27b34749773ae4c2bf04f93bdf5c727fbc8" +checksum = "06a9c0563da9bd9d890f47ee16638ba9da7ee3da80ae093e6340f2d9ba3f2785" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2916,9 +2917,9 @@ dependencies = [ [[package]] name = "snarkvm-curves" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2431f26bec6ec0b00716bdf12cc777b77aa19a3d414a335e8cd3529b276dfef4" +checksum = "a9608ad1e4539c91f06735cbea3a886eab22561d407d2d986fe173d27f0cf1de" dependencies = [ "rand", "rustc_version", @@ -2930,9 +2931,9 @@ dependencies = [ [[package]] name = "snarkvm-fields" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a075fa051d20d1335f44b9f936af6ef1574230c19d1059bf43a7fb3d13eb74a" +checksum = "5f822d04c3ea198df6796a480b4d48572739422fc347dbbd5e8c4c39e3bcf84a" dependencies = [ "aleo-std", "anyhow", @@ -2948,9 +2949,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96d52a9eb380d4ea97e0ac3699753572a2017e584cd23af70678e1e25e17590" +checksum = "9c98d7f66caa4743dac4c043eaa7c52cea4269f49fee9eb4de3b11234f513548" dependencies = [ "aleo-std", "anyhow", @@ -2979,9 +2980,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-authority" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9574129b57144e912ea5f6692b5c5471dab7522fe41bedf479d98d3b636bcdc" +checksum = "1f0a17ca7e28ecb029d2a331ee56ec2ad657f7fcbafc6aed573e68400fc24f48" dependencies = [ "anyhow", "rand", @@ -2992,9 +2993,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-block" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d8413babbbbd4f9df7177fe5ec8fb89bfc7bd775d5dc253a35f1f9cbf8d7118" +checksum = "6adaf69fb578a53ecdada9fb8e2b78a9f0081f23ecf96924bdd68d2c18a4f5f4" dependencies = [ "anyhow", "indexmap", @@ -3017,9 +3018,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-committee" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f72a471f9423f0f0e7a107354c76e4be0737c9bcae0ad4ab6c3cd611185b6df2" +checksum = "74a9574de139c260440e7dc49c53c1f084f0175b5def0511dae108a985df3ebc" dependencies = [ "indexmap", "rayon", @@ -3030,9 +3031,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "199bd604d194382019069acca91429628c71c064668442efc8fb58306f41fb4c" +checksum = "dc051716c605ecbf80e2ac7a5ff9677d7b802b78e5f2ff0fc91a85027d384748" dependencies = [ "snarkvm-ledger-narwhal-batch-certificate", "snarkvm-ledger-narwhal-batch-header", @@ -3044,9 +3045,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-batch-certificate" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e4bb0dd80f1f06acd2a3a7d9ff1dc71b4a2ca8c3d73c77e8ffff649f61a482" +checksum = "c7dc233992c54cd93b757677158202068f14ed266a2c094fb3a9a1c5c17ca9d6" dependencies = [ "indexmap", "rayon", @@ -3058,9 +3059,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-batch-header" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6786ce0f1d0ce71cef2f29a09febf889c755fd52ccf53691eb5a06ffc22b6eb2" +checksum = "d866a4dba35e2c04f7100fdb2ab671552927bbb855ceaa2100d8b69b194677a8" dependencies = [ "indexmap", "rayon", @@ -3071,9 +3072,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-data" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc08eb479037f905a2492d4431e1015b6d5b28bfe6edcea35e7407704d5b72a5" +checksum = "0c139c7538661c8dbd5ee5abada442c61d1ad317ef510cba13ee02ff1ddbb110" dependencies = [ "bytes", "serde_json", @@ -3083,9 +3084,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-subdag" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e793a47d0f199004b4f73a239eb1b198c90e39cf03640af39b4d34a171e78e0a" +checksum = "4a0d45a46bcff58c5066671878ef052e6fb2893a1084d691c566816233d2234a" dependencies = [ "indexmap", "rayon", @@ -3099,9 +3100,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-transmission" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c78113ae6c04239ec329ac9f9cd0e8a7374282808c5a223375b9b8aad421c4b" +checksum = "e1b2b36f96b430d91433646f64c7cbe7f7f7f3eb517ce04f9b6ab38fe0a8ccc9" dependencies = [ "bytes", "serde_json", @@ -3113,9 +3114,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-transmission-id" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a712b71c3fe5a7c21b7db20b16e27ad3a4f8fe45c4e44f70ff7515c6953b59f0" +checksum = "59b6a4d89357cad0e5964c84bd89d3b1236c96ff75ea65dd78b82207f9e3d445" dependencies = [ "snarkvm-console", "snarkvm-ledger-puzzle", @@ -3123,9 +3124,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-puzzle" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22c35414318062cb76a068859f39e01eea877513066e91e73406f93cd3135eb" +checksum = "b3f9fb2f4172617af3f19034723dbcf1b6e456e2d3f799fb658841f3a6e5d579" dependencies = [ "aleo-std", "anyhow", @@ -3144,9 +3145,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-puzzle-epoch" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73277b45a3d10a1dc20cd8f54b5987cdb5a0766c62eb7315a12ff24992bf9982" +checksum = "c9a7618aa786150d21289baa33fd29a67703393683196c158c64d2ed60a10024" dependencies = [ "aleo-std", "anyhow", @@ -3167,9 +3168,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-query" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e18e66c6b4834d0834a29c36c19f6c85814bb5c80c0fb158dcc4f6a5aa26300" +checksum = "fe33e4996354fd24dc515db168bfc94f1452a16066e516f0ba3e21aa22b43aa7" dependencies = [ "anyhow", "async-trait", @@ -3185,9 +3186,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-store" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9336f9bfd3ed18275af8b17c4e107e8ed6a42677522ebc1d4ce05088ddac34f0" +checksum = "7e50dee049525af5255c7a72ee7b1f8f813729cc9224bc3b7f59c39807f4d496" dependencies = [ "aleo-std-storage", "anyhow", @@ -3211,9 +3212,9 @@ dependencies = [ [[package]] name = "snarkvm-parameters" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a0e7c49fffc7e93221cd004b382fb4bd75aafbeee3fd8dcd12fc035dcce3a70" +checksum = "f9c24c034456998548d9ed7162ab6ac038da94da25a2cbb6b03e1ca7ccdcd539" dependencies = [ "aleo-std", "anyhow", @@ -3237,9 +3238,9 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c19ea8f5292190eb55e423a63b19ca2430adde3ad2e0d5a3b4a6033c7d9104" +checksum = "4191f5cb9f9793c3db3facb6085dbedec256f42968c1ade954093ce37d5ece3a" dependencies = [ "aleo-std", "anyhow", @@ -3273,9 +3274,9 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer-error" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a104d87ec01afbc9fa1bcf0425fd3eb31f9038dc81c14ccdee2884fe33f0598" +checksum = "ceb78a68ff721f4ffb356773fc6328d5ddda18348f7d898be9b1013391cf4fcd" dependencies = [ "anyhow", "snarkvm-circuit-environment", @@ -3286,9 +3287,9 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer-process" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "914bc444d4504e7e624a3e13e0e01252c7a1e9788a3d7f81515e3bf5b97c82d9" +checksum = "aecda5ba69f111a3bdc3f7026d49fd22e8f9d6f72bf82bc5f62e052fe5d61fe1" dependencies = [ "aleo-std", "colored", @@ -3313,9 +3314,9 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer-program" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed284751e7beb0561e4f28fd10ef3ebe240536aedbd298b8b054b482c4f2c4e" +checksum = "a29ef687bf561b749b2da424a5270f9856c5c006602d9aad3e725e90433919b7" dependencies = [ "enum-iterator", "indexmap", @@ -3335,9 +3336,9 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer-snark" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0749c669932ee44da7e047989085827825186f55826821f0015d322a91f3bbd2" +checksum = "261777f56ef81a80ce076ccccd3f376cb0bbb4292e8a075951b0ab39ff7a8e0c" dependencies = [ "bincode", "serde_json", @@ -3349,9 +3350,9 @@ dependencies = [ [[package]] name = "snarkvm-utilities" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b46d0bce6f3cf66e67fa50101568957f3f0b18b962ae2afbb9dff08799a8a4" +checksum = "712436dc79f5345d0c09795da64fe9e680b18976ba137563e46f3e8986accd04" dependencies = [ "aleo-std", "anyhow", @@ -3373,9 +3374,9 @@ dependencies = [ [[package]] name = "snarkvm-utilities-derives" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b28d07a68a84b654ca1bf06b1203c05a4a04672ca97247fe46d5aeb2d4ae507" +checksum = "e2318c1bc848919dc803e9aa22b0737e7671a82ef070ce12d29b49ff1b14958b" dependencies = [ "proc-macro2", "quote 1.0.47", diff --git a/sdk-abi/Cargo.toml b/sdk-abi/Cargo.toml index bee4c94..ed383d2 100644 --- a/sdk-abi/Cargo.toml +++ b/sdk-abi/Cargo.toml @@ -22,10 +22,9 @@ leo-abi = { git = "https://github.com/ProvableHQ/leo", rev = "c82f149e97b8279e3c leo-ast = { git = "https://github.com/ProvableHQ/leo", rev = "c82f149e97b8279e3c453cbf98e41eec46aec3e7", package = "leo-ast" } leo-disassembler = { git = "https://github.com/ProvableHQ/leo", rev = "c82f149e97b8279e3c453cbf98e41eec46aec3e7", package = "leo-disassembler" } leo-span = { git = "https://github.com/ProvableHQ/leo", rev = "c82f149e97b8279e3c453cbf98e41eec46aec3e7", package = "leo-span" } -# Same crates.io version + feature set the pinned leo rev uses, so -# `Process`/`Program` are the same types leo-disassembler's signatures -# expect (leo master c82f149e, 2026-09-09, pins snarkvm 4.9.1). -snarkvm = { version = "4.9.1", features = ["test_consensus_heights", "dev_skip_checks", "test_targets", "history"] } +# Unify the crates.io snarkVM version with Leo so Process and Program +# use the same types. Keep ABI-only features isolated from the main SDK. +snarkvm = { version = "4.10.0", features = ["test_consensus_heights", "dev_skip_checks", "test_targets", "history"] } [profile.release] opt-level = 3 diff --git a/sdk/Cargo.lock b/sdk/Cargo.lock index 5db8e42..404496a 100644 --- a/sdk/Cargo.lock +++ b/sdk/Cargo.lock @@ -2304,9 +2304,9 @@ dependencies = [ [[package]] name = "snarkvm" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c2f6032cf3f2ca6f444e94e0fe6caaa68c829dab15801d253c65950e2642029" +checksum = "f05c7cf42f0131c5253e0747a918d82c6c88a0ced749101e823599e8d9f900eb" dependencies = [ "anyhow", "rand", @@ -2322,9 +2322,9 @@ dependencies = [ [[package]] name = "snarkvm-algorithms" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9c3f8be02db85192a1fcffa4467d895542b502a74b7b30c077bff2e2d0ac737" +checksum = "fa2490fe7b3e286fb34e570717c8d3cdcdba6da7969cf348632f8a304ddccac9" dependencies = [ "aleo-std", "anyhow", @@ -2350,9 +2350,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c961201bbbe8053a75b456df71d8644c3d5757279bf4975d597bb44937a5762" +checksum = "d36e9ac80e11ff9bfc1e06b1fb2f051126fdb2c49a026e0a520dc8425a255c66" dependencies = [ "snarkvm-circuit-account", "snarkvm-circuit-algorithms", @@ -2365,9 +2365,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-account" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "596c40a8e487a2118c6ebf4d03b918c99586bd5cbfb7da6d36a3f0f03fe3eaae" +checksum = "5dc5561b1ed39d8a84bbd331933ed1b0b5c63592f21c7dabfe989decd1c12318" dependencies = [ "snarkvm-circuit-network", "snarkvm-circuit-types", @@ -2376,9 +2376,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-algorithms" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790c670f1f00bbdbc3e332b571b3dd1746cdd3c5c3a9ac690b25a9c1c3cf4995" +checksum = "83fc99c6b9ca5e3ade081abd80a61673d89bc1d1bfa3bffe5a55417bdb5a8efc" dependencies = [ "snarkvm-circuit-types", "snarkvm-console-algorithms", @@ -2387,9 +2387,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-collections" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "768461e7a8ac6a4bcd562fc8fe8aea24ae904a99c95ba86e49e7e5d4470f2c68" +checksum = "fdbabd8ded67214edf9577f7035e42bd4362e5d830fb7cbe7a380a0474becbcd" dependencies = [ "snarkvm-circuit-algorithms", "snarkvm-circuit-types", @@ -2398,9 +2398,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-environment" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05228ded870682c0fc0df687e984fba764366b53c54144bfba5b9b44a8c324a" +checksum = "49677add293aeabf271a85b1cd29e9ac76ee91dd0a419c899ac50eb059e99fa6" dependencies = [ "anyhow", "indexmap", @@ -2419,15 +2419,15 @@ dependencies = [ [[package]] name = "snarkvm-circuit-environment-witness" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ed2e035d30e909af0071c6a926e649a06337f4af1f122524ec5c95dff78846" +checksum = "94fffbddd0d5be8518eda15e9082a446071ab3262b80d77ec33e32724e0c7c58" [[package]] name = "snarkvm-circuit-network" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aba3a4f0ed5830e07c49d5b4c0bb367871878a2cfe1fbc4d43dcc0b626423e55" +checksum = "f9c81fc70976ce1bef8006408514249add426bfd2444ba9db184890735ffc44b" dependencies = [ "snarkvm-circuit-algorithms", "snarkvm-circuit-collections", @@ -2437,9 +2437,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-program" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c27c84f37c2cc2b30e47665924afb779b7337cb23e46af6374641bf1c5b68664" +checksum = "56be4b69ffaf58118ab1db9b7c0f569c6e70803d24e146630cda36ffaf9b46c7" dependencies = [ "snarkvm-circuit-account", "snarkvm-circuit-algorithms", @@ -2452,9 +2452,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc3adfbef58d5fa344e1e2002a03d48719cf09445dbc40b6deeba54395e88da" +checksum = "d953b14559130f888b6816a7a425553f556a9606f039424ba217b063dd2d7c99" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-address", @@ -2468,9 +2468,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-address" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "787dfd4937f9034605e9a9910873aa36d952e453909f97c5e4aa38ecc6dd6726" +checksum = "bfb0fda84c23646866b703718d6df591414ffcdeb9089d5de988a85e47dc899b" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2482,9 +2482,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-boolean" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9441ea0a19b2bf2998ee4a5a2d9879da07832e1f65cb863f117039880d4b6b4d" +checksum = "ffe8a5b98ae72d7b6ba037e7c1cc72c318704b09569618801acc71c83fd80ea0" dependencies = [ "snarkvm-circuit-environment", "snarkvm-console-types-boolean", @@ -2492,9 +2492,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-field" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d50b54f96df663b3f8a460b718972094a2307f8c55e43549f98e8bffed8c1bc" +checksum = "31b460c0acda02b70833b75c621bd90130b3183ecce38df4fcac99b22527ba86" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2503,9 +2503,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-group" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd96bdc1f5cec2329fba5344d544457e13bba984ad4d5c910d763cc2503a7225" +checksum = "88a294692ebf3082c5830b6892f97240a71c88f22de54a0b7caed7695db9b892" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2516,9 +2516,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-integers" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06eda084ac7b329f49d4ea221d3f29a17ad0966941c274445d5fc19df322e85f" +checksum = "684c413ddb7e3c0bdcde26ddd484f7880703c6804e63a2453d4e8bb1974c81d5" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2529,9 +2529,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-scalar" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a17c07cb5cb7dcfabcbce865b6791172436254b8c53fca97c29d296544ee99" +checksum = "557309a2eac33907c8bd1328397b4124af469c2952f8fc92a526ac4937a6b3fe" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2541,9 +2541,9 @@ dependencies = [ [[package]] name = "snarkvm-circuit-types-string" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5143bb104889abe9d35e622072ac308e68565ea77433ee11ac74333313127064" +checksum = "9fd29d7bda45f9468f111125284abcbf2ddc8d6c052ca30e00cfa80d7ec5d3d5" dependencies = [ "snarkvm-circuit-environment", "snarkvm-circuit-types-boolean", @@ -2554,9 +2554,9 @@ dependencies = [ [[package]] name = "snarkvm-console" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7052e5549a0d9c558603e6476d44c6d711ea02696331fa4b70948d0c2fe0831f" +checksum = "8c07e08e84d862607c1684be9dfcba23acc405786720aafa56753260f4517d12" dependencies = [ "snarkvm-console-account", "snarkvm-console-algorithms", @@ -2568,9 +2568,9 @@ dependencies = [ [[package]] name = "snarkvm-console-account" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f41da8b52475643a01b452b25d0a1af7f8b64465bc71946c1da4b37bb311a902" +checksum = "8ad1a4722dba991eb73ab049aea9da82ab24e4c533cd1370c0ee477f5d89b618" dependencies = [ "bs58", "snarkvm-console-network", @@ -2580,9 +2580,9 @@ dependencies = [ [[package]] name = "snarkvm-console-algorithms" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e59305ab59e72d95a430d3855556f07c2381e483143bd38a831b0cde64c6b508" +checksum = "9aced93d7edadb952b03e0599e679f6fe0f2a3adedfbf208c06400b2ab6b999f" dependencies = [ "blake2s_simd", "hex", @@ -2598,9 +2598,9 @@ dependencies = [ [[package]] name = "snarkvm-console-collections" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceceed14f7e91076939027fcd11532d79f430dff2e5ef98b0729c142cd937c86" +checksum = "7b8123f3ce5cc9a5ee45675dc81e0978851431a5bba27d99e59024cb374917de" dependencies = [ "aleo-std", "parking_lot", @@ -2612,9 +2612,9 @@ dependencies = [ [[package]] name = "snarkvm-console-network" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928ba6f859f4912b93fdfba087a02839d9427b74aec9f84722b47ef246b2ddc7" +checksum = "cc3f59916d97c225d5a4af4059f9fe7c646250e8c32a39922f768c3c5701bf23" dependencies = [ "anyhow", "enum-iterator", @@ -2633,9 +2633,9 @@ dependencies = [ [[package]] name = "snarkvm-console-network-environment" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c80ff70962c3d937f88dc6469abe3346f781b9fe1ee80d2fc7951d2cea59c0d" +checksum = "84400508e5600b580b610975fa0d55da722db4f950fc6daebb9b219f64441086" dependencies = [ "anyhow", "bech32", @@ -2652,9 +2652,9 @@ dependencies = [ [[package]] name = "snarkvm-console-program" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "069ec04c458f89f95e7948841cb6ef33c0678725286f455bd392ff22679115fb" +checksum = "2330aa1374a8a39aa5730758978d39658dda26f908cbeda2bd9f0c94331c9a4f" dependencies = [ "enum-iterator", "enum_index", @@ -2674,9 +2674,9 @@ dependencies = [ [[package]] name = "snarkvm-console-types" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b4f6bfc7dfaa57cf94588f620fe2a52905284e050b30caca94afd4929218dcb" +checksum = "350c823beeb7b8b8dc66010cebacd3c1016f4b10eced16e211a255a8015a2c0d" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-address", @@ -2690,9 +2690,9 @@ dependencies = [ [[package]] name = "snarkvm-console-types-address" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6095d44cff37de19d492b772b00b90c15fe866b759820ecc957b8955dc3e7c3d" +checksum = "bdc01722e25b87cf776c2f2601faa292887aefd576c89620d0ea4fac4e791417" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2702,19 +2702,20 @@ dependencies = [ [[package]] name = "snarkvm-console-types-boolean" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f97bf64eca0432940c232f024671bf672b6068e3d34c7a5dd4c6d1969d2ac911" +checksum = "6f588960020a2ed3e18be8deaef560ae3f3f3b8a15a774aeb2fd9a4e2f247d19" dependencies = [ "snarkvm-console-network-environment", ] [[package]] name = "snarkvm-console-types-field" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23dc4780540a3b010ba9b46c3e16ee1fc0af074c77569d0e3686ed489fb8fc1" +checksum = "ba714ce14f2b0d2de941235e7445d8cb1926873131970521a6292f22bc5d6414" dependencies = [ + "smallvec", "snarkvm-console-network-environment", "snarkvm-console-types-boolean", "zeroize", @@ -2722,9 +2723,9 @@ dependencies = [ [[package]] name = "snarkvm-console-types-group" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3797a367e1e3f5164835c70d2f6ac4a5c4fadfa28b191bd18c208496242aed2" +checksum = "a09eb22837fe723141ce3be6af39c5a9215f5d2e21b98b97381bd078b20d7fb8" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2734,9 +2735,9 @@ dependencies = [ [[package]] name = "snarkvm-console-types-integers" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11b86bc9faf94e36d3c7ccd2a2ba9020220ccd1fb4d4c89ed2ed1436da15e936" +checksum = "75cfaf7ddc03b7ab658301f506d9c65b58ab54b180728b8a5e43ecd6b4d09667" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2746,9 +2747,9 @@ dependencies = [ [[package]] name = "snarkvm-console-types-scalar" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46cadad75feaad8bb6022e53d9516b48d61b63f026968a8538fd4a47c89a935b" +checksum = "2517699bd3997aceb3b4840cc9023c74901a685112cde51e9c807d452cbaca48" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2758,9 +2759,9 @@ dependencies = [ [[package]] name = "snarkvm-console-types-string" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de20b3ca0ccadac63dc15a4c6575e27b34749773ae4c2bf04f93bdf5c727fbc8" +checksum = "06a9c0563da9bd9d890f47ee16638ba9da7ee3da80ae093e6340f2d9ba3f2785" dependencies = [ "snarkvm-console-network-environment", "snarkvm-console-types-boolean", @@ -2770,9 +2771,9 @@ dependencies = [ [[package]] name = "snarkvm-curves" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2431f26bec6ec0b00716bdf12cc777b77aa19a3d414a335e8cd3529b276dfef4" +checksum = "a9608ad1e4539c91f06735cbea3a886eab22561d407d2d986fe173d27f0cf1de" dependencies = [ "rand", "rustc_version", @@ -2784,9 +2785,9 @@ dependencies = [ [[package]] name = "snarkvm-fields" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a075fa051d20d1335f44b9f936af6ef1574230c19d1059bf43a7fb3d13eb74a" +checksum = "5f822d04c3ea198df6796a480b4d48572739422fc347dbbd5e8c4c39e3bcf84a" dependencies = [ "aleo-std", "anyhow", @@ -2802,9 +2803,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96d52a9eb380d4ea97e0ac3699753572a2017e584cd23af70678e1e25e17590" +checksum = "9c98d7f66caa4743dac4c043eaa7c52cea4269f49fee9eb4de3b11234f513548" dependencies = [ "aleo-std", "anyhow", @@ -2833,9 +2834,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-authority" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9574129b57144e912ea5f6692b5c5471dab7522fe41bedf479d98d3b636bcdc" +checksum = "1f0a17ca7e28ecb029d2a331ee56ec2ad657f7fcbafc6aed573e68400fc24f48" dependencies = [ "anyhow", "rand", @@ -2846,9 +2847,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-block" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d8413babbbbd4f9df7177fe5ec8fb89bfc7bd775d5dc253a35f1f9cbf8d7118" +checksum = "6adaf69fb578a53ecdada9fb8e2b78a9f0081f23ecf96924bdd68d2c18a4f5f4" dependencies = [ "anyhow", "indexmap", @@ -2871,9 +2872,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-committee" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f72a471f9423f0f0e7a107354c76e4be0737c9bcae0ad4ab6c3cd611185b6df2" +checksum = "74a9574de139c260440e7dc49c53c1f084f0175b5def0511dae108a985df3ebc" dependencies = [ "indexmap", "rayon", @@ -2884,9 +2885,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "199bd604d194382019069acca91429628c71c064668442efc8fb58306f41fb4c" +checksum = "dc051716c605ecbf80e2ac7a5ff9677d7b802b78e5f2ff0fc91a85027d384748" dependencies = [ "snarkvm-ledger-narwhal-batch-certificate", "snarkvm-ledger-narwhal-batch-header", @@ -2898,9 +2899,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-batch-certificate" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e4bb0dd80f1f06acd2a3a7d9ff1dc71b4a2ca8c3d73c77e8ffff649f61a482" +checksum = "c7dc233992c54cd93b757677158202068f14ed266a2c094fb3a9a1c5c17ca9d6" dependencies = [ "indexmap", "rayon", @@ -2912,9 +2913,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-batch-header" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6786ce0f1d0ce71cef2f29a09febf889c755fd52ccf53691eb5a06ffc22b6eb2" +checksum = "d866a4dba35e2c04f7100fdb2ab671552927bbb855ceaa2100d8b69b194677a8" dependencies = [ "indexmap", "rayon", @@ -2925,9 +2926,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-data" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc08eb479037f905a2492d4431e1015b6d5b28bfe6edcea35e7407704d5b72a5" +checksum = "0c139c7538661c8dbd5ee5abada442c61d1ad317ef510cba13ee02ff1ddbb110" dependencies = [ "bytes", "serde_json", @@ -2937,9 +2938,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-subdag" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e793a47d0f199004b4f73a239eb1b198c90e39cf03640af39b4d34a171e78e0a" +checksum = "4a0d45a46bcff58c5066671878ef052e6fb2893a1084d691c566816233d2234a" dependencies = [ "indexmap", "rayon", @@ -2953,9 +2954,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-transmission" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c78113ae6c04239ec329ac9f9cd0e8a7374282808c5a223375b9b8aad421c4b" +checksum = "e1b2b36f96b430d91433646f64c7cbe7f7f7f3eb517ce04f9b6ab38fe0a8ccc9" dependencies = [ "bytes", "serde_json", @@ -2967,9 +2968,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-narwhal-transmission-id" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a712b71c3fe5a7c21b7db20b16e27ad3a4f8fe45c4e44f70ff7515c6953b59f0" +checksum = "59b6a4d89357cad0e5964c84bd89d3b1236c96ff75ea65dd78b82207f9e3d445" dependencies = [ "snarkvm-console", "snarkvm-ledger-puzzle", @@ -2977,9 +2978,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-puzzle" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22c35414318062cb76a068859f39e01eea877513066e91e73406f93cd3135eb" +checksum = "b3f9fb2f4172617af3f19034723dbcf1b6e456e2d3f799fb658841f3a6e5d579" dependencies = [ "aleo-std", "anyhow", @@ -2998,9 +2999,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-puzzle-epoch" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73277b45a3d10a1dc20cd8f54b5987cdb5a0766c62eb7315a12ff24992bf9982" +checksum = "c9a7618aa786150d21289baa33fd29a67703393683196c158c64d2ed60a10024" dependencies = [ "aleo-std", "anyhow", @@ -3021,9 +3022,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-query" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e18e66c6b4834d0834a29c36c19f6c85814bb5c80c0fb158dcc4f6a5aa26300" +checksum = "fe33e4996354fd24dc515db168bfc94f1452a16066e516f0ba3e21aa22b43aa7" dependencies = [ "anyhow", "async-trait", @@ -3039,9 +3040,9 @@ dependencies = [ [[package]] name = "snarkvm-ledger-store" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9336f9bfd3ed18275af8b17c4e107e8ed6a42677522ebc1d4ce05088ddac34f0" +checksum = "7e50dee049525af5255c7a72ee7b1f8f813729cc9224bc3b7f59c39807f4d496" dependencies = [ "aleo-std-storage", "anyhow", @@ -3065,9 +3066,9 @@ dependencies = [ [[package]] name = "snarkvm-parameters" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a0e7c49fffc7e93221cd004b382fb4bd75aafbeee3fd8dcd12fc035dcce3a70" +checksum = "f9c24c034456998548d9ed7162ab6ac038da94da25a2cbb6b03e1ca7ccdcd539" dependencies = [ "aleo-std", "anyhow", @@ -3088,9 +3089,9 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c19ea8f5292190eb55e423a63b19ca2430adde3ad2e0d5a3b4a6033c7d9104" +checksum = "4191f5cb9f9793c3db3facb6085dbedec256f42968c1ade954093ce37d5ece3a" dependencies = [ "aleo-std", "anyhow", @@ -3124,9 +3125,9 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer-error" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a104d87ec01afbc9fa1bcf0425fd3eb31f9038dc81c14ccdee2884fe33f0598" +checksum = "ceb78a68ff721f4ffb356773fc6328d5ddda18348f7d898be9b1013391cf4fcd" dependencies = [ "anyhow", "snarkvm-circuit-environment", @@ -3137,9 +3138,9 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer-process" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "914bc444d4504e7e624a3e13e0e01252c7a1e9788a3d7f81515e3bf5b97c82d9" +checksum = "aecda5ba69f111a3bdc3f7026d49fd22e8f9d6f72bf82bc5f62e052fe5d61fe1" dependencies = [ "aleo-std", "colored", @@ -3164,9 +3165,9 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer-program" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed284751e7beb0561e4f28fd10ef3ebe240536aedbd298b8b054b482c4f2c4e" +checksum = "a29ef687bf561b749b2da424a5270f9856c5c006602d9aad3e725e90433919b7" dependencies = [ "enum-iterator", "indexmap", @@ -3186,9 +3187,9 @@ dependencies = [ [[package]] name = "snarkvm-synthesizer-snark" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0749c669932ee44da7e047989085827825186f55826821f0015d322a91f3bbd2" +checksum = "261777f56ef81a80ce076ccccd3f376cb0bbb4292e8a075951b0ab39ff7a8e0c" dependencies = [ "bincode", "serde_json", @@ -3200,9 +3201,9 @@ dependencies = [ [[package]] name = "snarkvm-utilities" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b46d0bce6f3cf66e67fa50101568957f3f0b18b962ae2afbb9dff08799a8a4" +checksum = "712436dc79f5345d0c09795da64fe9e680b18976ba137563e46f3e8986accd04" dependencies = [ "aleo-std", "anyhow", @@ -3224,9 +3225,9 @@ dependencies = [ [[package]] name = "snarkvm-utilities-derives" -version = "4.9.1" +version = "4.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b28d07a68a84b654ca1bf06b1203c05a4a04672ca97247fe46d5aeb2d4ae507" +checksum = "e2318c1bc848919dc803e9aa22b0737e7671a82ef070ce12d29b49ff1b14958b" dependencies = [ "proc-macro2", "quote 1.0.46", diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index b03380b..77c9e77 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -29,7 +29,7 @@ serde = "1" serde_json = "1" sha2 = "0.10" -snarkvm = { version = "4.9.1", default-features = false, features = [ +snarkvm = { version = "4.10.0", default-features = false, features = [ "console", "circuit", "synthesizer", "ledger", "utilities", "algorithms", "parameters", ] } diff --git a/sdk/Readme.md b/sdk/Readme.md index 4f73e1c..ed467f3 100644 --- a/sdk/Readme.md +++ b/sdk/Readme.md @@ -1,6 +1,6 @@ # Aleo Python SDK (MainnetV0) -The Aleo Python SDK provides Python bindings to Aleo's zero-knowledge cryptographic primitives, built with snarkvm 4.9.1. +The Aleo Python SDK provides Python bindings to Aleo's zero-knowledge cryptographic primitives, built with snarkvm 4.10.0. It ships two layers: From 2adfc8a50d032eb60232a6f8a51f9bcd4d030f89 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 25 Sep 2026 15:25:51 -0400 Subject: [PATCH 12/13] [Fix] Preserve swap recovery data and wait for scanner records --- shield-swap-sdk/AGENTS.md | 5 +- shield-swap-sdk/examples/first-swap/README.md | 37 +++++++--- shield-swap-sdk/examples/first-swap/swap.py | 12 +++- .../python/aleo_shield_swap/AGENTS.md | 5 +- .../python/aleo_shield_swap/_calls.py | 14 +++- .../python/aleo_shield_swap/_core.py | 29 +++++--- .../python/aleo_shield_swap/async_client.py | 42 +++++++---- .../python/aleo_shield_swap/client.py | 30 ++++---- shield-swap-sdk/tests/test_async_client.py | 27 ++++++++ .../tests/test_first_swap_account.py | 38 ++++++++++ shield-swap-sdk/tests/test_swap.py | 69 +++++++++++++++++++ 11 files changed, 250 insertions(+), 58 deletions(-) create mode 100644 shield-swap-sdk/tests/test_first_swap_account.py diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index e068389..c896bd6 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -405,7 +405,7 @@ exhausting it raises — the fail-fast for a systematically wrong program. ### Chain methods -### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int | str | Decimal', slippage_bps: 'int' = 50, expected_out: 'Optional[int | str | Decimal]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` +### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int | str | Decimal', slippage_bps: 'int' = 50, expected_out: 'Optional[int | str | Decimal]' = 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, record_wait_seconds: 'float' = 0.0, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` Prepare one private swap; submit with ``transact()`` or ``delegate()``. @@ -418,7 +418,8 @@ Returned handle amounts remain in base units. Quote with ``api.get_route`` and pass ``expected_out``. Without a quote, the spot estimate ignores fees and price impact. Wrapped inputs use underlying token records and route through the swap router automatically. -The SDK selects a covering record unless ``token_record`` is supplied. +``record_wait_seconds`` waits for a covering record (default 0); +``token_record`` bypasses scanning. Provider errors propagate. Preparing a call reserves a blinding counter when a journal is attached, even if the call is discarded or only simulated. The journal retains the diff --git a/shield-swap-sdk/examples/first-swap/README.md b/shield-swap-sdk/examples/first-swap/README.md index 68c3d94..96210de 100644 --- a/shield-swap-sdk/examples/first-swap/README.md +++ b/shield-swap-sdk/examples/first-swap/README.md @@ -2,7 +2,7 @@ Create a testnet account, request tokens, trade 1.5 USDCx for ETH, and claim the output. [swap.py](./swap.py) calls the Python SDK directly and uses its -in-memory account and swap handle. +account profile and swap handle. ## Run @@ -25,7 +25,9 @@ python -m aleo_shield_swap.examples.first_swap.swap ## Account and funding `SHIELD_SWAP_PRIVATE_KEY` optionally supplies an existing testnet account. -Otherwise the example generates a private key in memory. `ShieldSwap(aleo)` +Otherwise `Profile.load_or_create()` saves a generated key in the SDK’s default +profile (`~/.shield-swap/profile.json`, owner-only) and reuses it on later runs. +An existing profile must use testnet. `ShieldSwap(aleo)` uses the configured Aleo client. Both journal settings use the same account and register it with the record scanner. The first `from_private_key()` call sets the default account; later imports preserve an existing default. @@ -48,21 +50,24 @@ is needed in the example. Strings and `Decimal` values represent token units; integers retain their existing base-unit meaning. Excess precision is rejected. The SDK selects a token record and returns the handle needed to claim. -One unspent record must cover 1.5 USDCx. If the scanner has not indexed a -covering record yet, preparation raises `InsufficientRecordsError` before -proving or submitting a swap. +One unspent record must cover 1.5 USDCx. The example sets `record_wait_seconds=120` to let the SDK poll for a covering +record every five seconds. If none appears within two minutes, preparation +raises `InsufficientRecordsError` before proving or submitting a swap. Scanner +errors propagate immediately. Other callers default to no wait. ## Completion and recovery After the swap confirms, `claim_swap_output(handle).delegate(wait=True)` submits one claim and waits for confirmation. `claim.transaction_id` identifies the claim and `claim.amount_out` contains the received ETH in base units. -The example writes no console logs. Local account storage is disabled by default. +The example writes no console logs. Generated accounts are saved regardless +of the journal setting. -With journaling disabled, the private key and swap handle remain in memory. **Retain both before ending -an interrupted session.** The handle contains the blinding information needed -to claim; `handle.to_json()` serializes it. Neither value is saved automatically. -A newly generated key is lost when the process exits unless retained separately. +With journaling disabled, the swap handle remains in memory. Retain it with +`handle.to_json()` for later claims. The handle contains the blinding information +needed to claim. Enable the journal to retain that information if confirmation +times out or the process exits before the handle returns. An account supplied +through `SHIELD_SWAP_PRIVATE_KEY` remains the caller’s responsibility. Each run submits a new trade. To resume a pending claim, recreate the client with the same private key, restore the retained handle with @@ -75,6 +80,16 @@ handles there, and the example records the confirmed claim. Keep the file private: it contains the blinding information needed to claim. The flag only controls journal attachment. It does not change the account, -client, network, or record scanning, and it does not save the private key. +client, network, record scanning, or profile persistence. Use the same private key and journal to recover a pending swap. With `ENABLE_JOURNAL = False` (the default), no journal is created. + +The journal saves a swap handle as soon as delegated submission returns, before +waiting for confirmation. If the service returns only a transaction ID, the +first entry contains that ID and the claim secrets; a later entry adds the +swap ID after confirmation. If confirmation times out, retain the journal and +inspect the transaction instead of submitting again. An entry without a swap +ID is excluded from `pending_claims()` until recovered: read the confirmed +transaction’s swap transition, copy its public swap ID into the saved handle, +and record the completed handle with `Journal.record_swap(handle, counter)` +using the original entry’s counter. Confirm success before claiming. diff --git a/shield-swap-sdk/examples/first-swap/swap.py b/shield-swap-sdk/examples/first-swap/swap.py index ba10297..6676fec 100644 --- a/shield-swap-sdk/examples/first-swap/swap.py +++ b/shield-swap-sdk/examples/first-swap/swap.py @@ -3,16 +3,21 @@ from aleo import Aleo, HTTPProvider, testnet -from aleo_shield_swap import Journal, ShieldSwap +from aleo_shield_swap import Journal, Profile, ShieldSwap ENABLE_JOURNAL = False if __name__ == "__main__": - # Create an in-memory account or use an existing private key. + # Load a saved account or use an existing private key. key = os.environ.get("SHIELD_SWAP_PRIVATE_KEY") - private_key = testnet.PrivateKey.from_string(key) if key else testnet.PrivateKey.random() + if not key: + profile = Profile.load_or_create(network="testnet") + if profile.network != "testnet": + raise RuntimeError("This example requires a testnet profile") + key = profile.private_key + private_key = testnet.PrivateKey.from_string(key) aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="testnet")) account = aleo.account.from_private_key(private_key) aleo.records.register(account) @@ -50,6 +55,7 @@ amount_in=amount_in, expected_out=quote.estimated_amount_out, slippage_bps=50, + record_wait_seconds=120, ).delegate(wait=True) # Claim the confirmed swap's output once using its returned handle. diff --git a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md index e068389..c896bd6 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md +++ b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md @@ -405,7 +405,7 @@ exhausting it raises — the fail-fast for a systematically wrong program. ### Chain methods -### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int | str | Decimal', slippage_bps: 'int' = 50, expected_out: 'Optional[int | str | Decimal]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` +### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int | str | Decimal', slippage_bps: 'int' = 50, expected_out: 'Optional[int | str | Decimal]' = 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, record_wait_seconds: 'float' = 0.0, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'` Prepare one private swap; submit with ``transact()`` or ``delegate()``. @@ -418,7 +418,8 @@ Returned handle amounts remain in base units. Quote with ``api.get_route`` and pass ``expected_out``. Without a quote, the spot estimate ignores fees and price impact. Wrapped inputs use underlying token records and route through the swap router automatically. -The SDK selects a covering record unless ``token_record`` is supplied. +``record_wait_seconds`` waits for a covering record (default 0); +``token_record`` bypasses scanning. Provider errors propagate. Preparing a call reserves a blinding counter when a journal is attached, even if the call is discarded or only simulated. The journal retains the diff --git a/shield-swap-sdk/python/aleo_shield_swap/_calls.py b/shield-swap-sdk/python/aleo_shield_swap/_calls.py index 39c4835..89bbb6a 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_calls.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_calls.py @@ -74,10 +74,12 @@ class DexCall(Generic[R]): -> R`` turns the submitted transaction into the method's typed result.""" def __init__(self, aleo: Any, bound: Any, - build_result: Callable[[str, list[str]], R]) -> None: + build_result: Callable[[str, list[str]], R], *, + build_before_wait: bool = False) -> None: self._aleo = aleo self._bound = bound self._build = build_result + self._build_before_wait = build_before_wait def __repr__(self) -> str: return f"DexCall({self._bound!r})" @@ -113,6 +115,16 @@ def delegate(self, account: Any = None, *, wait: bool = True, payload = self._bound.delegate(account, **fee_kwargs) tx_id = extract_tx_id(payload) decoded = _payload_transitions(payload) + # Swap builders retain claim secrets before any confirmation I/O. An + # ID-only response retains a provisional handle, completed after lookup. + if self._build_before_wait: + outputs = root_outputs(decoded or [], self._bound.program_id, + self._bound.function_name) + result = self._build(tx_id, outputs) + if decoded is not None: + if wait: + self._aleo.network.wait_for_transaction(tx_id, timeout=wait_timeout) + return result if decoded is None: self._aleo.network.wait_for_transaction(tx_id, timeout=wait_timeout) tx = self._aleo.network.get_transaction_object(tx_id) diff --git a/shield-swap-sdk/python/aleo_shield_swap/_core.py b/shield-swap-sdk/python/aleo_shield_swap/_core.py index 9b5c94a..7caed46 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/_core.py +++ b/shield-swap-sdk/python/aleo_shield_swap/_core.py @@ -9,6 +9,8 @@ from __future__ import annotations import re +import math +import time import secrets from dataclasses import dataclass from decimal import Decimal, InvalidOperation @@ -427,6 +429,7 @@ def select_token_record( min_amount: int, token_id: Optional[str] = None, account: Any = None, + wait_seconds: float = 0.0, ) -> str: """One unspent record plaintext from *program* covering *min_amount*. @@ -435,17 +438,21 @@ def select_token_record( filters registry-style records; wrapper-program records carry no ``token_id`` and match any. """ + if not math.isfinite(wait_seconds) or wait_seconds < 0: + raise ValueError("record_wait_seconds must be finite and nonnegative") provider = aleo.record_provider if provider is None: raise InsufficientRecordsError( - "No record provider configured (aleo.record_provider is None) — " - "pass token_record= explicitly or configure a scanner." - ) - records = provider.find(account, program=program, unspent=True) - chosen = pick_covering_record(records, min_amount=min_amount, token_id=token_id) - if chosen is None: - raise InsufficientRecordsError( - f"No unspent {program} record covers {min_amount} " - f"(token_id={token_id or 'any'}) — privatize funds or pass token_record=." - ) - return chosen + "No record provider configured — pass token_record= explicitly.") + deadline = time.monotonic() + wait_seconds + while True: + records = provider.find(account, program=program, unspent=True) + chosen = pick_covering_record(records, min_amount=min_amount, token_id=token_id) + if chosen is not None: + return chosen + remaining = deadline - time.monotonic() + if remaining <= 0: + raise InsufficientRecordsError( + f"No unspent {program} record covers {min_amount} " + f"(token_id={token_id or 'any'}) — privatize funds or pass token_record=.") + time.sleep(min(5.0, remaining)) diff --git a/shield-swap-sdk/python/aleo_shield_swap/async_client.py b/shield-swap-sdk/python/aleo_shield_swap/async_client.py index 5b839ee..5838714 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/async_client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/async_client.py @@ -7,6 +7,9 @@ """ from __future__ import annotations +import asyncio +import math +import time import logging from decimal import Decimal from typing import Any, Callable, Generic, Optional, TypeVar @@ -278,18 +281,26 @@ async def is_used(counter: int) -> bool: return identities.at(counter) async def _select_token_record(self, *, program: str, min_amount: int, - token_id: Optional[str], account: Any) -> str: + token_id: Optional[str], account: Any, + wait_seconds: float = 0.0) -> str: + if not math.isfinite(wait_seconds) or wait_seconds < 0: + raise ValueError("record_wait_seconds must be finite and nonnegative") provider = self._aleo.record_provider if provider is None: raise InsufficientRecordsError( "No record provider configured — pass token_record= explicitly.") - records = await provider.find(account, program=program, unspent=True) - chosen = pick_covering_record(records, min_amount=min_amount, token_id=token_id) - if chosen is None: - raise InsufficientRecordsError( - f"No unspent {program} record covers {min_amount} " - f"(token_id={token_id or 'any'}) — privatize funds or pass token_record=.") - return chosen + deadline = time.monotonic() + wait_seconds + while True: + records = await provider.find(account, program=program, unspent=True) + chosen = pick_covering_record(records, min_amount=min_amount, token_id=token_id) + if chosen is not None: + return chosen + remaining = deadline - time.monotonic() + if remaining <= 0: + raise InsufficientRecordsError( + f"No unspent {program} record covers {min_amount} " + f"(token_id={token_id or 'any'}) — privatize funds or pass token_record=.") + await asyncio.sleep(min(5.0, remaining)) async def _select_position_record(self, pool_key: str, account: Any, position_token_id: Optional[str] = None) -> str: @@ -592,6 +603,7 @@ async def swap(self, *, pool_key: str, token_in_id: str, amount_in: int | str | nonce: Optional[int] = None, token_in_program: Optional[str] = None, token_record: Optional[str] = None, + record_wait_seconds: float = 0.0, identity: Optional[BlindedIdentity] = None, wrapper_proofs: Optional[str] = None, imports: Optional[dict[str, str]] = None, @@ -613,6 +625,8 @@ async def swap(self, *, pool_key: str, token_in_id: str, amount_in: int | str | tokens), converted using registry decimals. Floats, excess precision, non-finite values, and amounts outside u128 are rejected before proving. Returned handle amounts remain in base units. + ``record_wait_seconds`` waits for a covering record (default 0); + ``token_record`` bypasses scanning. Provider errors propagate. Quote first (``dex.api.get_route``) and pass *expected_out*: without it a spot estimate is used, which ignores fees and price impact. @@ -640,21 +654,21 @@ async def swap(self, *, pool_key: str, token_in_id: str, amount_in: int | str | pool=pool, slot=slot, token_in_id=token_in_id, amount_in=amount_in, slippage_bps=slippage_bps, expected_out=expected_out, sqrt_price_limit=sqrt_price_limit) - deadline = int(await self._aleo.network.get_latest_height()) + deadline_offset_blocks - swap_nonce = nonce if nonce is not None else generate_swap_nonce() - if identity is None: - identity = await self._next_blinded_identity(acct) - record = token_record if record is None: program = token_in_program or await self._token_program(token_in_id) record = await self._select_token_record( program=program, min_amount=amount_in, - token_id=token_in_id, account=acct) + token_id=token_in_id, account=acct, wait_seconds=record_wait_seconds) token_programs = [program] else: token_programs = [token_in_program] if token_in_program else [] + deadline = int(await self._aleo.network.get_latest_height()) + deadline_offset_blocks + swap_nonce = nonce if nonce is not None else generate_swap_nonce() + if identity is None: + identity = await self._next_blinded_identity(acct) + route = swap_route(await self._is_wrapped(token_in_id)) if route.program != self.program: token_programs.append(route.program) diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index acdfe69..16026ba 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -772,6 +772,7 @@ def swap( identity: Optional[BlindedIdentity] = None, token_in_program: Optional[str] = None, token_record: Optional[str] = None, + record_wait_seconds: float = 0.0, wrapper_proofs: Optional[str] = None, track: bool = True, imports: Optional[dict[str, str]] = None, @@ -788,7 +789,8 @@ def swap( Quote with ``api.get_route`` and pass ``expected_out``. Without a quote, the spot estimate ignores fees and price impact. Wrapped inputs use underlying token records and route through the swap router automatically. - The SDK selects a covering record unless ``token_record`` is supplied. + ``record_wait_seconds`` waits for a covering record (default 0); + ``token_record`` bypasses scanning. Provider errors propagate. Preparing a call reserves a blinding counter when a journal is attached, even if the call is discarded or only simulated. The journal retains the @@ -815,6 +817,18 @@ def swap( slippage_bps=slippage_bps, expected_out=expected_out, sqrt_price_limit=sqrt_price_limit, ) + # Resolve the record-funding program lazily: an explicit record + # needs no registry lookup (its program registration comes from + # token_in_program= or imports=). + program = token_in_program + record = token_record + if record is None: + program = program or self._token_program(token_in_id) + record = select_token_record( + self._aleo, program=program, min_amount=amount_in, + token_id=token_in_id, account=acct, wait_seconds=record_wait_seconds, + ) + deadline = get_deadline(self._aleo, deadline_offset_blocks) swap_nonce = nonce if nonce is not None else generate_swap_nonce() # With a journal, reserve through it: reservation is serialized by the @@ -830,18 +844,6 @@ def swap( elif identity is None: identity = next_blinded_identity(self._aleo, acct, self.program) - # Resolve the record-funding program lazily: an explicit record - # needs no registry lookup (its program registration comes from - # token_in_program= or imports=). - program = token_in_program - record = token_record - if record is None: - program = program or self._token_program(token_in_id) - record = select_token_record( - self._aleo, program=program, min_amount=amount_in, - token_id=token_in_id, account=acct, - ) - route = swap_route(self._is_wrapped(token_in_id)) # Dynamic dispatch: the prover cannot discover token callees # statically — register the DEX program, the involved token @@ -910,7 +912,7 @@ def build_result(tx_id: str, outputs: list[Any]) -> SwapHandle: self.journal.record_swap(handle, counter) return handle - return DexCall(self._aleo, bound, build_result) + return DexCall(self._aleo, bound, build_result, build_before_wait=True) def claim_swap_output( self, diff --git a/shield-swap-sdk/tests/test_async_client.py b/shield-swap-sdk/tests/test_async_client.py index c3d7ce1..2d5a912 100644 --- a/shield-swap-sdk/tests/test_async_client.py +++ b/shield-swap-sdk/tests/test_async_client.py @@ -384,3 +384,30 @@ async def test_async_swap_converts_token_units(astub): assert astub.last_call[1][5:7] == ["1500000u128", "49750u128"] handle = await call.transact() assert handle.amount_in == 1500000 + + +async def test_async_swap_waits_for_scanner(astub, monkeypatch): + from unittest.mock import AsyncMock + astub.record_provider.find = AsyncMock(side_effect=[[], [{"record_plaintext": RECORD_TEXT}]]) + sleep = AsyncMock() + monkeypatch.setattr("aleo_shield_swap.async_client.asyncio.sleep", sleep) + dex = AsyncShieldSwap(astub) + await dex.swap(pool_key="5field", token_in_id="1field", amount_in=10**9, + token_in_program="tok.aleo", expected_out=1_000_000, + record_wait_seconds=10) + assert astub.record_provider.find.call_count == 2 + sleep.assert_awaited_once() + assert astub.submitted == [] + + +async def test_async_record_wait_times_out_without_submission(astub, monkeypatch): + from unittest.mock import AsyncMock, Mock + from aleo_shield_swap.errors import InsufficientRecordsError + astub.record_provider.find = AsyncMock(return_value=[]) + monkeypatch.setattr("aleo_shield_swap.async_client.time", Mock(monotonic=Mock(side_effect=[0, 10]))) + dex = AsyncShieldSwap(astub) + with pytest.raises(InsufficientRecordsError): + await dex.swap(pool_key="5field", token_in_id="1field", amount_in=10**9, + token_in_program="tok.aleo", expected_out=1_000_000, + record_wait_seconds=10) + assert astub.submitted == [] diff --git a/shield-swap-sdk/tests/test_first_swap_account.py b/shield-swap-sdk/tests/test_first_swap_account.py new file mode 100644 index 0000000..e1d1cc6 --- /dev/null +++ b/shield-swap-sdk/tests/test_first_swap_account.py @@ -0,0 +1,38 @@ +"""The runnable example saves generated keys before scanner or faucet I/O.""" +import json +import runpy +from pathlib import Path +from unittest.mock import Mock + +import pytest +from aleo import testnet +from aleo_shield_swap import Profile + +EXAMPLE = Path(__file__).parents[1] / "examples/first-swap/swap.py" + + +def test_example_reuses_persisted_key_before_network(tmp_path, monkeypatch): + monkeypatch.delenv("SHIELD_SWAP_PRIVATE_KEY", raising=False) + monkeypatch.delenv("SHIELD_SWAP_PRIVATE_KEY_FILE", raising=False) + monkeypatch.setattr(Profile, "default_home", staticmethod(lambda: tmp_path)) + # Stop at client construction, before registration/authentication/funding. + monkeypatch.setattr("aleo.Aleo", Mock(side_effect=RuntimeError("stop before I/O"))) + for _ in range(2): + with pytest.raises(RuntimeError, match="stop before I/O"): + runpy.run_path(str(EXAMPLE), run_name="__main__") + saved = json.loads((tmp_path / "profile.json").read_text()) + if _ == 0: + first_key = saved["private_key"] + assert saved["private_key"] == first_key + assert saved["network"] == "testnet" + assert (tmp_path / "profile.json").stat().st_mode & 0o777 == 0o600 + + +def test_example_uses_explicit_key_without_loading_profile(monkeypatch): + monkeypatch.setenv("SHIELD_SWAP_PRIVATE_KEY", str(testnet.PrivateKey.random())) + load = Mock(side_effect=AssertionError("must not load profile")) + monkeypatch.setattr(Profile, "load_or_create", load) + monkeypatch.setattr("aleo.Aleo", Mock(side_effect=RuntimeError("stop before I/O"))) + with pytest.raises(RuntimeError, match="stop before I/O"): + runpy.run_path(str(EXAMPLE), run_name="__main__") + load.assert_not_called() diff --git a/shield-swap-sdk/tests/test_swap.py b/shield-swap-sdk/tests/test_swap.py index 1d7f40e..2c19bb8 100644 --- a/shield-swap-sdk/tests/test_swap.py +++ b/shield-swap-sdk/tests/test_swap.py @@ -223,3 +223,72 @@ def test_track_false_gives_a_side_effect_free_build(tmp_path, stub_aleo): _swap_call_on(dex, track=False) assert dex.journal.counter_cursor() == 0 assert dex.journal.events() == [] + + +@pytest.mark.parametrize("full_payload", [False, True]) +def test_confirmation_timeout_preserves_swap_secrets(tmp_path, stub_aleo, full_payload): + from unittest.mock import Mock + dex = _journalled_dex(tmp_path, stub_aleo) + call = _swap_call_on(dex) + if full_payload: + call._bound.delegate = Mock(return_value={"transaction": { + "id": "at1delegated", "execution": {"transitions": [{ + "program": call._bound.program_id, + "function": call._bound.function_name, + "outputs": [{"value": "77field"}], + }]}}}) + stub_aleo.network.wait_for_transaction = Mock(side_effect=TimeoutError("pending")) + with pytest.raises(TimeoutError): + call.delegate(wait=True) + saved = [e for e in dex.journal.events() if e["type"] == "swap"] + assert len(saved) == 1 + assert saved[0]["transaction_id"] == "at1delegated" + assert saved[0]["blinding_factor"] == BLINDING_FACTOR_0 + assert saved[0]["blinded_address"] == BLINDED_ADDRESS_0 + assert saved[0]["swap_id"] == ("77field" if full_payload else None) + + +def test_id_only_confirmation_completes_saved_handle(tmp_path, stub_aleo): + dex = _journalled_dex(tmp_path, stub_aleo) + handle = _swap_call_on(dex).delegate(wait=True) + assert dex.journal.pending_claims() == [handle] + + +def test_swap_waits_for_record_without_submitting(tmp_path, stub_aleo, monkeypatch): + from unittest.mock import Mock + dex = _journalled_dex(tmp_path, stub_aleo) + stub_aleo.record_provider.find = Mock(side_effect=[[], [{"record_plaintext": RECORD_TEXT}]]) + sleep = Mock() + monkeypatch.setattr("aleo_shield_swap._core.time.sleep", sleep) + _swap_call_on(dex, record_wait_seconds=10) + assert stub_aleo.record_provider.find.call_count == 2 + sleep.assert_called_once() + assert stub_aleo.submitted == [] + assert dex.journal.counter_cursor() == 1 + + +def test_record_wait_timeout_does_not_reserve_counter(tmp_path, stub_aleo, monkeypatch): + from unittest.mock import Mock + dex = _journalled_dex(tmp_path, stub_aleo) + stub_aleo.record_provider.find = Mock(return_value=[]) + monkeypatch.setattr("aleo_shield_swap._core.time.monotonic", Mock(side_effect=[0, 10])) + with pytest.raises(InsufficientRecordsError): + _swap_call_on(dex, record_wait_seconds=10) + assert dex.journal.events() == [] + assert stub_aleo.submitted == [] + + +def test_record_wait_propagates_scanner_error(tmp_path, stub_aleo): + from unittest.mock import Mock + dex = _journalled_dex(tmp_path, stub_aleo) + stub_aleo.record_provider.find = Mock(side_effect=RuntimeError("scanner unavailable")) + with pytest.raises(RuntimeError, match="scanner unavailable"): + _swap_call_on(dex, record_wait_seconds=10) + assert stub_aleo.record_provider.find.call_count == 1 + assert dex.journal.events() == [] + + +@pytest.mark.parametrize("seconds", [-1, float("inf"), float("nan")]) +def test_record_wait_rejects_invalid_duration(stub_aleo, seconds): + with pytest.raises(ValueError, match="record_wait_seconds"): + _swap_call(stub_aleo, record_wait_seconds=seconds) From fb6d6d4a298d8d65e1efacc54883e9f95ce45083 Mon Sep 17 00:00:00 2001 From: Michael Turner Date: Fri, 25 Sep 2026 15:51:23 -0400 Subject: [PATCH 13/13] [Fix] Re-register missing scanner accounts before retrying reads --- sdk/python/aleo/async_record_scanner.py | 4 +- sdk/python/aleo/facade/async_client.py | 1 + sdk/python/aleo/facade/records.py | 1 + sdk/python/aleo/record_scanner.py | 4 +- .../tests/test_scanner_reregistration.py | 71 +++++++++++++++++++ shield-swap-sdk/examples/first-swap/README.md | 3 + shield-swap-sdk/examples/first-swap/swap.py | 4 +- 7 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 sdk/python/tests/test_scanner_reregistration.py diff --git a/sdk/python/aleo/async_record_scanner.py b/sdk/python/aleo/async_record_scanner.py index c55c3f1..d5caa4c 100644 --- a/sdk/python/aleo/async_record_scanner.py +++ b/sdk/python/aleo/async_record_scanner.py @@ -430,7 +430,9 @@ async def owned(self, filter: OwnedFilter) -> dict[str, Any]: if resp.status_code == 422 and self.auto_re_register: vk = self._view_keys.get(resolved_uuid) if vk is not None: - await self.register_encrypted(vk, 0) + registration = await self.register_encrypted(vk, 0) + if not registration.get("ok"): + return registration resp = await self._send_authed( "POST", f"{self.url}/records/owned", content=body.encode() ) diff --git a/sdk/python/aleo/facade/async_client.py b/sdk/python/aleo/facade/async_client.py index fa90150..330bf4b 100644 --- a/sdk/python/aleo/facade/async_client.py +++ b/sdk/python/aleo/facade/async_client.py @@ -249,6 +249,7 @@ def _build_scanner(self) -> Any: return AsyncRecordScanner( base, network=provider.network, + auto_re_register=True, api_key=provider.api_key, consumer_id=getattr(provider, "consumer_id", None), transport=getattr(provider, "_transport", None), diff --git a/sdk/python/aleo/facade/records.py b/sdk/python/aleo/facade/records.py index 75a24da..ed0b8dd 100644 --- a/sdk/python/aleo/facade/records.py +++ b/sdk/python/aleo/facade/records.py @@ -95,6 +95,7 @@ def _build_scanner(self) -> Any: return RecordScanner( base, network=provider.network, + auto_re_register=True, api_key=provider.api_key if credentialed else None, consumer_id=getattr(provider, "consumer_id", None) if credentialed else None, transport=getattr(provider, "_transport", None), diff --git a/sdk/python/aleo/record_scanner.py b/sdk/python/aleo/record_scanner.py index 5cebcfd..2b2729e 100644 --- a/sdk/python/aleo/record_scanner.py +++ b/sdk/python/aleo/record_scanner.py @@ -469,7 +469,9 @@ def owned(self, filter: OwnedFilter) -> dict[str, Any]: # Re-register if we have a view key for this uuid vk = self._view_keys.get(resolved_uuid) if vk is not None: - self.register_encrypted(vk, 0) + registration = self.register_encrypted(vk, 0) + if not registration.get("ok"): + return registration resp = self._send_authed( "POST", f"{self.url}/records/owned", data=body ) diff --git a/sdk/python/tests/test_scanner_reregistration.py b/sdk/python/tests/test_scanner_reregistration.py new file mode 100644 index 0000000..e983946 --- /dev/null +++ b/sdk/python/tests/test_scanner_reregistration.py @@ -0,0 +1,71 @@ +"""Missing scanner registrations recover once, with failures preserved.""" +from unittest.mock import AsyncMock, Mock + +import pytest +from aleo import Aleo, AsyncAleo, HTTPProvider +from aleo.mainnet import PrivateKey +from aleo._scanner_common import compute_uuid + + +@pytest.mark.parametrize('client_type', [Aleo, AsyncAleo]) +def test_facade_enables_registration_recovery(client_type): + client = client_type(HTTPProvider('https://edge.provable.com/api')) + assert client.records.scanner.auto_re_register is True + + +@pytest.mark.parametrize('registration_ok', [True, False]) +def test_sync_recovery_preserves_registration_failure(registration_ok): + client = Aleo(HTTPProvider('https://edge.provable.com/api')) + scanner = client.records.scanner + account = client.account.from_private_key(PrivateKey.random()) + scanner.set_account(account) + scanner.auto_re_register = True + failure = {'ok': False, 'status': 503, 'error': {'message': 'registration unavailable', 'status': 503}} + scanner.register_encrypted = Mock(return_value={'ok': True} if registration_ok else failure) + scanner._send_authed = Mock(side_effect=[Mock(status_code=422), Mock(status_code=200, ok=True, json=lambda: [])]) + result = scanner.owned({'uuid': str(compute_uuid(account.view_key))}) + assert result == ({'ok': True, 'data': []} if registration_ok else failure) + scanner.register_encrypted.assert_called_once_with(account.view_key, 0) + assert scanner._send_authed.call_count == (2 if registration_ok else 1) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('registration_ok', [True, False]) +async def test_async_recovery_preserves_registration_failure(registration_ok): + client = AsyncAleo(HTTPProvider('https://edge.provable.com/api')) + scanner = client.records.scanner + account = client.account.from_private_key(PrivateKey.random()) + scanner.set_account(account) + scanner.auto_re_register = True + failure = {'ok': False, 'status': 503, 'error': {'message': 'registration unavailable', 'status': 503}} + scanner.register_encrypted = AsyncMock(return_value={'ok': True} if registration_ok else failure) + scanner._send_authed = AsyncMock(side_effect=[Mock(status_code=422), Mock(status_code=200, is_success=True, json=lambda: [])]) + result = await scanner.owned({'uuid': str(compute_uuid(account.view_key))}) + assert result == ({'ok': True, 'data': []} if registration_ok else failure) + scanner.register_encrypted.assert_awaited_once_with(account.view_key, 0) + assert scanner._send_authed.call_count == (2 if registration_ok else 1) + + +def test_sync_second_422_stops(): + client = Aleo(HTTPProvider('https://edge.provable.com/api')) + scanner = client.records.scanner + scanner.set_account(client.account.from_private_key(PrivateKey.random())) + scanner.register_encrypted = Mock(return_value={'ok': True}) + scanner._send_authed = Mock(return_value=Mock(status_code=422, ok=False, text='UUID not registered')) + result = scanner.owned({}) + assert result['status'] == 422 and result['ok'] is False + assert scanner._send_authed.call_count == 2 + scanner.register_encrypted.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_second_422_stops(): + client = AsyncAleo(HTTPProvider('https://edge.provable.com/api')) + scanner = client.records.scanner + scanner.set_account(client.account.from_private_key(PrivateKey.random())) + scanner.register_encrypted = AsyncMock(return_value={'ok': True}) + scanner._send_authed = AsyncMock(return_value=Mock(status_code=422, is_success=False, text='UUID not registered')) + result = await scanner.owned({}) + assert result['status'] == 422 and result['ok'] is False + assert scanner._send_authed.call_count == 2 + scanner.register_encrypted.assert_awaited_once() diff --git a/shield-swap-sdk/examples/first-swap/README.md b/shield-swap-sdk/examples/first-swap/README.md index 96210de..16029ad 100644 --- a/shield-swap-sdk/examples/first-swap/README.md +++ b/shield-swap-sdk/examples/first-swap/README.md @@ -31,6 +31,9 @@ An existing profile must use testnet. `ShieldSwap(aleo)` uses the configured Aleo client. Both journal settings use the same account and register it with the record scanner. The first `from_private_key()` call sets the default account; later imports preserve an existing default. +The example stops if initial scanner registration fails. If a later records +query returns HTTP 422, the SDK re-registers the account and retries that query +once. A failed re-registration surfaces its error. `dex.api.authenticate()` signs the API challenge with the account's key. `confirm_airdrop()` requests tokens and waits for the faucet job to settle. diff --git a/shield-swap-sdk/examples/first-swap/swap.py b/shield-swap-sdk/examples/first-swap/swap.py index 6676fec..9e2f57c 100644 --- a/shield-swap-sdk/examples/first-swap/swap.py +++ b/shield-swap-sdk/examples/first-swap/swap.py @@ -20,7 +20,9 @@ private_key = testnet.PrivateKey.from_string(key) aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="testnet")) account = aleo.account.from_private_key(private_key) - aleo.records.register(account) + registration = aleo.records.register(account) + if not registration["ok"]: + raise RuntimeError(f"Scanner registration failed: {registration['error']}") dex = ShieldSwap(aleo) address = str(account.address)