Metadata-Version: 2.5
Name: shield-swap-sdk
Version: 0.7.0
Summary: Python SDK for the shield swap AMM Dex on Aleo
Requires-Python: >=3.10
Requires-Dist: aleo-sdk>=0.7.0
Requires-Dist: requests>=2
Provides-Extra: async
Requires-Dist: httpx>=0.27; extra == 'async'
Provides-Extra: dev
Requires-Dist: datamodel-code-generator>=0.25; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Provides-Extra: mcp
Requires-Dist: mcp<2,>=1.0; extra == 'mcp'
Description-Content-Type: text/markdown

# shield-swap-sdk

Typed Python client for the **shield_swap** AMM on Aleo. Sits on top of the
Aleo Python SDK's facade (`aleo.Aleo`): signer, record provider, proving
configuration, and network all come from the client you bind — this package
adds the DEX methods, the typed results, and the off-chain DEX API, nothing
else.

```python
from aleo import Aleo
from aleo_shield_swap import ShieldSwap

aleo = Aleo(Aleo.HTTPProvider("https://edge.provable.com/api"))
aleo.default_account = account
dex = ShieldSwap(aleo)

pools = dex.api.get_pools()                      # requires a live DEX API
handle = dex.swap(pool_key=pools[0].key,
                  token_in_id=pools[0].token0,
                  amount_in=10**9).delegate()    # broadcasts; spends funds
out = dex.claim_swap_output(handle).delegate()   # broadcasts; spends funds
```

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`
address on the position NFT (defaults to the recipient) and `collect` always
pays it — decide the payout wallet at mint time. The off-chain API is
selected per network — `https://api.testnet.swap.shield.fi` for testnet,
`https://api.swap.shield.fi` for mainnet (override with `SHIELD_SWAP_API_URL`).

## Install

```bash
pip install -e shield-swap-sdk                 # from the repo root
pip install -e "shield-swap-sdk[async]"        # + AsyncShieldSwap (httpx)
pip install -e "shield-swap-sdk[mcp]"          # + the MCP server
```

