Skip to content

Commit 8cfce9e

Browse files
[Feat] Add route quotes and complete first-swap funding flow
1 parent fb6d6d4 commit 8cfce9e

24 files changed

Lines changed: 1139 additions & 272 deletions

‎shield-swap-sdk/AGENTS.md‎

Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -222,12 +222,8 @@ them):
222222
input records** — `swap_many` implements the recipe; copy it, don't
223223
improvise.
224224

225-
Suggested path for a new integrator: (1) `onboard()` a profile — it
226-
doubles as a test fixture; (2) walk swap → `collect_all()` once with the
227-
Tier 1 methods so the mechanics are concrete; (3) read the reference below
228-
for the surface your app needs; (4) `tests/integration/` and
229-
`scripts/rehearsal.py` in the repo are working reference implementations
230-
of the full journey.
225+
For the funding, swap, and claim flow, see
226+
`examples/first-swap/`.
231227

232228
Every write method returns a prepared `DexCall`: nothing touches the
233229
network until a terminal method — `.simulate()` (local, free),
@@ -405,48 +401,54 @@ exhausting it raises — the fail-fast for a systematically wrong program.
405401

406402
### Chain methods
407403

408-
### `swap(self, *, pool_key: 'str', token_in_id: 'str', amount_in: 'int | str | Decimal', slippage_bps: 'int' = 50, expected_out: 'Optional[int | str | Decimal]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, record_wait_seconds: 'float' = 0.0, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'`
404+
### `confirm_airdrop(self, *, timeout: 'float' = 600.0, poll_interval: 'float' = 5.0, account: 'Any' = None) -> 'ConfirmAirdropResult'`
409405

410-
Prepare one private swap; submit with ``transact()`` or ``delegate()``.
406+
Fund the account and wait for its airdrop records to become spendable.
411407

412-
Integer ``amount_in`` and ``expected_out`` values are base units.
413-
Strings and ``Decimal`` values are token units (``"1.5"`` means 1.5
414-
tokens), converted exactly with registry metadata. Floats, excess
415-
precision, non-finite values, and amounts outside u128 are rejected.
416-
Returned handle amounts remain in base units.
408+
Requires API authentication and scanner registration. ``timeout`` covers
409+
faucet settlement and scanning. Check ``success`` and ``error`` before
410+
swapping. A record timeout retains the transfer results; inspect those
411+
transactions before requesting another airdrop. Scanner errors propagate.
417412

418-
Quote with ``api.get_route`` and pass ``expected_out``. Without a quote,
419-
the spot estimate ignores fees and price impact. Wrapped inputs use
420-
underlying token records and route through the swap router automatically.
421-
``record_wait_seconds`` waits for a covering record (default 0);
422-
``token_record`` bypasses scanning. Provider errors propagate.
413+
### `quote(self, *, token_in: 'str', token_out: 'str', amount_in: 'str | Decimal', slippage_bps: 'int' = 50) -> 'SwapQuote'`
423414

424-
Preparing a call reserves a blinding counter when a journal is attached,
425-
even if the call is discarded or only simulated. The journal retains the
426-
handle after submission. Without a journal, retain the returned handle
427-
for claiming and avoid concurrent swaps: chain probing cannot reserve
428-
counters atomically. ``identity`` supplies an explicit identity;
429-
``track=False`` bypasses journal reservation.
415+
Quote a direct or multi-hop swap from two symbols and a token amount.
430416

431-
``deadline_offset_blocks`` defaults to 10,000 (~8 hours at 3s/block)
432-
to allow delegated proving; an expired deadline rejects at finalize.
417+
Returns the API's best route and final slippage floor; pass it to
418+
``swap(quote)``. Requires API authentication. Quoting spends no funds
419+
and needs no record scanning or transaction signature.
433420

434-
### `claim_swap_output(self, handle: 'SwapHandle', *, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[ClaimResult]'`
421+
### `swap(self, quote: 'Optional[SwapQuote]' = None, *, pool_key: 'Optional[str]' = None, token_in_id: 'Optional[str]' = None, amount_in: 'Optional[int | str | Decimal]' = None, slippage_bps: 'Optional[int]' = None, expected_out: 'Optional[int | str | Decimal]' = None, sqrt_price_limit: 'Optional[int]' = None, deadline_offset_blocks: 'int' = 10000, nonce: 'Optional[int]' = None, identity: 'Optional[BlindedIdentity]' = None, token_in_program: 'Optional[str]' = None, token_record: 'Optional[str]' = None, wrapper_proofs: 'Optional[str]' = None, track: 'bool' = True, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[SwapHandle]'`
435422

