diff --git a/shield-swap-sdk/AGENTS.md b/shield-swap-sdk/AGENTS.md index 3d43c60..ada946a 100644 --- a/shield-swap-sdk/AGENTS.md +++ b/shield-swap-sdk/AGENTS.md @@ -212,12 +212,16 @@ them): A swap with nothing left over claims through the no-refund entrypoints automatically; `get_swap_execution` reads the per-hop fill receipt (fees paid, price after) at any later time. -- **Rebalancing is one transaction, testnet only for now** — - `plan_rebalance` quotes the close-and-remint (what comes back, what the - new range needs, funding vs refund per token) and `rebalance_position` - submits it through `shield_swap_rebalance_router.aleo`. Every amount is - asserted at the execution price: a trade in between reverts the whole - transaction (fee paid, no funds moved) — re-plan and resubmit. +- **Rebalancing replaces the complete position in one transaction** — + choose `plan_rebalance_exact_amounts` for exact quotes, + `plan_rebalance_to_liquidity` for exact liquidity with actual recovery, or + `plan_rebalance_from_amounts` for execution-time sizing from token caps. + Review the plan, then pass `plan=` to the matching `rebalance_position_*` + method. Flexible funding/refunds are fixed; surplus stays collectible on + the successor. `estimated_liquidity` is advisory, and token minimums do not + guarantee minimum liquidity. The router is supported on testnet; flexible + calls require an upgraded contract. A rejection rolls back funds movements + but can still cost a fee — refresh state before retrying. - **Concurrency needs partitioned blinded-identity counters AND disjoint input records** — `swap_many` implements the recipe; copy it, don't improvise. @@ -499,52 +503,40 @@ them, or pass 2–4 bundles (owner, withdrawal, then a receiver per wrapped side Burn an empty position NFT. -### `plan_rebalance(self, *, pool_key: 'str', position_token_id: 'str', tick_lower: 'int', tick_upper: 'int', liquidity_target: 'Optional[int]' = None, max_funding0: 'Optional[int]' = None, max_funding1: 'Optional[int]' = None) -> 'RebalancePlan'` +### `plan_rebalance(self, *, pool_key: 'str', position_token_id: 'str', tick_lower: 'int', tick_upper: 'int', liquidity_target: 'Optional[int]' = None, max_funding0: 'Optional[int]' = None, max_funding1: 'Optional[int]' = None, amount0_desired: 'Optional[int]' = None, amount1_desired: 'Optional[int]' = None, amount0_min: 'Optional[int]' = None, amount1_min: 'Optional[int]' = None, funded0: 'Optional[int]' = None, funded1: 'Optional[int]' = None, refund0: 'Optional[int]' = None, refund1: 'Optional[int]' = None) -> 'RebalancePlan'` -Quote a close-and-remint of one position into a new range. Reads only. +Plan a complete position replacement without signing or submitting. -Reads the pool, slot, ``positions`` entry, both current boundary ticks, -and each side's wrapped-ness, then derives what the close returns, what -the successor range needs, and per side the funding to add or surplus -to refund. Size the successor with exactly one of *liquidity_target* -(exact) or *max_funding0*/*max_funding1* (a budget the planner solves -for; ``0, 0`` rebalances on recovered funds alone). +A positive target with omitted bounds preserves exact quoted recovery +and deposits. ``max_funding0/1`` also produce an exact quote sized at +the current price. Explicit ``amount0/1_desired`` cap token consumption; +``amount0/1_min`` default to the caps. Widen either bound to use actual +recovery, or set ``liquidity_target=0`` to size liquidity at execution. +Flexible funding and refunds are fixed amounts, defaulting to zero; +surplus remains collectible on the successor and earns no LP fees. -The plan is only valid at the pool price it was built against — the -contract asserts every amount at finalize — so build it right before -:meth:`rebalance_position` and never cache one. +Amounts are integer token base units, from 0 through 2**128 - 1. +``estimated_liquidity`` is advisory; token floors do not guarantee a +minimum liquidity. Named planners supply mode-specific defaults. +Requires an existing pool, position, and initialized boundary ticks. -Raises: - PoolNotFoundError / PoolNotInitializedError: For an unknown pool. - ValueError: If the position or a boundary tick does not exist, the - aligned range is empty, the sizing is ambiguous, or the budget - supports no liquidity. - -### `rebalance_position(self, *, plan: 'Optional[RebalancePlan]' = None, pool_key: 'Optional[str]' = None, position_token_id: 'Optional[str]' = None, tick_lower: 'Optional[int]' = None, tick_upper: 'Optional[int]' = None, liquidity_target: 'Optional[int]' = None, max_funding0: 'Optional[int]' = None, max_funding1: 'Optional[int]' = None, position_record: 'Optional[str]' = None, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 20, nonce: 'Optional[str]' = None, compliance_proofs: 'Optional[RebalanceProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[RebalanceResult]'` - -Close a position and mint its successor range in ONE transaction. - -Burns the old position, settles its principal and every fee it earned, -adds funding from the signer's records where the new range needs more, -mints the successor with the same owner and withdrawal address, and -pays any surplus to the withdrawal address — atomically, through -``shield_swap_rebalance_router.aleo`` (deployed on testnet). Either -pass a *plan* from :meth:`plan_rebalance` (submitted verbatim), or the -pool, range, and one sizing mode and the plan is built here. - -Every amount is asserted against the pool price at execution: a trade -that moves the price between planning and finalize reverts the whole -transaction (fee paid, no funds moved). Rebuild and resubmit when -that happens; the short default deadline fails stale requests cheaply. -Funding records for a wrapped side are the UNDERLYING asset's records. -Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds -them, or pass 2–6 bundles (funded wrapped senders, wrapped receivers, -then owner and withdrawal). +### `rebalance_position(self, *, plan: 'Optional[RebalancePlan]' = None, pool_key: 'Optional[str]' = None, position_token_id: 'Optional[str]' = None, tick_lower: 'Optional[int]' = None, tick_upper: 'Optional[int]' = None, liquidity_target: 'Optional[int]' = None, max_funding0: 'Optional[int]' = None, max_funding1: 'Optional[int]' = None, amount0_desired: 'Optional[int]' = None, amount1_desired: 'Optional[int]' = None, amount0_min: 'Optional[int]' = None, amount1_min: 'Optional[int]' = None, funded0: 'Optional[int]' = None, funded1: 'Optional[int]' = None, refund0: 'Optional[int]' = None, refund1: 'Optional[int]' = None, position_record: 'Optional[str]' = None, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 20, nonce: 'Optional[str]' = None, compliance_proofs: 'Optional[RebalanceProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[RebalanceResult]'` -Raises: - ValueError: Without a plan or a complete (pool, range, sizing), or - with the wrong number of compliance proof bundles. - InsufficientRecordsError: If no record covers a funded side. +Prepare an atomic replacement in the same pool, keeping owner and withdrawal. + +Pass a reviewed ``plan`` verbatim, or pool/range/sizing arguments as in +``plan_rebalance``. A supplied plan cannot be combined with planning +overrides. Exact bounds enforce the quote; wider bounds use actual +recovery and a zero target sizes liquidity at execution. Funding and +immediate refunds stay fixed; flexible surplus remains collectible on +the successor. Wrapped funding uses the underlying asset's records. + +Preparation reads chain state and resolves records and proofs. Submit +with ``delegate()`` or ``transact()``. A rejection rolls back all funds +movements but can still cost a fee; refresh state before retrying. +The default deadline is 20 blocks. Flexible calls require a deployment +supporting execution-time recovery and amount-driven rebalancing. +Freeze-list proofs are built automatically unless supplied explicitly. ### `plan_inventory(self, token: 'str', target: 'InventoryTarget', *, max_transactions: 'Optional[int]' = None, account: 'Any' = None) -> 'InventoryPlan'` diff --git a/shield-swap-sdk/README.md b/shield-swap-sdk/README.md index 57ab64f..82a391f 100644 --- a/shield-swap-sdk/README.md +++ b/shield-swap-sdk/README.md @@ -124,7 +124,72 @@ from the chain or the service. | `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. | +| `plan_rebalance(...)` / `rebalance_position(...)` | Plan or prepare a complete position replacement in one transaction. Choose exact quotes, exact liquidity with flexible recovery, or execution-time sizing from token budgets; see below. | + +### Move a position to another range + +A rebalance closes the complete old position and creates a replacement in the +same pool, preserving its owner and withdrawal address. Principal and all owed +and accrued fees become available for the replacement. Optional funding adds +private tokens; no swap occurs. Choose a mode according to what must remain +fixed between planning and execution: + +| Planner / matching execution method | Guarantee and required inputs | +|---|---| +| `plan_rebalance_exact_amounts` / `rebalance_position_exact_amounts` | Exact quoted recovery and deposits. Supply a positive `liquidity_target`, or paired `max_funding0/1` budgets that the planner converts to an exact quote. Price or fee changes can reject execution. | +| `plan_rebalance_to_liquidity` / `rebalance_position_to_liquidity` | Exact successor liquidity using actual recovery. Supply a positive target and both `amount0/1_desired` caps. Minimums default to zero; at least one token bound must remain flexible. Insufficient funds reject rather than minting less liquidity. | +| `plan_rebalance_from_amounts` / `rebalance_position_from_amounts` | Liquidity determined from available tokens at execution. Supply both desired caps; minimums default to zero and the target is zero. `estimated_liquidity` is a preview, not a guaranteed amount. | + +Token caps, minimums, fixed `funded0/1`, and fixed `refund0/1` use integer base +units from 0 through `2**128 - 1`. Funding and immediate refunds default to zero +in flexible modes and cannot both be positive for the same token. Unused funds +stay collectible as `tokens_owed` on the successor; they earn no liquidity fees +until reinvested. Token minimums constrain consumption, not minimum liquidity. + +The generic `plan_rebalance` and `rebalance_position` preserve existing exact +calls. With explicit caps, omitted minimums equal those caps. Widening either +bound enables actual recovery; setting `liquidity_target=0` enables execution-time +sizing even when both bounds are equal. `max_funding0/1` remain quote-time sizing +budgets and cannot be combined with explicit caps or fixed transfers. + +Continue with the configured `dex` client from the setup above. This example +uses illustrative token caps; choose caps in each token's own base units and +review the plan before submitting: + +```python +positions = dex.get_owned_positions() +if not positions: + raise ValueError("An owned position is required before rebalancing") +position = positions[0] +plan = dex.plan_rebalance_from_amounts( + pool_key=position.pool_key, + position_token_id=position.position_token_id, + tick_lower=position.tick_lower - 60, + tick_upper=position.tick_upper + 60, + amount0_desired=100_000, + amount1_desired=100_000, +) +print(plan) # Includes fixed transfers, token bounds, and estimated liquidity. +# After reviewing the plan: +result = dex.rebalance_position_from_amounts(plan=plan).delegate() +print(result.transaction_id, result.position_token_id) +``` + +Passing `plan=` preserves its constraints and estimates verbatim. Planning +arguments cannot override a supplied plan; build a new plan to change them. +Preparation reads the network and resolves records/proofs; only `delegate()` or +`transact()` submits. A rejected rebalance leaves the old position unchanged but +can still cost a fee. Refresh state before retrying a confirmed rejection. + +`AsyncShieldSwap` exposes all four planners; liquidity execution remains on +`ShieldSwap`. The same named planner and execution helpers are available through +`aleo_shield_swap.agent` and the MCP server. Agent/MCP amounts are decimal strings +to preserve u128 precision; those execution tools rebuild the plan from the +provided constraints immediately before submission. + +The rebalance router is supported on testnet. Flexible calls require a contract +deployment with execution-time recovery and amount-driven rebalancing support; +SDK availability alone does not establish deployment compatibility. A swap whose input was fully consumed (`amount_remaining == 0`) claims through the no-refund entrypoints (`claim_swap_output_no_refund` and the diff --git a/shield-swap-sdk/codegen/gen_context.py b/shield-swap-sdk/codegen/gen_context.py index 9c684b1..b0df415 100644 --- a/shield-swap-sdk/codegen/gen_context.py +++ b/shield-swap-sdk/codegen/gen_context.py @@ -171,12 +171,16 @@ A swap with nothing left over claims through the no-refund entrypoints automatically; `get_swap_execution` reads the per-hop fill receipt (fees paid, price after) at any later time. -- **Rebalancing is one transaction, testnet only for now** — - `plan_rebalance` quotes the close-and-remint (what comes back, what the - new range needs, funding vs refund per token) and `rebalance_position` - submits it through `shield_swap_rebalance_router.aleo`. Every amount is - asserted at the execution price: a trade in between reverts the whole - transaction (fee paid, no funds moved) — re-plan and resubmit. +- **Rebalancing replaces the complete position in one transaction** — + choose `plan_rebalance_exact_amounts` for exact quotes, + `plan_rebalance_to_liquidity` for exact liquidity with actual recovery, or + `plan_rebalance_from_amounts` for execution-time sizing from token caps. + Review the plan, then pass `plan=` to the matching `rebalance_position_*` + method. Flexible funding/refunds are fixed; surplus stays collectible on + the successor. `estimated_liquidity` is advisory, and token minimums do not + guarantee minimum liquidity. The router is supported on testnet; flexible + calls require an upgraded contract. A rejection rolls back funds movements + but can still cost a fee — refresh state before retrying. - **Concurrency needs partitioned blinded-identity counters AND disjoint input records** — `swap_many` implements the recipe; copy it, don't improvise. diff --git a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md index 3d43c60..ada946a 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md +++ b/shield-swap-sdk/python/aleo_shield_swap/AGENTS.md @@ -212,12 +212,16 @@ them): A swap with nothing left over claims through the no-refund entrypoints automatically; `get_swap_execution` reads the per-hop fill receipt (fees paid, price after) at any later time. -- **Rebalancing is one transaction, testnet only for now** — - `plan_rebalance` quotes the close-and-remint (what comes back, what the - new range needs, funding vs refund per token) and `rebalance_position` - submits it through `shield_swap_rebalance_router.aleo`. Every amount is - asserted at the execution price: a trade in between reverts the whole - transaction (fee paid, no funds moved) — re-plan and resubmit. +- **Rebalancing replaces the complete position in one transaction** — + choose `plan_rebalance_exact_amounts` for exact quotes, + `plan_rebalance_to_liquidity` for exact liquidity with actual recovery, or + `plan_rebalance_from_amounts` for execution-time sizing from token caps. + Review the plan, then pass `plan=` to the matching `rebalance_position_*` + method. Flexible funding/refunds are fixed; surplus stays collectible on + the successor. `estimated_liquidity` is advisory, and token minimums do not + guarantee minimum liquidity. The router is supported on testnet; flexible + calls require an upgraded contract. A rejection rolls back funds movements + but can still cost a fee — refresh state before retrying. - **Concurrency needs partitioned blinded-identity counters AND disjoint input records** — `swap_many` implements the recipe; copy it, don't improvise. @@ -499,52 +503,40 @@ them, or pass 2–4 bundles (owner, withdrawal, then a receiver per wrapped side Burn an empty position NFT. -### `plan_rebalance(self, *, pool_key: 'str', position_token_id: 'str', tick_lower: 'int', tick_upper: 'int', liquidity_target: 'Optional[int]' = None, max_funding0: 'Optional[int]' = None, max_funding1: 'Optional[int]' = None) -> 'RebalancePlan'` +### `plan_rebalance(self, *, pool_key: 'str', position_token_id: 'str', tick_lower: 'int', tick_upper: 'int', liquidity_target: 'Optional[int]' = None, max_funding0: 'Optional[int]' = None, max_funding1: 'Optional[int]' = None, amount0_desired: 'Optional[int]' = None, amount1_desired: 'Optional[int]' = None, amount0_min: 'Optional[int]' = None, amount1_min: 'Optional[int]' = None, funded0: 'Optional[int]' = None, funded1: 'Optional[int]' = None, refund0: 'Optional[int]' = None, refund1: 'Optional[int]' = None) -> 'RebalancePlan'` -Quote a close-and-remint of one position into a new range. Reads only. +Plan a complete position replacement without signing or submitting. -Reads the pool, slot, ``positions`` entry, both current boundary ticks, -and each side's wrapped-ness, then derives what the close returns, what -the successor range needs, and per side the funding to add or surplus -to refund. Size the successor with exactly one of *liquidity_target* -(exact) or *max_funding0*/*max_funding1* (a budget the planner solves -for; ``0, 0`` rebalances on recovered funds alone). +A positive target with omitted bounds preserves exact quoted recovery +and deposits. ``max_funding0/1`` also produce an exact quote sized at +the current price. Explicit ``amount0/1_desired`` cap token consumption; +``amount0/1_min`` default to the caps. Widen either bound to use actual +recovery, or set ``liquidity_target=0`` to size liquidity at execution. +Flexible funding and refunds are fixed amounts, defaulting to zero; +surplus remains collectible on the successor and earns no LP fees. -The plan is only valid at the pool price it was built against — the -contract asserts every amount at finalize — so build it right before -:meth:`rebalance_position` and never cache one. +Amounts are integer token base units, from 0 through 2**128 - 1. +``estimated_liquidity`` is advisory; token floors do not guarantee a +minimum liquidity. Named planners supply mode-specific defaults. +Requires an existing pool, position, and initialized boundary ticks. -Raises: - PoolNotFoundError / PoolNotInitializedError: For an unknown pool. - ValueError: If the position or a boundary tick does not exist, the - aligned range is empty, the sizing is ambiguous, or the budget - supports no liquidity. - -### `rebalance_position(self, *, plan: 'Optional[RebalancePlan]' = None, pool_key: 'Optional[str]' = None, position_token_id: 'Optional[str]' = None, tick_lower: 'Optional[int]' = None, tick_upper: 'Optional[int]' = None, liquidity_target: 'Optional[int]' = None, max_funding0: 'Optional[int]' = None, max_funding1: 'Optional[int]' = None, position_record: 'Optional[str]' = None, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 20, nonce: 'Optional[str]' = None, compliance_proofs: 'Optional[RebalanceProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[RebalanceResult]'` - -Close a position and mint its successor range in ONE transaction. - -Burns the old position, settles its principal and every fee it earned, -adds funding from the signer's records where the new range needs more, -mints the successor with the same owner and withdrawal address, and -pays any surplus to the withdrawal address — atomically, through -``shield_swap_rebalance_router.aleo`` (deployed on testnet). Either -pass a *plan* from :meth:`plan_rebalance` (submitted verbatim), or the -pool, range, and one sizing mode and the plan is built here. - -Every amount is asserted against the pool price at execution: a trade -that moves the price between planning and finalize reverts the whole -transaction (fee paid, no funds moved). Rebuild and resubmit when -that happens; the short default deadline fails stale requests cheaply. -Funding records for a wrapped side are the UNDERLYING asset's records. -Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds -them, or pass 2–6 bundles (funded wrapped senders, wrapped receivers, -then owner and withdrawal). +### `rebalance_position(self, *, plan: 'Optional[RebalancePlan]' = None, pool_key: 'Optional[str]' = None, position_token_id: 'Optional[str]' = None, tick_lower: 'Optional[int]' = None, tick_upper: 'Optional[int]' = None, liquidity_target: 'Optional[int]' = None, max_funding0: 'Optional[int]' = None, max_funding1: 'Optional[int]' = None, amount0_desired: 'Optional[int]' = None, amount1_desired: 'Optional[int]' = None, amount0_min: 'Optional[int]' = None, amount1_min: 'Optional[int]' = None, funded0: 'Optional[int]' = None, funded1: 'Optional[int]' = None, refund0: 'Optional[int]' = None, refund1: 'Optional[int]' = None, position_record: 'Optional[str]' = None, token0_program: 'Optional[str]' = None, token1_program: 'Optional[str]' = None, token0_record: 'Optional[str]' = None, token1_record: 'Optional[str]' = None, tick_lower_hint: 'Optional[int]' = None, tick_upper_hint: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 20, nonce: 'Optional[str]' = None, compliance_proofs: 'Optional[RebalanceProofs]' = None, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[RebalanceResult]'` -Raises: - ValueError: Without a plan or a complete (pool, range, sizing), or - with the wrong number of compliance proof bundles. - InsufficientRecordsError: If no record covers a funded side. +Prepare an atomic replacement in the same pool, keeping owner and withdrawal. + +Pass a reviewed ``plan`` verbatim, or pool/range/sizing arguments as in +``plan_rebalance``. A supplied plan cannot be combined with planning +overrides. Exact bounds enforce the quote; wider bounds use actual +recovery and a zero target sizes liquidity at execution. Funding and +immediate refunds stay fixed; flexible surplus remains collectible on +the successor. Wrapped funding uses the underlying asset's records. + +Preparation reads chain state and resolves records and proofs. Submit +with ``delegate()`` or ``transact()``. A rejection rolls back all funds +movements but can still cost a fee; refresh state before retrying. +The default deadline is 20 blocks. Flexible calls require a deployment +supporting execution-time recovery and amount-driven rebalancing. +Freeze-list proofs are built automatically unless supplied explicitly. ### `plan_inventory(self, token: 'str', target: 'InventoryTarget', *, max_transactions: 'Optional[int]' = None, account: 'Any' = None) -> 'InventoryPlan'` diff --git a/shield-swap-sdk/python/aleo_shield_swap/__init__.py b/shield-swap-sdk/python/aleo_shield_swap/__init__.py index 66d8871..b2f8417 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/__init__.py +++ b/shield-swap-sdk/python/aleo_shield_swap/__init__.py @@ -46,6 +46,10 @@ def _require_aleo_sdk() -> None: from .rebalance import ( RebalancePlan as RebalancePlan, RebalanceResult as RebalanceResult, + plan_rebalance as plan_rebalance, + plan_rebalance_exact_amounts as plan_rebalance_exact_amounts, + plan_rebalance_to_liquidity as plan_rebalance_to_liquidity, + plan_rebalance_from_amounts as plan_rebalance_from_amounts, ) from .compliance import ( ClaimProofs as ClaimProofs, @@ -132,7 +136,8 @@ def agent_guide() -> str: "AirdropPendingError", "AirdropRateLimitedError", "CredentialsMissingError", "OwnedPosition", "OwnedPositionState", "HopFill", "SwapExecution", - "RebalancePlan", "RebalanceResult", + "RebalancePlan", "RebalanceResult", "plan_rebalance", + "plan_rebalance_exact_amounts", "plan_rebalance_to_liquidity", "plan_rebalance_from_amounts", "ConfirmAirdropResult", "Profile", "Journal", "REGISTRATION_STAGES", "OnboardReport", "StageOutcome", "SessionStatus", "PositionView", "SwapBatchReport", "CollectReport", "blinded_identity_at", diff --git a/shield-swap-sdk/python/aleo_shield_swap/agent.py b/shield-swap-sdk/python/aleo_shield_swap/agent.py index 33c923d..1e27fad 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/agent.py +++ b/shield-swap-sdk/python/aleo_shield_swap/agent.py @@ -12,6 +12,9 @@ from __future__ import annotations from dataclasses import asdict, is_dataclass +from functools import partial + +from .rebalance import _AMOUNT_FIELDS from typing import Any, Callable _S = {"type": "string"} @@ -112,17 +115,25 @@ def _h_get_swap_execution(dex: Any, args: dict[str, Any]) -> Any: return _serialize(dex.get_swap_execution(args["swap_id"])) -def _opt_int(args: dict[str, Any], key: str) -> Any: +def _opt_int(args: dict[str, Any], key: str) -> int | None: value = args.get(key) - return int(value) if value is not None else None + if value is None: + return None + if type(value) is int: + return value + if isinstance(value, str) and value.isascii() and value.isdecimal(): + return int(value) + raise ValueError(f"{key} must be an integer or an unsigned decimal integer string") def _rebalance_kwargs(args: dict[str, Any]) -> dict[str, Any]: - return dict(pool_key=args["pool_key"], position_token_id=args["position_token_id"], + result = dict(pool_key=args["pool_key"], position_token_id=args["position_token_id"], tick_lower=int(args["tick_lower"]), tick_upper=int(args["tick_upper"]), liquidity_target=_opt_int(args, "liquidity_target"), max_funding0=_opt_int(args, "max_funding0"), max_funding1=_opt_int(args, "max_funding1")) + result.update({key: _opt_int(args, key) for key in _AMOUNT_FIELDS if args.get(key) is not None}) + return result #: RebalancePlan fields that are raw u128 amounts — reported as strings so @@ -130,7 +141,9 @@ def _rebalance_kwargs(args: dict[str, Any]) -> dict[str, Any]: #: (the tool descriptions promise "raw base units as strings"). _PLAN_AMOUNT_FIELDS = ("old_liquidity", "fees_accrued0", "fees_accrued1", "recovered0", "recovered1", "required0", "required1", - "funded0", "funded1", "refund0", "refund1", "liquidity_target") + "funded0", "funded1", "refund0", "refund1", "liquidity_target", + "amount0_desired", "amount1_desired", "amount0_min", "amount1_min", + "estimated_liquidity") def _plan_json(plan: Any) -> dict[str, Any]: @@ -141,12 +154,14 @@ def _plan_json(plan: Any) -> dict[str, Any]: return out -def _h_plan_rebalance(dex: Any, args: dict[str, Any]) -> Any: - return _plan_json(dex.plan_rebalance(**_rebalance_kwargs(args))) +def _h_plan_rebalance(dex: Any, args: dict[str, Any], *, mode: str = "") -> Any: + method = getattr(dex, "plan_rebalance" + (f"_{mode}" if mode else "")) + return _plan_json(method(**_rebalance_kwargs(args))) -def _h_rebalance_position(dex: Any, args: dict[str, Any]) -> Any: - result = _serialize(dex.rebalance_position(**_rebalance_kwargs(args)).delegate()) +def _h_rebalance_position(dex: Any, args: dict[str, Any], *, mode: str = "") -> Any: + method = getattr(dex, "rebalance_position" + (f"_{mode}" if mode else "")) + result = _serialize(method(**_rebalance_kwargs(args)).delegate()) if isinstance(result.get("plan"), dict): for key in _PLAN_AMOUNT_FIELDS: if result["plan"].get(key) is not None: @@ -154,6 +169,19 @@ def _h_rebalance_position(dex: Any, args: dict[str, Any]) -> Any: return result +_REBALANCE_REQUIRED = ["pool_key", "position_token_id", "tick_lower", "tick_upper"] +_REBALANCE_PROPERTIES = { + "pool_key": _S, "position_token_id": _S, "tick_lower": _I, "tick_upper": _I, + "liquidity_target": {**_S, "description": "Positive exact liquidity; zero sizes from tokens at execution."}, + "max_funding0": {**_S, "description": "Quote-time extra token0 budget; requires max_funding1 and no explicit caps."}, + "max_funding1": {**_S, "description": "Quote-time extra token1 budget; requires max_funding0 and no explicit caps."}, + **{f"amount{i}_desired": {**_S, "description": f"Maximum token{i} consumed, in integer base units; supply both caps."} for i in range(2)}, + **{f"amount{i}_min": {**_S, "description": f"Minimum token{i} consumed; generic/exact helpers default to the cap, flexible helpers to zero."} for i in range(2)}, + **{f"funded{i}": {**_S, "description": f"Fixed extra token{i} funding; flexible calls default to zero. Cannot combine with refund{i}."} for i in range(2)}, + **{f"refund{i}": {**_S, "description": f"Fixed immediate token{i} payout; flexible calls default to zero. Cannot combine with funded{i}."} for i in range(2)}, +} + + _TOOLS: list[tuple[str, str, dict[str, Any], Callable[[Any, dict[str, Any]], Any]]] = [ ("setup_account", "Register this machine's shield-swap profile end to end (auth, " @@ -212,33 +240,50 @@ def _h_rebalance_position(dex: Any, args: dict[str, Any]) -> Any: "null until the swap finalizes.", _schema({"swap_id": _S}, ["swap_id"]), _h_get_swap_execution), ("plan_rebalance", - "Quote moving a position to a new tick range in one transaction (testnet " - "only — the rebalance router is not on mainnet). Size with EXACTLY one " - "of liquidity_target (exact successor liquidity) or max_funding0 AND " - "max_funding1 (extra raw units the user will add; 0 and 0 = reuse only " - "what the old position returns). Reports recovered, required, funded, " - "and refund amounts per token; show them to the user before executing. " - "Amounts are raw base units as strings.", - _schema({"pool_key": _S, "position_token_id": _S, "tick_lower": _I, - "tick_upper": _I, "liquidity_target": _S, "max_funding0": _S, - "max_funding1": _S}, - ["pool_key", "position_token_id", "tick_lower", "tick_upper"]), - _h_plan_rebalance), + "Plan moving a complete position to a new range (testnet router). " + "Positive liquidity_target with omitted/equal token bounds enforces an exact quote; " + "max_funding0/1 also size an exact quote. Wider token bounds use actual recovery; " + "a zero target requires both desired caps and sizes liquidity at execution. " + "Funding/refunds are fixed, flexible surplus stays collectible on the successor. " + "Show the plan before executing; amounts are raw base units as strings. " + "estimated_liquidity is advisory, never a guaranteed minimum. Flexible calls require an upgraded contract.", + _schema(_REBALANCE_PROPERTIES, _REBALANCE_REQUIRED), _h_plan_rebalance), ("rebalance_position", - "Execute the rebalance plan_rebalance described: burn the old position, " - "settle its principal and fees, add any funding, and mint the successor " - "range — atomically. Same sizing arguments as plan_rebalance (the plan is " - "rebuilt at submit time). Reverts if the pool price moved since planning; " - "on revert, re-plan and resubmit. Returns the new position id, the " - "transaction id, and the submitted plan.", - _schema({"pool_key": _S, "position_token_id": _S, "tick_lower": _I, - "tick_upper": _I, "liquidity_target": _S, "max_funding0": _S, - "max_funding1": _S}, - ["pool_key", "position_token_id", "tick_lower", "tick_upper"]), - _h_rebalance_position), + "Replace a complete position atomically, keeping its owner and withdrawal address. " + "Uses plan_rebalance arguments and rebuilds the plan before submitting. " + "Exact calls can reject on price/fee changes; flexible calls use actual recovery " + "within token bounds. Funding/refunds stay fixed; surplus stays collectible. " + "A rejected transaction moves no funds but can cost a fee. Returns the successor, " + "transaction ID, and submitted plan with amounts as base-unit strings.", + _schema(_REBALANCE_PROPERTIES, _REBALANCE_REQUIRED), _h_rebalance_position), ] +_REBALANCE_MODES = { + "exact_amounts": "Enforce exact quoted recovery and deposits. A positive target and equal bounds are required; price or fee changes can reject execution.", + "to_liquidity": "Require exact successor liquidity using actual recovery. Supply a positive target and both token caps; minimums default to zero and at least one bound must be wider. Insufficient funds reject instead of minting less liquidity.", + "from_amounts": "Size successor liquidity from actual recovered tokens plus fixed funding minus fixed refunds, capped per token. Supply both desired caps; target must be omitted or zero and minimums default to zero. Estimated liquidity is advisory; no swap occurs.", +} +for _mode, _description in _REBALANCE_MODES.items(): + _properties = dict(_REBALANCE_PROPERTIES) + _required = list(_REBALANCE_REQUIRED) + if _mode != "exact_amounts": + _properties.pop("max_funding0") + _properties.pop("max_funding1") + _required.extend(["amount0_desired", "amount1_desired"]) + if _mode == "to_liquidity": + _required.append("liquidity_target") + for _prefix, _handler, _effect in ( + ("plan_rebalance", _h_plan_rebalance, "Read chain state without submitting. Show the plan before execution."), + ("rebalance_position", _h_rebalance_position, "Rebuild the plan and submit atomically, keeping owner and withdrawal. Rejection rolls back funds but can cost a fee."), + ): + _TOOLS.append((f"{_prefix}_{_mode}", + f"{_description} {_effect} Token amounts use integer base-unit strings. " + "Fixed funding/refunds default to zero in flexible modes; surplus stays collectible on the successor. " + "Flexible calls require an upgraded contract; rebalance routing is testnet-only.", + _schema(dict(_properties), list(_required)), partial(_handler, mode=_mode))) + + def shield_swap_tools() -> list[dict[str, Any]]: """Tool definitions (Claude API ``tools=`` shape).""" return [{"name": name, "description": desc, "input_schema": schema} 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 0319268..3f828b4 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/async_client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/async_client.py @@ -49,7 +49,8 @@ SHIELD_SWAP_FREEZELIST, ClaimProofs, Slot, SwapProofs, check_proof_arguments, claim_slots, merge_legacy_wrapper_proofs, planned_proof_slots, supplied_literals, swap_slots, ) -from .rebalance import RebalancePlan, plan_rebalance as _plan_rebalance +from .rebalance import (RebalancePlan, plan_rebalance as _plan_rebalance, + _mode_parameters, _validate_planning) from .position_math import ( amounts_for_liquidity, fee_growth_inside, @@ -567,9 +568,16 @@ async def plan_rebalance(self, *, pool_key: str, position_token_id: str, tick_lower: int, tick_upper: int, liquidity_target: Optional[int] = None, max_funding0: Optional[int] = None, - max_funding1: Optional[int] = None) -> RebalancePlan: + max_funding1: Optional[int] = None, + amount0_desired: Optional[int] = None, + amount1_desired: Optional[int] = None, + amount0_min: Optional[int] = None, + amount1_min: Optional[int] = None, + funded0: Optional[int] = None, funded1: Optional[int] = None, + refund0: Optional[int] = None, refund1: Optional[int] = None) -> RebalancePlan: """Quote a close-and-remint — see :meth:`ShieldSwap.plan_rebalance`. Reads only; the boundary-tick and wrapped-ness reads run concurrently.""" + _validate_planning(locals()) import asyncio pool = await self.get_pool(pool_key) slot = (await self.get_slot(pool_key)).raw @@ -590,7 +598,44 @@ async def plan_rebalance(self, *, pool_key: str, position_token_id: str, tick_lower=tick_lower, tick_upper=tick_upper, slot=slot, position=position, lower_tick=lower, upper_tick=upper, wrapped0=w0, wrapped1=w1, liquidity_target=liquidity_target, - max_funding0=max_funding0, max_funding1=max_funding1) + max_funding0=max_funding0, max_funding1=max_funding1, + amount0_desired=amount0_desired, amount1_desired=amount1_desired, + amount0_min=amount0_min, amount1_min=amount1_min, + funded0=funded0, funded1=funded1, refund0=refund0, refund1=refund1) + + async def plan_rebalance_exact_amounts(self, **kwargs: Any) -> RebalancePlan: + """Plan exact recovery and deposit amounts for a replacement position. + + Accepts the arguments of ``plan_rebalance``; positive targets and equal + token bounds are required. Paired ``max_funding0/1`` quote-time budgets + remain supported. Price or fee changes can reject execution. + Reads chain state without signing or submitting. + """ + return await self.plan_rebalance(**_mode_parameters("exact_amounts", kwargs)) + + async def plan_rebalance_to_liquidity(self, **kwargs: Any) -> RebalancePlan: + """Plan exact successor liquidity with flexible recovered funds. + + Accepts the arguments of ``plan_rebalance``. Requires a positive + ``liquidity_target`` and both token caps; minimums default to zero and + at least one bound must be wider than its minimum. Use fixed + ``funded0/1`` and ``refund0/1``, not ``max_funding0/1``. Insufficient + funds reject execution instead of reducing liquidity. + Reads chain state without signing or submitting. + """ + return await self.plan_rebalance(**_mode_parameters("to_liquidity", kwargs)) + + async def plan_rebalance_from_amounts(self, **kwargs: Any) -> RebalancePlan: + """Plan a replacement sized from available tokens at execution. + + Accepts the arguments of ``plan_rebalance``. Requires both token caps; + minimums default to zero and the target must be omitted or zero. Fixed + funding/refunds default to zero. ``estimated_liquidity`` is a preview, + not a guarantee. Surplus stays collectible on the successor; no swap + occurs. Token amounts use integer base units. + Reads chain state without signing or submitting. + """ + return await self.plan_rebalance(**_mode_parameters("from_amounts", kwargs)) async def get_owned_position(self, position_token_id: str, *, account: Any = None) -> Optional[OwnedPosition]: diff --git a/shield-swap-sdk/python/aleo_shield_swap/client.py b/shield-swap-sdk/python/aleo_shield_swap/client.py index 09acbd1..c9b5e2f 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/client.py +++ b/shield-swap-sdk/python/aleo_shield_swap/client.py @@ -109,6 +109,7 @@ RebalancePlan, RebalanceResult, plan_rebalance as _plan_rebalance, + _mode_parameters, _validate_planning, _validate_plan_arguments, ) from .tick_math import ( MAX_TICK, @@ -1644,26 +1645,29 @@ def plan_rebalance(self, *, pool_key: str, position_token_id: str, tick_lower: int, tick_upper: int, liquidity_target: Optional[int] = None, max_funding0: Optional[int] = None, - max_funding1: Optional[int] = None) -> RebalancePlan: - """Quote a close-and-remint of one position into a new range. Reads only. - - Reads the pool, slot, ``positions`` entry, both current boundary ticks, - and each side's wrapped-ness, then derives what the close returns, what - the successor range needs, and per side the funding to add or surplus - to refund. Size the successor with exactly one of *liquidity_target* - (exact) or *max_funding0*/*max_funding1* (a budget the planner solves - for; ``0, 0`` rebalances on recovered funds alone). - - The plan is only valid at the pool price it was built against — the - contract asserts every amount at finalize — so build it right before - :meth:`rebalance_position` and never cache one. - - Raises: - PoolNotFoundError / PoolNotInitializedError: For an unknown pool. - ValueError: If the position or a boundary tick does not exist, the - aligned range is empty, the sizing is ambiguous, or the budget - supports no liquidity. + max_funding1: Optional[int] = None, + amount0_desired: Optional[int] = None, + amount1_desired: Optional[int] = None, + amount0_min: Optional[int] = None, + amount1_min: Optional[int] = None, + funded0: Optional[int] = None, funded1: Optional[int] = None, + refund0: Optional[int] = None, refund1: Optional[int] = None) -> RebalancePlan: + """Plan a complete position replacement without signing or submitting. + + A positive target with omitted bounds preserves exact quoted recovery + and deposits. ``max_funding0/1`` also produce an exact quote sized at + the current price. Explicit ``amount0/1_desired`` cap token consumption; + ``amount0/1_min`` default to the caps. Widen either bound to use actual + recovery, or set ``liquidity_target=0`` to size liquidity at execution. + Flexible funding and refunds are fixed amounts, defaulting to zero; + surplus remains collectible on the successor and earns no LP fees. + + Amounts are integer token base units, from 0 through 2**128 - 1. + ``estimated_liquidity`` is advisory; token floors do not guarantee a + minimum liquidity. Named planners supply mode-specific defaults. + Requires an existing pool, position, and initialized boundary ticks. """ + _validate_planning(locals()) pool = self.get_pool(pool_key) slot = self.get_slot(pool_key).raw position = self._position_state(position_token_id) @@ -1681,7 +1685,44 @@ def plan_rebalance(self, *, pool_key: str, position_token_id: str, wrapped0=self._is_wrapped(str(pool.token0)), wrapped1=self._is_wrapped(str(pool.token1)), liquidity_target=liquidity_target, - max_funding0=max_funding0, max_funding1=max_funding1) + max_funding0=max_funding0, max_funding1=max_funding1, + amount0_desired=amount0_desired, amount1_desired=amount1_desired, + amount0_min=amount0_min, amount1_min=amount1_min, + funded0=funded0, funded1=funded1, refund0=refund0, refund1=refund1) + + def plan_rebalance_exact_amounts(self, **kwargs: Any) -> RebalancePlan: + """Plan exact recovery and deposit amounts for a replacement position. + + Accepts the arguments of ``plan_rebalance``; positive targets and equal + token bounds are required. Paired ``max_funding0/1`` quote-time budgets + remain supported. Price or fee changes can reject execution. + Reads chain state without signing or submitting. + """ + return self.plan_rebalance(**_mode_parameters("exact_amounts", kwargs)) + + def plan_rebalance_to_liquidity(self, **kwargs: Any) -> RebalancePlan: + """Plan exact successor liquidity with flexible recovered funds. + + Accepts the arguments of ``plan_rebalance``. Requires a positive + ``liquidity_target`` and both token caps; minimums default to zero and + at least one bound must be wider than its minimum. Use fixed + ``funded0/1`` and ``refund0/1``, not ``max_funding0/1``. Insufficient + funds reject execution instead of reducing liquidity. + Reads chain state without signing or submitting. + """ + return self.plan_rebalance(**_mode_parameters("to_liquidity", kwargs)) + + def plan_rebalance_from_amounts(self, **kwargs: Any) -> RebalancePlan: + """Plan a replacement sized from available tokens at execution. + + Accepts the arguments of ``plan_rebalance``. Requires both token caps; + minimums default to zero and the target must be omitted or zero. Fixed + funding/refunds default to zero. ``estimated_liquidity`` is a preview, + not a guarantee. Surplus stays collectible on the successor; no swap + occurs. Token amounts use integer base units. + Reads chain state without signing or submitting. + """ + return self.plan_rebalance(**_mode_parameters("from_amounts", kwargs)) def rebalance_position( self, @@ -1694,6 +1735,14 @@ def rebalance_position( liquidity_target: Optional[int] = None, max_funding0: Optional[int] = None, max_funding1: Optional[int] = None, + amount0_desired: Optional[int] = None, + amount1_desired: Optional[int] = None, + amount0_min: Optional[int] = None, + amount1_min: Optional[int] = None, + funded0: Optional[int] = None, + funded1: Optional[int] = None, + refund0: Optional[int] = None, + refund1: Optional[int] = None, position_record: Optional[str] = None, token0_program: Optional[str] = None, token1_program: Optional[str] = None, @@ -1708,39 +1757,36 @@ def rebalance_position( imports: Optional[dict[str, str]] = None, account: Any = None, ) -> DexCall[RebalanceResult]: - """Close a position and mint its successor range in ONE transaction. - - Burns the old position, settles its principal and every fee it earned, - adds funding from the signer's records where the new range needs more, - mints the successor with the same owner and withdrawal address, and - pays any surplus to the withdrawal address — atomically, through - ``shield_swap_rebalance_router.aleo`` (deployed on testnet). Either - pass a *plan* from :meth:`plan_rebalance` (submitted verbatim), or the - pool, range, and one sizing mode and the plan is built here. - - Every amount is asserted against the pool price at execution: a trade - that moves the price between planning and finalize reverts the whole - transaction (fee paid, no funds moved). Rebuild and resubmit when - that happens; the short default deadline fails stale requests cheaply. - Funding records for a wrapped side are the UNDERLYING asset's records. - Freeze-list proofs: leave ``compliance_proofs`` unset and the SDK builds - them, or pass 2–6 bundles (funded wrapped senders, wrapped receivers, - then owner and withdrawal). - - Raises: - ValueError: Without a plan or a complete (pool, range, sizing), or - with the wrong number of compliance proof bundles. - InsufficientRecordsError: If no record covers a funded side. + """Prepare an atomic replacement in the same pool, keeping owner and withdrawal. + + Pass a reviewed ``plan`` verbatim, or pool/range/sizing arguments as in + ``plan_rebalance``. A supplied plan cannot be combined with planning + overrides. Exact bounds enforce the quote; wider bounds use actual + recovery and a zero target sizes liquidity at execution. Funding and + immediate refunds stay fixed; flexible surplus remains collectible on + the successor. Wrapped funding uses the underlying asset's records. + + Preparation reads chain state and resolves records and proofs. Submit + with ``delegate()`` or ``transact()``. A rejection rolls back all funds + movements but can still cost a fee; refresh state before retrying. + The default deadline is 20 blocks. Flexible calls require a deployment + supporting execution-time recovery and amount-driven rebalancing. + Freeze-list proofs are built automatically unless supplied explicitly. """ + planning = dict(liquidity_target=liquidity_target, + max_funding0=max_funding0, max_funding1=max_funding1, + amount0_desired=amount0_desired, amount1_desired=amount1_desired, + amount0_min=amount0_min, amount1_min=amount1_min, + funded0=funded0, funded1=funded1, refund0=refund0, refund1=refund1) acct = self._account(account) if plan is None: if pool_key is None or tick_lower is None or tick_upper is None: raise ValueError("Pass plan=, or pool_key, tick_lower, and " "tick_upper with one sizing mode.") # Validate the sizing before any chain read. - from .rebalance import _sizing - _sizing(liquidity_target, max_funding0, max_funding1) + _validate_planning(planning) else: + _validate_plan_arguments(plan, locals()) pool_key, position_token_id = plan.pool_key, plan.position_token_id assert pool_key is not None pool = self.get_pool(pool_key) @@ -1754,8 +1800,7 @@ def rebalance_position( plan = self.plan_rebalance( pool_key=pool_key, position_token_id=position_token_id, tick_lower=tick_lower, tick_upper=tick_upper, - liquidity_target=liquidity_target, - max_funding0=max_funding0, max_funding1=max_funding1) + **planning) w0 = self._is_wrapped(str(pool.token0)) w1 = self._is_wrapped(str(pool.token1)) @@ -1789,6 +1834,8 @@ def survivor(hint: int) -> int: else upper_pred) deadline = get_deadline(self._aleo, deadline_offset_blocks) + desired0 = plan.amount0_desired if plan.amount0_desired is not None else plan.required0 + desired1 = plan.amount1_desired if plan.amount1_desired is not None else plan.required1 request = g.CoreRebalanceRequest( old_liquidity=plan.old_liquidity, recovered0=plan.recovered0, recovered1=plan.recovered1, @@ -1797,8 +1844,9 @@ def survivor(hint: int) -> int: liquidity_target=plan.liquidity_target, mint=g.MintPositionRequest( pool=pool_key, tick_lower=plan.tick_lower, tick_upper=plan.tick_upper, - amount0_desired=plan.required0, amount1_desired=plan.required1, - amount0_min=plan.required0, amount1_min=plan.required1, + amount0_desired=desired0, amount1_desired=desired1, + amount0_min=plan.amount0_min if plan.amount0_min is not None else desired0, + amount1_min=plan.amount1_min if plan.amount1_min is not None else desired1, tick_lower_hint=lo_hint, tick_upper_hint=hi_hint), deadline=deadline, ).to_plaintext() @@ -1866,6 +1914,38 @@ def build_result(tx_id: str, outputs: list[Any]) -> RebalanceResult: return DexCall(self._aleo, bound, build_result) + def rebalance_position_exact_amounts(self, **kwargs: Any) -> DexCall[RebalanceResult]: + """Prepare a rebalance enforcing exact quoted recovery and deposits. + + Accepts ``rebalance_position`` arguments, including a reviewed plan. + Requires a positive target and equal token bounds. Price or fee + changes can reject execution. + Submit with ``delegate()`` or ``transact()`` after reviewing the plan. + """ + return self.rebalance_position(**_mode_parameters("exact_amounts", kwargs)) + + def rebalance_position_to_liquidity(self, **kwargs: Any) -> DexCall[RebalanceResult]: + """Prepare exact successor liquidity using actual recovered funds. + + Accepts ``rebalance_position`` arguments, including a reviewed plan. + Requires a positive target, both token caps, and at least one flexible + bound. Minimums default to zero when building a new plan. Fixed + funding/refunds default to zero; insufficient funds reject execution. + Submit with ``delegate()`` or ``transact()`` after reviewing the plan. + """ + return self.rebalance_position(**_mode_parameters("to_liquidity", kwargs)) + + def rebalance_position_from_amounts(self, **kwargs: Any) -> DexCall[RebalanceResult]: + """Prepare a replacement sized from available tokens at execution. + + Accepts ``rebalance_position`` arguments, including a reviewed plan. + Requires both token caps and an omitted or zero target. Minimums and + fixed funding/refunds default to zero when building a new plan. Surplus + stays collectible on the successor; estimated liquidity is advisory. + Submit with ``delegate()`` or ``transact()`` after reviewing the plan. + """ + return self.rebalance_position(**_mode_parameters("from_amounts", kwargs)) + def _select_position_record(self, pool_key: str, account: Any, position_token_id: Optional[str] = None) -> str: """Unspent PositionNFT plaintext for *pool_key* from the shield_swap diff --git a/shield-swap-sdk/python/aleo_shield_swap/rebalance.py b/shield-swap-sdk/python/aleo_shield_swap/rebalance.py index c77a182..9d054dd 100644 --- a/shield-swap-sdk/python/aleo_shield_swap/rebalance.py +++ b/shield-swap-sdk/python/aleo_shield_swap/rebalance.py @@ -1,19 +1,13 @@ -"""Rebalance planning — the exact close-and-remint quote the contract asserts. +"""Plan a complete position replacement from a supplied chain snapshot. -A rebalance (``shield_swap_rebalance_router.aleo``) burns a position, settles -everything it returns, optionally adds funds, and mints the successor range -in ONE transaction. The contract re-derives every amount at finalize and -asserts equality, so the plan is only submittable at the pool price it was -built against: rebuild after any delay rather than caching one. - -Pure functions only — the client feeds them chain state (see -``ShieldSwap.plan_rebalance``); a caller with its own indexer can feed them -directly. Mirrors veil's ``planRebalance`` field for field. +Exact bounds enforce quoted recovery and deposits. Flexible bounds use actual +recovery; a zero target derives liquidity from token budgets at execution. +Planning never signs or submits a transaction. """ from __future__ import annotations -from dataclasses import dataclass -from typing import Optional +from dataclasses import asdict, dataclass +from typing import Any, Mapping, Optional from . import _generated as g from ._routing import rebalance_route @@ -30,51 +24,65 @@ #: a budget by a rounding unit; each retry lowers the target by one. BUDGET_CLAMP_RETRIES = 8 -#: Default request lifetime. Short on purpose: a plan is only valid at the -#: price it was built against, so a stale transaction almost certainly reverts -#: on the exactness asserts anyway — the deadline fails it cheaply instead. +#: Default request lifetime, in blocks, to limit exposure to state changes. REBALANCE_DEADLINE_OFFSET_BLOCKS = 20 @dataclass(frozen=True) class RebalancePlan: - """The exact rebalance a position can submit against the planned state. + """Describe submitted constraints and estimates for a replacement position. - All amounts are raw base units. The contract asserts, per side, - ``recovered + funded == required + refund`` and ``funded * refund == 0``; - every plan the planner produces satisfies both by construction, and a - hand-built plan must too. + Amounts use integer token base units. Flexible surplus stays collectible + on the successor and earns no liquidity fees until reinvested. - ``fees_accrued0/1`` are advisory (already folded into ``recovered``); - execution never reads them. + Attributes: + pool_key: Pool shared by the old and replacement positions. + position_token_id: Position to close completely. + tick_lower: Spacing-aligned lower bound of the replacement range. + tick_upper: Spacing-aligned upper bound of the replacement range. + old_liquidity: Full liquidity removed from the old position. + fees_accrued0: Estimated token0 fees since the last checkpoint. + fees_accrued1: Estimated token1 fees since the last checkpoint. + recovered0: Quoted token0 principal plus owed and accrued fees. + recovered1: Quoted token1 recovery; exact quotes enforce both values. + required0: Estimated token0 consumed, also the cap if bounds are absent. + required1: Estimated token1 consumed, also the cap if bounds are absent. + funded0: Fixed extra token0 supplied from private records. + funded1: Fixed extra token1 supplied from private records. + refund0: Fixed immediate token0 payout to the withdrawal address. + refund1: Fixed immediate token1 payout to the withdrawal address. + liquidity_target: Positive exact liquidity, or zero for execution-time sizing. + function_name: Router entry point determined by tokens and funded sides. + amount0_desired: Token0 consumption cap; None uses required0. + amount1_desired: Token1 consumption cap; None uses required1. + amount0_min: Token0 consumption floor; None uses its cap. + amount1_min: Token1 consumption floor; None uses its cap. + estimated_liquidity: Advisory liquidity when the target is zero. Never + submitted as the target or a guaranteed minimum liquidity. """ pool_key: str position_token_id: str - #: Successor range, spacing-aligned. tick_lower: int tick_upper: int - #: The position's live liquidity — the close removes all of it. old_liquidity: int - #: Fees earned since the position's checkpoint, settled by the close. fees_accrued0: int fees_accrued1: int - #: Everything the close returns: principal at the live price + owed + accrued. recovered0: int recovered1: int - #: What the successor range needs at the live price (rounded up). required0: int required1: int - #: What the caller must add — ``max(required - recovered, 0)``. funded0: int funded1: int - #: Surplus paid to the position's withdrawal address. refund0: int refund1: int - #: The successor position's exact liquidity. liquidity_target: int - #: The router entrypoint the plan selects. function_name: str + amount0_desired: Optional[int] = None + amount1_desired: Optional[int] = None + amount0_min: Optional[int] = None + amount1_min: Optional[int] = None + estimated_liquidity: Optional[int] = None @dataclass(frozen=True) @@ -94,8 +102,8 @@ def _sizing(liquidity_target: Optional[int], max_funding0: Optional[int], raise ValueError("Pass exactly one sizing mode: liquidity_target=, or " "max_funding0= and max_funding1= together") if has_target: - if liquidity_target <= 0: # type: ignore[operator] - raise ValueError("liquidity_target must be greater than zero") + if liquidity_target < 0: # type: ignore[operator] + raise ValueError("liquidity_target must not be negative") return if max_funding0 is None or max_funding1 is None: raise ValueError("Budget sizing needs both max_funding0 and max_funding1 " @@ -104,6 +112,111 @@ def _sizing(liquidity_target: Optional[int], max_funding0: Optional[int], raise ValueError("Funding budgets must not be negative") +_AMOUNT_FIELDS = ("amount0_desired", "amount1_desired", "amount0_min", "amount1_min", + "funded0", "funded1", "refund0", "refund1") +_PLAN_INPUT_FIELDS = ("pool_key", "position_token_id", "tick_lower", "tick_upper", + "liquidity_target", "max_funding0", "max_funding1", *_AMOUNT_FIELDS) + + +def _validate_amounts(params: Mapping[str, Any]) -> None: + """Reject invalid contract integers, bounds, transfers, and exact accounting.""" + fields = (*_AMOUNT_FIELDS, "liquidity_target", "old_liquidity", "recovered0", + "recovered1", "required0", "required1", "max_funding0", "max_funding1") + for key in fields: + value = params.get(key) + if value is not None and (type(value) is not int or not 0 <= value < 1 << 128): + raise ValueError(f"{key} must be a u128 integer (0 through 2**128 - 1)") + if params.get("liquidity_target") is not None and any( + params.get(key) is not None for key in ("max_funding0", "max_funding1")): + raise ValueError("Pass exactly one sizing mode: liquidity_target, or max_funding0 and max_funding1") + if (params.get("amount0_desired") is None) != (params.get("amount1_desired") is None): + raise ValueError("Supply amount0_desired and amount1_desired together") + if params.get("amount0_desired") is None and any( + params.get(key) is not None for key in ("amount0_min", "amount1_min")): + raise ValueError("Token minimums require amount0_desired and amount1_desired") + desired = [params.get(f"amount{i}_desired") for i in range(2)] + desired = [cap if cap is not None else params.get(f"required{i}") + for i, cap in enumerate(desired)] + minimum = [params.get(f"amount{i}_min") for i in range(2)] + minimum = [value if value is not None else desired[i] for i, value in enumerate(minimum)] + flexible = params.get("liquidity_target") == 0 or any( + lo is not None and cap is not None and lo < cap for lo, cap in zip(minimum, desired)) + for i, cap in enumerate(desired): + if cap is not None and minimum[i] > cap: + raise ValueError(f"Token{i} minimum exceeds its desired amount") + funded, refund = params.get(f"funded{i}"), params.get(f"refund{i}") + if (funded or 0) > 0 and (refund or 0) > 0: + raise ValueError(f"Token{i} cannot have both funding and a refund") + recovered, required = params.get(f"recovered{i}"), params.get(f"required{i}") + if not flexible and cap is not None and required is not None and cap != required: + raise ValueError(f"Exact token{i} amount must match the liquidity target's quoted deposit") + if (not flexible and cap is not None and recovered is not None + and funded is not None and refund is not None): + if recovered + funded != cap + refund: + raise ValueError(f"Exact token{i} recovery and funding must equal its desired amount and refund") + + +def _validate_planning(params: Mapping[str, Any]) -> None: + _validate_amounts(params) + _sizing(params.get("liquidity_target"), params.get("max_funding0"), params.get("max_funding1")) + explicit = params.get("amount0_desired") is not None + if explicit and params.get("liquidity_target") is None: + raise ValueError("Explicit token bounds require liquidity_target; use 0 for execution-time sizing") + if params.get("liquidity_target") == 0 and not explicit: + raise ValueError("A zero liquidity_target requires amount0_desired and amount1_desired") + if not explicit and any(params.get(key) is not None for key in ("funded0", "funded1", "refund0", "refund1")): + raise ValueError("Fixed funding and refunds require explicit token bounds") + + +def _validate_plan_arguments(plan: RebalancePlan, params: Mapping[str, Any]) -> None: + if any(params.get(key) is not None for key in _PLAN_INPUT_FIELDS): + raise ValueError("A supplied plan cannot be combined with planning overrides") + _validate_amounts(asdict(plan)) + if plan.liquidity_target == 0 and plan.amount0_desired is None: + raise ValueError("A zero liquidity_target requires amount0_desired and amount1_desired") + + +def _mode_parameters(mode: str, kwargs: Mapping[str, Any]) -> dict[str, Any]: + """Apply a named helper's guarantee without altering a prebuilt plan.""" + result = dict(kwargs) + plan = result.get("plan") + if plan is not None: + _validate_plan_arguments(plan, result) + params = asdict(plan) + else: + params = result + if mode != "exact_amounts": + if any(params.get(key) is not None for key in ("max_funding0", "max_funding1")): + raise ValueError("Use fixed funding (funded0/funded1), not max_funding, with explicit token bounds") + if mode == "from_amounts": + if params.get("liquidity_target") not in (None, 0): + raise ValueError("Amount-driven rebalancing requires a zero liquidity_target") + params["liquidity_target"] = 0 + elif params.get("liquidity_target") is None or params["liquidity_target"] <= 0: + raise ValueError("Exact liquidity requires a positive liquidity_target") + if any(params.get(f"amount{i}_desired") is None for i in range(2)): + raise ValueError("Supply amount0_desired and amount1_desired for bounded rebalancing") + # Omitted minima on an existing plan mean exact caps, not helper defaults. + if plan is None: + for i in range(2): + if params.get(f"amount{i}_min") is None: + params[f"amount{i}_min"] = 0 + _validate_amounts(params) + minima = [params.get(f"amount{i}_min") for i in range(2)] + caps = [params.get(f"amount{i}_desired") for i in range(2)] + caps = [cap if cap is not None else params.get(f"required{i}") for i, cap in enumerate(caps)] + minima = [lo if lo is not None else caps[i] for i, lo in enumerate(minima)] + if mode == "exact_amounts": + target = params.get("liquidity_target") + if target is not None and target <= 0: + raise ValueError("Exact amounts require a positive liquidity_target") + if minima != caps: + raise ValueError("Exact amounts require minimum and desired amounts to be equal") + elif mode == "to_liquidity" and minima == caps: + raise ValueError("Flexible recovery requires a wider bound on at least one token") + return result + + def plan_rebalance(*, pool_key: str, position_token_id: str, tick_lower: int, tick_upper: int, slot: g.Slot, position: g.Position, @@ -111,37 +224,29 @@ def plan_rebalance(*, pool_key: str, position_token_id: str, wrapped0: bool, wrapped1: bool, liquidity_target: Optional[int] = None, max_funding0: Optional[int] = None, - max_funding1: Optional[int] = None) -> RebalancePlan: - """Build the exact rebalance from pre-read chain state. Pure. - - Computes everything the transaction asserts: the full recovery of the old - range (principal at the live price, the settled ``tokens_owed``, and the - fees accrued since the checkpoints), the successor range's deposit, and - per side either the funding to add or the surplus to refund. - - Args: - pool_key: The pool the position belongs to. - position_token_id: The position to close. - tick_lower: Successor lower bound, before spacing alignment. - tick_upper: Successor upper bound. - slot: The pool's live slot (price, tick, spacing, fee accumulators). - position: The ``positions`` entry being closed. - lower_tick: The ``ticks`` entry at the position's CURRENT lower bound. - upper_tick: The entry at its current upper bound. - wrapped0: Whether token0 is a wrapper (routes and proofs follow). - wrapped1: Whether token1 is a wrapper. - liquidity_target: Exact successor liquidity — one sizing mode. - max_funding0: Token0 budget on top of what the close returns — the - other mode, together with *max_funding1*; the planner solves for - the largest liquidity the budget supports. ``0`` rebalances on - recovered funds alone. - max_funding1: Token1 budget. - - Raises: - ValueError: On an empty aligned range, both or neither sizing modes, - or a budget that supports no liquidity in the range. + max_funding1: Optional[int] = None, + amount0_desired: Optional[int] = None, + amount1_desired: Optional[int] = None, + amount0_min: Optional[int] = None, + amount1_min: Optional[int] = None, + funded0: Optional[int] = None, funded1: Optional[int] = None, + refund0: Optional[int] = None, refund1: Optional[int] = None) -> RebalancePlan: + """Plan a replacement position from pre-read pool, position, and tick state. + + Without token bounds, a positive ``liquidity_target`` or paired + ``max_funding0/1`` budgets produce an exact quote. Explicit desired amounts + cap consumption; minimums default to those caps. Widening either bound + uses actual recovery, with fixed funding/refunds defaulting to zero. + A zero target estimates liquidity from the capped available funds, but + leaves the submitted target at zero for execution-time sizing. + + All amount arguments accept integers from 0 through 2**128 - 1. + ``max_funding`` budgets cannot be combined with explicit bounds. Bounds + constrain token consumption, not minimum liquidity. Raises ValueError for + invalid bounds, conflicting transfers, or budgets supporting no liquidity. + No network reads, signing, or submission occurs. """ - _sizing(liquidity_target, max_funding0, max_funding1) + _validate_planning(locals()) spacing = int(slot.tick_spacing) lo = round_tick_to_spacing(tick_lower, spacing) @@ -180,40 +285,83 @@ def required_for(liquidity: int) -> tuple[int, int]: # anything the recovered balances do not cover is funding. return amounts_for_liquidity(sqrt_price, sqrt_lo, sqrt_hi, liquidity, round_up=True) - if liquidity_target is not None: + explicit = amount0_desired is not None + amount_mode = liquidity_target == 0 + available0 = recovered0 + (funded0 or 0) - (refund0 or 0) + available1 = recovered1 + (funded1 or 0) - (refund1 or 0) + if explicit and (available0 < 0 or available1 < 0): + raise ValueError("Fixed refunds exceed the quoted recovery and funding") + if liquidity_target is not None and not amount_mode: target = liquidity_target required = required_for(target) else: - assert max_funding0 is not None and max_funding1 is not None - target = liquidity_for_amounts(sqrt_price, sqrt_lo, sqrt_hi, - recovered0 + max_funding0, recovered1 + max_funding1) - # The solve floors and the deposit ceils, so the first target can - # exceed the budget by a rounding unit; step down until it fits. + if amount_mode: + assert amount0_desired is not None and amount1_desired is not None + budget0, budget1 = min(available0, amount0_desired), min(available1, amount1_desired) + else: + assert max_funding0 is not None and max_funding1 is not None + budget0, budget1 = recovered0 + max_funding0, recovered1 + max_funding1 + target = liquidity_for_amounts(sqrt_price, sqrt_lo, sqrt_hi, budget0, budget1) for _ in range(BUDGET_CLAMP_RETRIES + 1): if target <= 0: raise ValueError("The funding budget supports no liquidity in this " "range — raise it or narrow the range") required = required_for(target) - if (required[0] <= recovered0 + max_funding0 - and required[1] <= recovered1 + max_funding1): + if required[0] <= budget0 and required[1] <= budget1: break target -= 1 else: - raise ValueError("Budget sizing did not converge — pass an explicit " - "liquidity_target") + raise ValueError("Budget sizing did not converge — pass an explicit liquidity_target") - funded0 = max(required[0] - recovered0, 0) - funded1 = max(required[1] - recovered1, 0) - refund0 = max(recovered0 - required[0], 0) - refund1 = max(recovered1 - required[1], 0) + min0 = amount0_min if amount0_min is not None else amount0_desired + min1 = amount1_min if amount1_min is not None else amount1_desired + flexible = amount_mode or (explicit and ( + (min0 is not None and amount0_desired is not None and min0 < amount0_desired) or + (min1 is not None and amount1_desired is not None and min1 < amount1_desired))) + funded0 = funded0 if funded0 is not None else (0 if flexible else max(required[0] - recovered0, 0)) + funded1 = funded1 if funded1 is not None else (0 if flexible else max(required[1] - recovered1, 0)) + refund0 = refund0 if refund0 is not None else (0 if flexible else max(recovered0 - required[0], 0)) + refund1 = refund1 if refund1 is not None else (0 if flexible else max(recovered1 - required[1], 0)) route = rebalance_route(wrapped0, wrapped1, funded0 > 0, funded1 > 0) - return RebalancePlan( + plan = RebalancePlan( pool_key=pool_key, position_token_id=position_token_id, tick_lower=lo, tick_upper=hi, old_liquidity=old_liquidity, fees_accrued0=fees0, fees_accrued1=fees1, recovered0=recovered0, recovered1=recovered1, required0=required[0], required1=required[1], funded0=funded0, funded1=funded1, refund0=refund0, refund1=refund1, - liquidity_target=target, function_name=route.function, + liquidity_target=0 if amount_mode else target, function_name=route.function, + amount0_desired=amount0_desired, amount1_desired=amount1_desired, + amount0_min=min0, amount1_min=min1, + estimated_liquidity=target if amount_mode else None, ) + _validate_amounts(asdict(plan)) + return plan + + +def plan_rebalance_exact_amounts(**kwargs: Any) -> RebalancePlan: + """Plan exact quoted recovery and deposits; price or fee changes can reject execution. + + Accepts the state and constraints of :func:`plan_rebalance`. + No network reads, signing, or submission occurs. + """ + return plan_rebalance(**_mode_parameters("exact_amounts", kwargs)) + + +def plan_rebalance_to_liquidity(**kwargs: Any) -> RebalancePlan: + """Plan exact successor liquidity using actual recovery and bounded token consumption. + + Accepts the state and constraints of :func:`plan_rebalance`. + No network reads, signing, or submission occurs. + """ + return plan_rebalance(**_mode_parameters("to_liquidity", kwargs)) + + +def plan_rebalance_from_amounts(**kwargs: Any) -> RebalancePlan: + """Estimate successor liquidity from token caps; the contract sizes it at execution. + + Accepts the state and constraints of :func:`plan_rebalance`. + No network reads, signing, or submission occurs. + """ + return plan_rebalance(**_mode_parameters("from_amounts", kwargs)) diff --git a/shield-swap-sdk/tests/test_agent.py b/shield-swap-sdk/tests/test_agent.py index c4ae81f..7ab7691 100644 --- a/shield-swap-sdk/tests/test_agent.py +++ b/shield-swap-sdk/tests/test_agent.py @@ -9,6 +9,8 @@ CURATED = {"setup_account", "redeem_referral_code", "request_airdrop", "status", "get_swap_execution", "plan_rebalance", "rebalance_position", + "plan_rebalance_exact_amounts", "plan_rebalance_to_liquidity", "plan_rebalance_from_amounts", + "rebalance_position_exact_amounts", "rebalance_position_to_liquidity", "rebalance_position_from_amounts", "get_pools", "get_balances", "get_positions", "swap_many", "mint_position", "adjust_liquidity", "collect_all"} diff --git a/shield-swap-sdk/tests/test_async_parity.py b/shield-swap-sdk/tests/test_async_parity.py index dc37d10..e65d75d 100644 --- a/shield-swap-sdk/tests/test_async_parity.py +++ b/shield-swap-sdk/tests/test_async_parity.py @@ -16,7 +16,9 @@ "swap_many", "collect_all", # require a journal "create_pool", "mint", "increase_liquidity", # LP surface "decrease_liquidity", "collect", "burn", - "rebalance_position", + "rebalance_position", # shared sync LP executor + "rebalance_position_exact_amounts", "rebalance_position_to_liquidity", + "rebalance_position_from_amounts", "get_positions", "find_tick_predecessor", } diff --git a/shield-swap-sdk/tests/test_flexible_rebalance.py b/shield-swap-sdk/tests/test_flexible_rebalance.py new file mode 100644 index 0000000..4978e13 --- /dev/null +++ b/shield-swap-sdk/tests/test_flexible_rebalance.py @@ -0,0 +1,192 @@ +"""Flexible rebalance constraints, planning and submitted request regression tests.""" +from dataclasses import replace + +import pytest + +from aleo_shield_swap import _generated as g +from aleo_shield_swap.client import ShieldSwap +from aleo_shield_swap.rebalance import plan_rebalance +from .test_rebalance import _state, _stub, POOL, POSITION_ID, OLD_LOWER, OLD_UPPER, TOKEN_RECORD + +COMMON = dict(pool_key=POOL, position_token_id=POSITION_ID, + tick_lower=OLD_LOWER, tick_upper=OLD_UPPER) + + +def test_zero_target_survives_equal_bounds(): + p = plan_rebalance(**_state(), liquidity_target=0, amount0_desired=10_000, amount1_desired=10_000) + assert p.liquidity_target == 0 and p.estimated_liquidity > 0 + assert p.required0 <= 10_000 and p.required1 <= 10_000 + assert p.amount0_min == p.amount1_min == 10_000 + assert (p.funded0, p.funded1, p.refund0, p.refund1) == (0, 0, 0, 0) + + +def test_amount_budgets_include_fixed_transfers(): + p = plan_rebalance(**_state(), liquidity_target=0, amount0_desired=1_000_000, + amount1_desired=1_000_000, amount0_min=0, amount1_min=0, + funded0=123, refund1=456) + assert (p.funded0, p.funded1, p.refund0, p.refund1) == (123, 0, 0, 456) + assert p.required0 <= p.recovered0 + 123 and p.required1 <= p.recovered1 - 456 + assert p.function_name == "rebalance_plain_plain_one" + + +def test_one_sided_amount_budget(): + p = plan_rebalance(**_state(tick_lower=600, tick_upper=1200), liquidity_target=0, + amount0_desired=5_000, amount1_desired=0, amount0_min=0, amount1_min=0) + assert p.estimated_liquidity > 0 + assert 0 < p.required0 <= 5_000 and p.required1 == 0 + assert p.function_name == "rebalance_plain_plain_none" + + +def test_explicit_exact_bounds_derive_quote_transfers(): + exact = plan_rebalance(**_state(), liquidity_target=500_000) + p = plan_rebalance(**_state(), liquidity_target=500_000, + amount0_desired=exact.required0, amount1_desired=exact.required1) + assert (p.refund0, p.refund1) == (exact.refund0, exact.refund1) + assert p.funded0 == p.funded1 == 0 + + +@pytest.mark.parametrize("overrides, message", [ + ({"amount0_desired": -1}, "u128"), + ({"amount0_desired": 1 << 128}, "u128"), + ({"funded0": True}, "u128"), + ({"amount0_min": 10_001}, "minimum"), + ({"funded0": 1, "refund0": 1}, "funding.*refund"), + ({"refund0": 1_000_000}, "refunds exceed"), + ({"amount1_desired": None}, "together"), +]) +def test_invalid_constraints(overrides, message): + args = dict(liquidity_target=0, amount0_desired=10_000, amount1_desired=10_000, + amount0_min=0, amount1_min=0) + args.update(overrides) + with pytest.raises(ValueError, match=message): + plan_rebalance(**_state(), **args) + + +@pytest.mark.parametrize("args, message", [ + ({"liquidity_target": 0}, "zero.*requires"), + ({"liquidity_target": 1, "funded0": 1}, "explicit token bounds"), + ({"max_funding0": 0, "max_funding1": 0, + "amount0_desired": 10, "amount1_desired": 10}, "require liquidity_target"), + ({"liquidity_target": 1, "amount0_min": 0}, "minimums require"), + ({"liquidity_target": 500_000, "amount0_desired": 1, "amount1_desired": 1}, "quoted deposit"), +]) +def test_ambiguous_bounds(args, message): + with pytest.raises(ValueError, match=message): + plan_rebalance(**_state(), **args) + + +def test_helpers_preserve_modes_and_prebuilt_bounds(): + dex = ShieldSwap(_stub()) + exact = dex.plan_rebalance_exact_amounts(**COMMON, liquidity_target=500_000) + dex.rebalance_position_exact_amounts(plan=exact) + with pytest.raises(ValueError, match="positive"): + dex.plan_rebalance_exact_amounts(**COMMON, liquidity_target=0) + p = dex.plan_rebalance_to_liquidity(**COMMON, liquidity_target=500_000, + amount0_desired=150_000, amount1_desired=160_000, + amount0_min=90_000, amount1_min=80_000, funded0=123, refund1=456) + result = dex.rebalance_position_to_liquidity(plan=p, token0_record=TOKEN_RECORD).transact() + req = next(g.CoreRebalanceRequest.from_plaintext(x) for x in dex._aleo.last_call[1] + if x.startswith("{ old_liquidity:")) + assert req.liquidity_target == 500_000 + assert (req.mint.amount0_desired, req.mint.amount1_desired) == (150_000, 160_000) + assert (req.mint.amount0_min, req.mint.amount1_min) == (90_000, 80_000) + assert req.funded0 == 123 and req.refund1 == 456 and result.plan == p + with pytest.raises(ValueError, match="equal"): + dex.rebalance_position_exact_amounts(plan=p) + with pytest.raises(ValueError, match="wider"): + dex.rebalance_position_to_liquidity(plan=replace(exact, amount0_desired=exact.required0, + amount1_desired=exact.required1)) + p = dex.plan_rebalance_from_amounts(**COMMON, amount0_desired=10_000, amount1_desired=10_000) + assert p.amount0_min == p.amount1_min == 0 + dex.rebalance_position_from_amounts(plan=p).transact() + req = g.CoreRebalanceRequest.from_plaintext(dex._aleo.last_call[1][2]) + assert req.liquidity_target == 0 + assert req.mint.amount0_desired == 10_000 and req.mint.amount0_min == 0 + with pytest.raises(ValueError, match="zero"): + dex.plan_rebalance_from_amounts(**COMMON, liquidity_target=1, + amount0_desired=10_000, amount1_desired=10_000) + with pytest.raises(ValueError, match="fixed funding"): + dex.plan_rebalance_from_amounts(**COMMON, max_funding0=0, max_funding1=0, + amount0_desired=10_000, amount1_desired=10_000) + + +def test_prebuilt_plan_rejects_overrides_and_bad_accounting(): + p = plan_rebalance(**_state(), liquidity_target=500_000) + dex = ShieldSwap(_stub()) + with pytest.raises(ValueError, match="plan.*overrid"): + dex.rebalance_position(plan=p, liquidity_target=1) + with pytest.raises(ValueError, match="recovery.*funding"): + dex.rebalance_position(plan=replace(p, refund0=p.refund0 + 1)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode, constraints", [ + ("exact_amounts", {"liquidity_target": 500_000}), + ("to_liquidity", {"liquidity_target": 500_000, "amount0_desired": 150_000, "amount1_desired": 160_000}), + ("from_amounts", {"amount0_desired": 10_000, "amount1_desired": 10_000}), +]) +async def test_async_and_exported_planners_match_sync(mode, constraints): + import aleo_shield_swap as sdk + from .test_async_client import AsyncStubAleo + stub = _stub() + sync = getattr(ShieldSwap(stub), f"plan_rebalance_{mode}")(**COMMON, **constraints) + async_dex = sdk.AsyncShieldSwap(AsyncStubAleo(mappings=stub.programs._mappings)) + planned = await getattr(async_dex, f"plan_rebalance_{mode}")(**COMMON, **constraints) + assert planned == sync + assert getattr(sdk, f"plan_rebalance_{mode}")(**_state(), **constraints) == sync + + +@pytest.mark.parametrize("mode, constraints", [ + ("exact_amounts", {"liquidity_target": "500000"}), + ("to_liquidity", {"liquidity_target": "500000", "amount0_desired": "150000", "amount1_desired": "160000"}), + ("from_amounts", {"amount0_desired": str((1 << 100) + 123), "amount1_desired": "10000"}), + ("", {"liquidity_target": "0", "amount0_desired": "10000", "amount1_desired": "10000", "amount0_min": "0", "amount1_min": "0"}), +]) +def test_agent_modes_preserve_constraints_and_integer_precision(mode, constraints): + from aleo_shield_swap.agent import dispatch_tool, shield_swap_tools + suffix = f"_{mode}" if mode else "" + tools = {t["name"]: t for t in shield_swap_tools()} + plan_name, execute_name = "plan_rebalance" + suffix, "rebalance_position" + suffix + assert plan_name in tools and execute_name in tools + assert set(constraints) <= tools[plan_name]["input_schema"]["properties"].keys() + dex = ShieldSwap(_stub()) + args = {**COMMON, **constraints} + planned = dispatch_tool(dex, plan_name, args) + result = dispatch_tool(dex, execute_name, args) + assert result["position_token_id"] == "77field" + assert result["plan"] == planned + if mode == "from_amounts": + assert planned["amount0_desired"] == str((1 << 100) + 123) + assert planned["liquidity_target"] == "0" + assert isinstance(planned["estimated_liquidity"], str) + if mode == "to_liquidity": + assert planned["amount0_min"] == "0" and planned["refund0"] == "0" + + +@pytest.mark.parametrize("mode", ["from_amounts", "to_liquidity"]) +def test_bounded_helpers_reject_invalid_values_before_chain_reads(mode): + dex = ShieldSwap(_stub()) + with pytest.raises(ValueError, match="u128"): + getattr(dex, f"plan_rebalance_{mode}")(**COMMON, liquidity_target=0 if mode == "from_amounts" else 1, + amount0_desired=True, amount1_desired=10_000) + + +@pytest.mark.asyncio +async def test_mcp_exposes_and_executes_amount_planner(): + import json + pytest.importorskip("mcp") + from aleo_shield_swap.mcp import tool_definitions, call_tool + tools = {tool.name: tool for tool in tool_definitions()} + assert "amount0_desired" in tools["plan_rebalance_from_amounts"].inputSchema["required"] + result = await call_tool(ShieldSwap(_stub()), "plan_rebalance_from_amounts", + {**COMMON, "amount0_desired": "10000", "amount1_desired": "10000"}) + plan = json.loads(result[0].text) + assert plan["liquidity_target"] == "0" and int(plan["estimated_liquidity"]) > 0 + + +@pytest.mark.parametrize("value", [True, 1.5, "1.5"]) +def test_agent_rejects_amounts_that_would_be_coerced_or_truncated(value): + from aleo_shield_swap.agent import dispatch_tool + with pytest.raises(ValueError, match="integer|decimal"): + dispatch_tool(ShieldSwap(_stub()), "plan_rebalance_from_amounts", + {**COMMON, "amount0_desired": value, "amount1_desired": "10000"})