Requires `aleo-sdk>=0.5.1` (this repo's SDK; imports as `aleo`) and Python 3.10+.

## First swap example

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` optionally attaches the SDK journal.
Releases containing the example support
`python -m aleo_shield_swap.examples.first_swap.swap`.

## Agents

`AGENTS.md` (generated from the SDK's docstrings — always current) is the
one page an agent needs: the five-method lifecycle, the conversation pattern,
and the building-block reference. It ships in the wheel:

```bash
pip install shield-swap-sdk
python -m aleo_shield_swap          # prints the agent guide
```

Claude Code users get it via the `shield-swap` skill (packaged under
`aleo_shield_swap/skills/`, copy into `.claude/skills/`); any MCP client
can run the same lifecycle through `python -m aleo_shield_swap.mcp`.

## How calls work

Every read returns a value immediately. Every write returns a prepared
`DexCall` — nothing touches the network until you invoke a terminal method:

```python
call = dex.swap(pool_key=key, token_in_id=token, amount_in=10**9)

call.simulate()             # runs locally; no broadcast, no fee
call.transact(account)      # proves locally, broadcasts, pays the fee
call.delegate(account)      # proves via the delegated proving service
```

`transact` and `delegate` return the method's *typed result* (a `SwapHandle`,
`MintResult`, `ClaimResult`, …) built from the transaction's root-transition
outputs — not a bare transaction id. Local proving downloads SNARK parameters
on first use and takes minutes for the larger entrypoints; `delegate` is the
practical path and requires DPS credentials on the provider
(`api_key=`, `network_client.consumer_id`).

Chain reads and writes live directly on `ShieldSwap`; the off-chain DEX API
is namespaced under `.api`, so a call site always shows whether a value came
from the chain or the service.

## The method surface

**Chain reads** (node REST API):

| Verb | Returns |
|---|---|
| `get_pool(pool_key)` | The pool struct from the `pools` mapping. |
| `get_slot(pool_key)` | `SlotView` — current tick, sqrt price, liquidity, spacing. |
| `get_swap_output(swap_id)` | The finalized swap outcome; raises `SwapOutputNotFinalizedError` until the finalize lands (and again after the claim consumes it). |
| `get_swap_execution(swap_id)` | The fill receipt — executed height and per-hop amounts, gross/protocol/LP fee, post-trade price. Survives the claim; `None` until finalized. |
| `get_pool_creator(pool_key)` | Who created the pool (`None` for pools that predate creator tracking). |
| `is_pool_initialized(pool_key)` | Whether the pool exists on chain. |
| `get_public_balances(programs)` | Each token program's on-chain `balances` mapping entry for an address (raw base units; absent reads as 0). |
| `get_private_balances(programs)` | Summed unspent record amounts per token program (needs a registered record provider). |
| `get_balances()` | Public + private balances in one shape, joined through the API's token registry. |
| `derive_pool_key(token0, token1, fee)` / `derive_tick_key(pool_key, tick)` | Mapping keys derived locally — no network. |

**Writes** (each returns a `DexCall`):

| Verb | What it does |
|---|---|
| `swap(...)` | Phase one of the two-transaction private swap: locks the input record against a blinded identity. Returns a `SwapHandle` — persist it if the process might die before the claim. |
| `claim_swap_output(handle)` | Phase two: claims the finalized output as a private record. |
| `create_pool(...)` | Initializes a pool for a token pair + fee tier. |
| `mint(...)` | Opens a position in a tick range; returns a `MintResult` with the position's token id. |
| `increase_liquidity(...)` / `decrease_liquidity(...)` | Resizes a position (spends the position NFT record and returns a fresh one). |
| `collect(...)` | Pays out `tokens_owed` as private records. |
| `burn(...)` | Closes an emptied position and removes it from the `positions` mapping. |
| `plan_rebalance(...)` / `rebalance_position(...)` | Close a position and mint its successor range in ONE transaction via `shield_swap_rebalance_router.aleo` (testnet). The plan quotes what comes back, what the new range needs, and funding vs refund per token; every amount is asserted at the execution price, so re-plan and resubmit if the pool moved. |

A swap whose input was fully consumed (`amount_remaining == 0`) claims
through the no-refund entrypoints (`claim_swap_output_no_refund` and the
router's `claim_to_*_no_refund`), which never mint a zero-value refund
record — `claim_swap_output` picks them automatically.

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.
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
(`find_tick_predecessor`) — pass `tick_*_hint=` only if you know better.
And when a pool side is **wrapped**, routed `mint`/`increase` amounts for
that side must be *exactly* what the range consumes (the router burns the
wrapper change record) — single-sided ranges make this deterministic;
in-range wrapped amounts depend on the live price.

**DEX API** (`dex.api`, standalone as `ApiClient`): `get_pools`,
`get_tokens`, `get_route`, `get_ohlcv`, `get_positions`, `get_unclaimed`,
`get_pool_stats_batch`, `get_liquidity_distribution`, the compliance reads
(`get_compliance`, `get_token_compliance`, `get_pair_compliance` — check
before spending on a write), cookie-session management (`get_session`,
`refresh_session`, `list_sessions`, `revoke_session`, `logout`,
`logout_all`, `get_ws_ticket`), and referral issuance (`referral_settings`,
`list_referral_codes`, `generate_referral_codes`).
Route quoting, OHLCV, and the account views are auth-gated — call
`api.authenticate(address, sign)` once (challenge/verify by signature, no
funds required; the session rides as httpOnly cookies + a CSRF header and
is short-lived — mint a durable `ss_…` token via `create_api_token` for
anything long-running). Authentication is the whole gate — no invite is
needed. A **referral code** is optional attribution: `redeem_code` credits
the friend who shared it (once per account), and `my_referral_code` returns
the code this account shares with others.

## Privacy

The swap flow never puts your address on chain next to the output. `swap`
derives a single-use **blinded identity** from the signer's view key
(`derive_blinding_factor` / `derive_blinded_address` are exported for
verification against the TS SDK's vectors); the claim proves knowledge of the
blinding factor instead of revealing the owner.

Two conveniences trade secret material for service:

- `delegate` sends the transaction *authorization* to the proving service —
  it can see the transaction's contents (not your private key). Prove
  locally with `transact` if that is unacceptable.
- The hosted record scanner behind `get_private_balances` /
  `aleo.records.register` shares the account's **view key** with the
  scanning service, which can then see everything the account owns.

## Async

`AsyncShieldSwap` / `AsyncApiClient` mirror the sync **read, swap and claim**
surface method-for-method on `aleo.AsyncAleo` (install the `[async]` extra).
The liquidity verbs (`create_pool`, `mint`, `increase_liquidity`,
`decrease_liquidity`, `collect`, `burn`, `rebalance_position`) and the
journal-bound helpers (`from_profile`, `onboard`, `status`, `swap_many`,
`collect_all`, `get_positions`) are sync-only for now — the exact list is
pinned by `tests/test_async_parity.py`:

```python
from aleo_shield_swap import AsyncShieldSwap

dex = AsyncShieldSwap(async_aleo)
handle = await (await dex.swap(pool_key=key, token_in_id=token,
                               amount_in=10**9)).delegate()
```

## Agent tools and MCP

`shield_swap_tools()` returns JSON-schema tool definitions for the whole method
surface; `dispatch_tool(dex, name, args)` executes one. For MCP hosts, the
`[mcp]` extra ships a stdio server over the same definitions:

```bash
ALEO_PRIVATE_KEY=APrivateKey1... python -m aleo_shield_swap.mcp
```

Omit `ALEO_PRIVATE_KEY` for a read-only server. See the module docstring for
the full environment (`ALEO_ENDPOINT`, `ALEO_NETWORK`, DPS credentials).

## Generated bindings

The contract surface is pinned, not hand-written: `codegen/` holds the
deployed program source, its ABI, and the DEX API's OpenAPI document.
`_generated.py` (program structs + entrypoints via `aleo.codegen`) and
`_api_models.py` (API response models) are built from those pins. When the
deployed contract or API drifts, rerun `codegen/regen-abi.sh` /
`codegen/regen-openapi.sh` and reconcile.

## Tests

```bash
python -m pytest                        # hermetic tier — no network
python -m pytest -m live               # read-only against the REAL testnet + DEX API
python -m pytest -m "live and slow"    # spends real testnet funds (DPS proving)
ALEO_DEVNODE_UNPROVEN=1 \
python -m pytest -m devnode            # full AMM lifecycle on a local aleo-devnode
```

The devnode tier deploys the vendored `shield_swap.aleo` stack and drives
pool creation, liquidity, swaps, and burn end-to-end, hermetically. It needs
the `aleo-devnode` binary (`ALEO_DEVNODE_BIN` or on `PATH`) and skips
otherwise. Deployments are proofless (dummy verifying keys — the devnode
skips certificate verification); `ALEO_DEVNODE_UNPROVEN=1` extends that to
executions and is the fast path (~5 minutes). Without it, executions are
fully proven locally: expect SNARK parameter downloads and key synthesis on
first use.

<a id="compliance"></a>

## Compliance

Swaps and liquidity actions prove in zero knowledge that the addresses taking
part are not on a freezelist. Two kinds of list apply. Shield Swap's own list
(`shield_swap_freezelist.aleo`) screens the signer when a swap output is
claimed, and the signer, the position's recipient, and its withdrawal address
when a position is minted, collected, or rebalanced. Each ARC-22 stablecoin
that trades through a wrapper (USDCx, USAD) screens the signer when its
records are wrapped and the receiving address when they are unwrapped, against
the stablecoin's own list.

The SDK builds these Merkle exclusion proofs locally and passes them as
private inputs of the transaction. The programs check the Merkle paths inside
the transaction proof, and their finalize compares each reconstructed root
with the root stored on chain. The network verifies that an address is absent
from a list without seeing the address or the paths.

Proof generation is on by default. Leave `compliance_proofs` unset and call
the methods as usual:

```python
dex.mint(pool_key=key, tick_lower=lo, tick_upper=hi,
         amount0_desired=a0, amount1_desired=a1)
dex.claim_swap_output(handle)
```

### Functions that take compliance proofs

Each bundle is one `[MerkleProof; 2]` input: two Merkle paths proving that one
address is absent from one list. The counts are input slots on the entrypoint
the call resolves to, including the placeholder slot of a wrapper that ignores
proofs (credits).

| Method | Type | Bundles | Subjects, in input order |
| --- | --- | --- | --- |
| `swap()` | `SwapProofs` | 0 to 1 | The signer, for a wrapped input token |
| `claim_swap_output()` | `ClaimProofs` | 1 to 3 | Shield Swap signer, then the wrapped output receiver and the wrapped refund receiver when there is a refund |
| `mint()` | `MintProofs` | 3 to 5 | Wrapped token senders, then Shield Swap signer, recipient, and withdrawal address |
| `increase_liquidity()` | `IncreaseProofs` | 0 to 2 | Wrapped token senders |
| `collect()` | `CollectProofs` | 2 to 4 | Shield Swap owner and withdrawal address, then wrapped token receivers |
| `rebalance_position()` | `RebalanceProofs` | 2 to 6 | Funded wrapped token senders, wrapped token receivers, then Shield Swap owner and withdrawal address |

Shield Swap slots use the AMM list. Wrapped-token slots use the underlying
stablecoin's list, found through that program's imports
(`test_usdcx_stablecoin.aleo` imports `test_usdcx_freezelist.aleo`). Senders
are the signer. Claim receivers are the signer too; collect and rebalance
receivers are the PositionNFT's withdrawal address. Within each group side 0
comes before side 1, a plain side has no slot, and a rebalance keeps its
receiver slots even when a side has no refund. A multi-hop swap adds no slot
for its intermediate hops. `decrease_liquidity()` and `burn()` take no proofs;
`swap_many()` and `collect_all()` always let the SDK build them.

### Automatic generation

Before each call the SDK works out the entrypoint, the slots, and the address
each slot screens, then reads each list's current root
(`freeze_list_root[1u8]`) once, so a mint's three Shield Swap bundles share
one root. When a root changed since the last call it downloads the published
tree from `GET /programs/{id}/compliance/freeze-list`, checks that the tree
ends in that root, builds each witness, and checks that the witness
reconstructs the root. Trees and witnesses stay in memory per client, list,
and root, so while a root is unchanged nothing beyond the root is read. An
empty list uses the empty-tree witness without a download.

A frozen address raises `FrozenAddressError`, and a missing root or a
published tree that does not match the root raises `FreezeListError`, in each
case before anything is proved or a fee is paid. A wrapped token whose
underlying program the registry cannot name raises `ValueError`; passing
`token0_program=` (or the matching argument) names it. The cache holds at
most 16 lists and 256 addresses per list and does not survive a restart.

`dex.freezelist` is the resolver, an `aleo.FreezeListProofs`:
`dex.freezelist.exclusion_proof(address, program)` returns one bundle for any
address and list, `dex.freezelist.resolve(contexts)` fills a list of
`ComplianceProofContext` values, and `dex.freezelist.clear()` drops the
cached trees. `dex.amm_freezelist_program` names the AMM list for a
non-standard deployment.

### Optional proof inputs

`compliance_proofs` takes a `ComplianceProofs` value: the method's tuple of
bundles in input order, or `None`. Each bundle is an encoded `[MerkleProof; 2]`
string, a `MerkleProofPair`, or a `(MerkleProofInput, MerkleProofInput)` tuple;
`MerkleProofInput` takes siblings as ints or `"...field"` literals. The count
is checked against the route the call resolves to before anything runs, and a
mismatch raises `ValueError` naming the slots the route expects. The same
bundle may fill several slots when the list and the address match.

`wrapper_proofs=`, the earlier single-string argument, still fills every
wrapped-token slot while the Shield Swap slots are built by the SDK. It cannot
be combined with `compliance_proofs`. A supplied bundle bypasses the root read
and the witness check, so its freshness is the caller's responsibility.

```python
from aleo_shield_swap import MerkleProofInput, MerkleProofPair

dex.swap(pool_key=key, token_in_id=usdcx, amount_in=10**6,
         compliance_proofs=(sender_bundle,))                      # one wrapped input
dex.mint(pool_key=key, tick_lower=lo, tick_upper=hi,
         amount0_desired=a0, amount1_desired=a1,
         compliance_proofs=(signer_bundle, recipient_bundle, withdrawal_bundle))   # plain pool
pair = MerkleProofPair(MerkleProofInput(siblings=(0,) * 16, leaf_index=1),
                       MerkleProofInput(siblings=("0field",) * 16, leaf_index="1u32"))
dex.collect(pool_key=key, amount0_requested=x, amount1_requested=y,
            compliance_proofs=(pair, pair, usdcx_receiver_bundle))  # token0 wrapped
```