436-
Claim a private swap's output — phase two of the lifecycle.
423+
Prepare a direct or multi-hop swap; submit with transact/delegate.
424+
425+
Pass a ``SwapQuote`` to execute its full route and final slippage floor.
426+
Alternatively pass pool_key, token_in_id, and amount_in for one pool;
427+
trade parameters cannot override a quote. Legacy strings/Decimal use
428+
token units, integers base units. Returned handle amounts are base units.
429+
430+
Wrapped inputs spend underlying records. ``token_record`` bypasses scanning.
431+
Journaled calls reserve a counter during preparation and retain the
432+
submitted handle. Without a journal, save the handle and avoid concurrent
433+
swaps; use an explicit identity for concurrency. ``track=False`` bypasses
434+
journaling. ``deadline_offset_blocks`` defaults to 10,000 (~8 hours).
435+
436+
### `claim_swap_output(self, handle: 'SwapHandle', *, timeout: 'float' = 0.0, wrapper_proofs: 'Optional[str]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[ClaimResult]'`
437+
438+
Claim a private swap's output.
439+
440+
With a journal, ``delegate(wait=True)`` or ``transact(wait=True)``
441+
records the confirmed claim automatically.
437442

438443
Reads the chain-computed result from ``swap_outputs`` (never an
439444
off-chain service — these amounts gate money movement), proves
440-
ownership of the blinded identity, and claims. A wrapped output or
441-
refund routes automatically through the router, which unwraps to
442-
the signer in the same transaction — even for swaps that started
443-
as direct core calls. The output and any refund arrive as private
444-
records owned by the signer (output first, refund second); the
445-
mapping entry is consumed.
446-
447-
Raises :class:`SwapOutputNotFinalizedError` **at prepare time** when
448-
the output is not readable yet (retry after a few blocks) or was
449-
already claimed.
445+
ownership of the blinded identity, and claims. Wrapped tokens unwrap
446+
automatically. Output and refunds arrive as private records owned by
447+
the signer; claiming consumes the mapping entry.
448+
449+
After swap confirmation, ``timeout`` sets the maximum wait in seconds
450+
(default 0). Missing output is polled every 2s without submitting. Timeout raises
451+
``SwapOutputNotFinalizedError``; other errors propagate.
450452

451453
### `create_pool(self, *, token0_id: 'str', token1_id: 'str', fee: 'int', initial_tick: 'int', tick_spacing: 'Optional[int]' = None, initial_sqrt_price: 'Optional[int]' = None, imports: 'Optional[dict[str, str]]' = None, account: 'Any' = None) -> 'DexCall[TxResult]'`
452454

‎shield-swap-sdk/codegen/gen_context.py‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
TIER1 = ["from_profile", "onboard", "status", "get_positions",
2929
"swap_many", "collect_all"]
30-
TIER2_CLIENT = ["swap", "claim_swap_output", "create_pool", "mint",
30+
TIER2_CLIENT = ["confirm_airdrop", "quote", "swap", "claim_swap_output", "create_pool", "mint",
3131
"increase_liquidity", "decrease_liquidity", "collect", "burn",
3232
"plan_rebalance", "rebalance_position",
3333
"get_pool", "get_slot", "get_swap_output", "get_swap_execution",
@@ -180,12 +180,8 @@
180180
input records** — `swap_many` implements the recipe; copy it, don't
181181
improvise.
182182
183-
Suggested path for a new integrator: (1) `onboard()` a profile — it
184-
doubles as a test fixture; (2) walk swap → `collect_all()` once with the
185-
Tier 1 methods so the mechanics are concrete; (3) read the reference below
186-
for the surface your app needs; (4) `tests/integration/` and
187-
`scripts/rehearsal.py` in the repo are working reference implementations
188-
of the full journey."""
183+
For the funding, swap, and claim flow, see
184+
`examples/first-swap/`."""
189185

190186

191187
def _entry(name: str, fn: object) -> str:

‎shield-swap-sdk/examples/first-swap/README.md‎

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,32 +36,55 @@ query returns HTTP 422, the SDK re-registers the account and retries that query
3636
once. A failed re-registration surfaces its error.
3737

3838
`dex.api.authenticate()` signs the API challenge with the account's key.
39-
`confirm_airdrop()` requests tokens and waits for the faucet job to settle.
39+
`has_swap_balance(source.address, "1.5")` checks for a covering private USDCx
40+
record first. An account with enough funds skips the airdrop. Scanner errors
41+
stop the example rather than triggering funding. Otherwise,
42+
`shield_swap_client.confirm_airdrop()` requests tokens, waits for the faucet
43+
job to settle, then waits for decrypted, unspent token records from the
44+
airdrop's transaction IDs. Older records do not satisfy this check.
4045
It returns `funding.status == "settled"` with per-token outcomes in
4146
`funding.job.results`, or `"rate_limited"` with the faucet's explanation in
4247
`funding.message`. A settled job can contain failed token transfers.
43-
44-
The helper polls every 5 seconds and times out after 10 minutes by default.
48+
The example checks `funding.success` and raises `funding.error` on failure.
49+
Success requires every transfer accepted and its records available; the error
50+
includes the rate-limit reason or unsuccessful transfers and their transaction IDs.
51+
52+
The helper polls every 5 seconds; its default 10-minute timeout covers both
53+
settlement and record scanning. If scanning times out, `funding.success` is
54+
false and `funding.error` lists the pending transaction IDs. The lower-level
55+
`api.confirm_airdrop(address)` only waits for faucet settlement.
4556
`AirdropPendingError.job_id` identifies a timed-out job for further status reads.
46-
A rate-limited account can continue if it already holds enough USDCx.
47-
It looks up USDCx and ETH with `get_token(symbol)`, quotes a direct pool
48-
with `get_route()`, then calls
49-
`swap(...).delegate(wait=True)` with that quote and a 0.5% slippage limit.
50-
The example passes `amount_in="1.5"` and the quote's decimal output directly
51-
to `swap()`. The SDK converts both using token metadata; no unit conversion
52-
is needed in the example. Strings and `Decimal` values represent token units;
53-
integers retain their existing base-unit meaning. Excess precision is rejected.
57+
The example stops on a rate-limit response, missing token results, or any
58+
transfer that was not accepted on chain. For pending transfers, inspect the
59+
reported transaction before requesting another airdrop. API errors and
60+
confirmation timeouts also stop the example before it submits a swap.
61+
`quote(token_in="USDCx", token_out="ETH", amount_in="1.5", slippage_bps=50)`
62+
resolves the symbols and asks the API for its best route. The result includes
63+
`estimated_amount_out`, `minimum_amount_out`, and ordered `hops`. Amounts on the
64+
quote are decimal strings in token units. The slippage floor applies once to
65+
the final output, including for two- and three-hop routes.
66+
67+
`swap(quote).delegate(wait=True)` executes that route in one transaction. The
68+
SDK checks its network, token path, and live pool directions before preparing
69+
it. A quote reserves no records and does not guarantee its estimated price;
70+
refresh the quote before a later submission if prices have changed.
71+
The existing `swap(pool_key=..., token_in_id=..., amount_in=...)` form remains
72+
available. Its integer amounts use base units; strings/Decimal use token units.
5473

5574
The SDK selects a token record and returns the handle needed to claim.
56-
One unspent record must cover 1.5 USDCx. The example sets `record_wait_seconds=120` to let the SDK poll for a covering
57-
record every five seconds. If none appears within two minutes, preparation
58-
raises `InsufficientRecordsError` before proving or submitting a swap. Scanner
59-
errors propagate immediately. Other callers default to no wait.
75+
One unspent record must cover 1.5 USDCx. If the scanner does not return a
76+
covering record, preparation raises `InsufficientRecordsError` before proving
77+
or submitting a swap. Allow newly funded records to become available before
78+
trying again. Scanner errors propagate immediately.
6079

6180
## Completion and recovery
6281

63-
After the swap confirms, `claim_swap_output(handle).delegate(wait=True)`
64-
submits one claim and waits for confirmation. `claim.transaction_id` identifies
82+
After the swap confirms, `claim_swap_output(handle, timeout=5)`
83+
waits up to five seconds for its output mapping, polling missing-output reads
84+
every two seconds. Then `.delegate(wait=True)` submits one claim and waits
85+
for confirmation. Timeout stops before submission; preserve the handle and
86+
inspect the existing swap instead of rerunning the example. Other read errors
87+
propagate immediately. `claim.transaction_id` identifies
6588
the claim and `claim.amount_out` contains the received ETH in base units.
6689
The example writes no console logs. Generated accounts are saved regardless
6790
of the journal setting.
@@ -79,7 +102,9 @@ Do not rerun the whole script to recover a swap.
79102

80103
Set `ENABLE_JOURNAL = True` in `swap.py` to attach the SDK's `Journal` at
81104
`testnet-<account-address>.jsonl` in the working directory. The SDK retains swap
82-
handles there, and the example records the confirmed claim. Keep the file
105+
handles there and automatically records the claim after confirmation.
106+
`delegate(wait=True)` and `transact(wait=True)` update the journal; non-waiting
107+
submissions do not mark a claim confirmed. Keep the file
83108
private: it contains the blinding information needed to claim.
84109

85110
The flag only controls journal attachment. It does not change the account,

‎shield-swap-sdk/examples/first-swap/swap.py‎

Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,59 +10,61 @@
1010

1111

1212
if __name__ == "__main__":
13-
# Load a saved account or use an existing private key.
13+
### STEP 1. Create an Aleo Account ###
14+
### Shield Swap runs on the Aleo blockchain to enable private trading of asset pairs.
15+
### This step configures an Aleo account to enable private trading.
16+
17+
# Use a configured private key or create a new key.
1418
key = os.environ.get("SHIELD_SWAP_PRIVATE_KEY")
1519
if not key:
20+
# Load a saved private key from an existing Profile or create a new one.
1621
profile = Profile.load_or_create(network="testnet")
1722
if profile.network != "testnet":
1823
raise RuntimeError("This example requires a testnet profile")
1924
key = profile.private_key
20-
private_key = testnet.PrivateKey.from_string(key)
25+
26+
# Create an Aleo object capable of talking to the Aleo network and configure an account.
2127
aleo = Aleo(HTTPProvider("https://edge.provable.com/api", network="testnet"))
28+
private_key = testnet.PrivateKey.from_string(key)
2229
account = aleo.account.from_private_key(private_key)
30+
address = str(account.address)
31+
32+
# Register the account with the record scanning service to find its records.
33+
# The SDK decrypts the returned records locally.
2334
registration = aleo.records.register(account)
2435
if not registration["ok"]:
25-
raise RuntimeError(f"Scanner registration failed: {registration['error']}")
26-
dex = ShieldSwap(aleo)
27-
address = str(account.address)
36+
raise RuntimeError(f"Record scanner registration failed: {registration['error']}")
2837

29-
# Optionally retain this account's swap handles in the SDK journal.
38+
### STEP 2. Create a Shield Swap client and fund the account with testnet tokens. ###
39+
# Use this account and scanner for Shield Swap operations and configure a local journal
40+
# to keep track of trading activity.
41+
shield_swap_client = ShieldSwap(aleo)
3042
if ENABLE_JOURNAL:
31-
dex.journal = Journal(f"testnet-{address}.jsonl")
32-
33-
# Sign the API challenge with the account's private key.
34-
dex.api.authenticate(address, lambda message: str(private_key.sign(message.encode())))
35-
36-
# Request testnet tokens and wait for the faucet job to settle.
37-
funding = dex.api.confirm_airdrop(address)
38-
39-
# Look up USDCx and ETH, then find a direct pool.
40-
source = dex.api.get_token("USDCx")
41-
target = dex.api.get_token("ETH")
42-
pool = next(pool for pool in dex.api.get_pools()
43-
if {pool.token0, pool.token1} == {source.id, target.id})
44-
45-
# Quote the selected pool in token units.
43+
shield_swap_client.journal = Journal(f"testnet-{address}.jsonl")
44+
# Authenticate with the shield swap API.
45+
shield_swap_client.api.authenticate(address, lambda message: str(private_key.sign(message.encode())))
46+
# Use existing private USDCx when one record can cover the swap.
47+
source = shield_swap_client.api.get_token("USDCx")
4648
amount_in = "1.5"
47-
quote = dex.api.get_route(
48-
token_in=source.id, token_out=target.id, amount_in=amount_in, pool_key=pool.key,
49+
if not shield_swap_client.has_swap_balance(source.address, amount_in):
50+
# Request testnet tokens and stop if funding fails.
51+
funding = shield_swap_client.confirm_airdrop()
52+
if not funding.success:
53+
raise RuntimeError(funding.error)
54+
55+
### STEP 3. Execute a swap between USDCx and ETH. ###
56+
# Quote the best available route, including intermediate tokens when useful.
57+
# The quote contains the final output estimate and a 0.5% slippage limit.
58+
quote = shield_swap_client.quote(
59+
token_in="USDCx", token_out="ETH", amount_in=amount_in, slippage_bps=50,
4960
)
50-
if not quote.estimated_amount_out:
51-
raise RuntimeError("The selected pool returned no quote")
5261

53-
# Submit one swap and wait for confirmation. Keep the returned handle for the claim.
54-
handle = dex.swap(
55-
pool_key=pool.key,
56-
token_in_id=source.id,
57-
amount_in=amount_in,
58-
expected_out=quote.estimated_amount_out,
59-
slippage_bps=50,
60-
record_wait_seconds=120,
61-
).delegate(wait=True)
62+
# Execute the quoted route in one swap transaction and wait for confirmation.
63+
handle = shield_swap_client.swap(quote).delegate(wait=True)
6264

63-
# Claim the confirmed swap's output once using its returned handle.
64-
claim = dex.claim_swap_output(handle).delegate(wait=True)
65-
if dex.journal is not None:
66-
dex.journal.record_claim(handle.swap_id, claim.transaction_id, claim.amount_out)
65+
# Wait for the confirmed swap's output to become readable, then submit one claim.
66+
claim = shield_swap_client.claim_swap_output(
67+
handle, timeout=5,
68+
).delegate(wait=True)
6769
if claim.amount_out <= 0:
6870
raise RuntimeError("The claim returned no ETH")

0 commit comments

Comments
 (0)