Commit 51a6b0b
Release develop → main: swap integrations, feature flags, Pix, Lightning Address, and more (#88)
* Add SideSwap integration: BTC \xe2\x86\x94 L-BTC pegs and asset swap quoting (#27)
* Add SideSwap integration: BTC ↔ L-BTC pegs and asset swap quoting
Mirrors the AQUA Flutter wallet's SideSwap flow over WebSocket JSON-RPC
(wss://api.sideswap.io/json-rpc-ws). Lets users move funds between
Bitcoin mainchain and Liquid via SideSwap pegs, which charge 0.1% (vs
0.2% on instant swap-market trades) at the cost of waiting for chain
confirmations.
- New module src/aqua/sideswap.py: async WS JSON-RPC client with sync
wrappers, dataclasses (SideSwapPeg, SideSwapServerStatus, etc.),
SideSwapPegManager for orchestration, and recommendation logic that
surfaces the time-vs-fee trade-off (and warns about the 102-conf
cold-wallet path on large peg-ins).
- New MCP tools: sideswap_server_status, sideswap_peg_quote,
sideswap_peg_in, sideswap_peg_out, sideswap_peg_status,
sideswap_recommend, sideswap_list_assets, sideswap_quote.
- New prompts: peg_in, peg_out, swap_assets — each guides the agent to
quote, recommend, confirm, execute, and track.
- Persistence in ~/.aqua/sideswap_pegs/{order_id}.json (mode 0o600,
atomic writes; saved before broadcast for crash recovery).
- websockets>=12.0 added to dependencies.
- 38 new tests cover the WS client, peg flow, recommendation logic,
storage round-trip, and quote handling. All 337 tests pass.
Asset swap *execution* (legacy start_swap_web + HTTP swap_start/swap_sign)
is intentionally not implemented: local PSET output verification is
security-critical and needs an audit before live signing. sideswap_quote
returns a quote only and directs users to the AQUA mobile wallet or
sideswap.io for execution.
* Address PR #27 review feedback
Prompt-text consistency:
- server.py:729 / 1217 — use `>= 0.01 BTC (1,000,000 sats)` consistently
in the agent prompts (matches AGENTS.md threshold).
Validation tightening:
- tools.py:sideswap_peg_quote, sideswap_peg_out — explicit `amount > 0`
check before calling into the manager.
- sideswap.py:peg_out — validate `btc_address` parses on the matching
Bitcoin network via bdkpython BEFORE creating the SideSwap order, so
typos / wrong-network addresses don't produce orphaned orders.
- sideswap.py:peg_out — decrypt the wallet mnemonic up-front when the
wallet is encrypted, so a bad password fails fast instead of after a
SideSwap order has already been created. Watch-only / unencrypted
wallets are unaffected.
- sideswap.py:peg_out — reserve 200 sats for the Liquid network fee in
the balance check (Liquid fees are stable and tiny but non-zero, and
the previous check could pass for `balance == amount` then fail at
broadcast).
State machine:
- SideSwapPeg gets a new `local_error: Optional[str]` field. peg_out's
broadcast-failure branch now writes to `local_error` instead of
setting `tx_state = "InsufficientAmount"` — `tx_state` is reserved
for SideSwap server enums (`Detected | Processing | Done |
InsufficientAmount`) so it always reflects what the server reported.
- peg_status: pick the most-progressed entry from the txns list rather
than just `txns[-1]`. SideSwap returns one entry per detected deposit
on the peg address; if the user reuses the address, a fresh `Detected`
deposit can appear after a completed `Done`. The previous code let
the persisted state regress and lost the original `payout_txid`.
Regression test added.
- peg_in: drop the dead `wallet_manager.load_wallet(...)` call. Receiving
a peg-in only needs the wallet's next address, never the mnemonic.
The `password` kwarg stays for signature symmetry.
Misc:
- Hoist `import threading` and `import websockets` to the top of
sideswap.py — neither is optional.
- Let the `update_price_stream` notification timeout propagate in
`fetch_swap_quote` instead of swallowing it. Silent timeouts produced
a price=0.0 quote that looked legitimate to callers.
delete_wallet:
- tools.py:delete_wallet now also removes SideSwap peg records owned by
the wallet (`storage.delete_sideswap_pegs_for_wallet`). Returns
`sideswap_pegs_removed` in the response. New regression test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add SideSwap atomic asset-swap execution via mkt::* (#31)
* Add SideSwap atomic asset-swap execution via mkt::*
Layers atomic asset swap execution on top of the SideSwap peg integration
(#27). Uses SideSwap's modern `mkt::*` WebSocket-only flow with a
security-critical local PSET verifier between get_quote and signing.
Both directions are supported in one go: L-BTC → asset and asset → L-BTC.
Why this PR is structured this way
----------------------------------
This consolidates and supersedes #28, #29, and #30. Those three were
incrementally building toward this end state — first the legacy
`start_swap_web`+HTTP path, then bidirectional support, then the
migration to `mkt::*` — but the legacy path got entirely replaced by
the mkt::* migration, so reviewers would have been auditing scaffolding
that never runs in production. This PR lands only the code that
actually runs.
The verifier
------------
`verify_pset_balances` is a pure function that operates on the dict
returned by `wollet.pset_details(pset).balance.balances()`. Three
rules; any failure raises `PsetVerificationError` and signing is
aborted before any tx hits the wire.
| Rule | Why it matters |
|---|---|
| Wallet must gain *exactly* `recv_amount` of `recv_asset` | Blocks the canonical attack: server's PSET takes our funds and pays us nothing |
| Wallet must lose at most `send_amount + fee_tolerance_sats` of `send_asset` (only if `send_asset == fee_asset`) | Blocks overcharge attacks; tolerance covers Liquid's tens-of-sats network fee |
| No other asset may have a non-zero balance change | Blocks extra-output / siphon attacks |
The manager always passes `fee_asset = policy_asset` (L-BTC), so the
fee tolerance only ever relaxes constraints on the L-BTC side. On
asset → L-BTC the asset side is checked at strict equality — without
this pinning, a hostile server could siphon up to `fee_tolerance_sats`
of USDt (or any other asset) per swap.
Fee model (verified against AQUA Flutter + sideswap_lwk)
-------------------------------------------------------
| Direction | Fee paid by | Wallet effect |
|---|---|---|
| L-BTC → asset | User's L-BTC change | L-BTC `-(send + fee)`, asset `+recv` |
| asset → L-BTC | Dealer's L-BTC change | asset `-send` exact, L-BTC `+recv` exact |
UTXO selection picks only `send_asset` inputs in both cases — no
separate L-BTC fee inputs needed. Mirrors `swap_provider.dart`
`executeTransaction()` in aquawallet/aqua-wallet.
mkt::* wire format
------------------
Top-level method is `"market"`, params is a single-key object whose key
is the snake_case mkt::Request variant. AssetType and TradeDir are
PascalCase strings. Per `sideswap_api/src/mkt.rs`.
```
{"id":1, "method":"market", "params":{"list_markets":{}}}
{"id":2, "method":"market", "params":{"start_quotes":{
"asset_pair":{"base":"...","quote":"..."},
"asset_type":"Quote",
"amount":100000,
"trade_dir":"Sell",
"utxos":[...],
"receive_address":"lq1...",
"change_address":"lq1...",
"instant_swap":true
}}}
{"id":3, "method":"market", "params":{"get_quote":{"quote_id":42}}}
{"id":4, "method":"market", "params":{"taker_sign":{"quote_id":42, "pset":"..."}}}
```
Notification: `{"method":"market", "params":{"quote":{"status":{"Success":{...}}}}}`
What's in this PR
-----------------
- `verify_pset_balances` (pure) + `PsetVerificationError`
- `SideSwapWSClient.mkt`, `mkt_list_markets`, `mkt_start_quotes`,
`mkt_stop_quotes`, `mkt_get_quote`, `mkt_taker_sign`,
`next_market_notification`
- `resolve_market` — picks the matching market and derives
(asset_type, trade_dir) for our taker case (always Sell with
asset_type matching the side we're sending)
- `parse_quote_status` — raises on LowBalance / Error so callers
never proceed with an invalid quote
- `select_swap_utxos` — confidential UTXOs of `send_asset` only,
largest-first
- `SideSwapSwap` dataclass + storage helpers
(`~/.aqua/sideswap_swaps/{order_id}.json`, mode 0o600, atomic
writes; saved at every step for crash recovery)
- `SideSwapSwapManager.execute_swap` — end-to-end orchestrator
- MCP tools `sideswap_execute_swap` (with `send_bitcoins` parameter
for direction) and `sideswap_swap_status`
- Updated `swap_assets` prompt to drive the full quote → confirm →
execute → status flow
Tests (52 new, all 393 in suite passing)
----------------------------------------
Verifier (19): exact-match passes; canonical attacks rejected
(server keeps recv, short delivery, excess delivery, overcharge,
undercharge, unrelated asset movement, fee tolerance limits); same
send/recv asset rejected; arg validation; reverse direction with
`fee_asset=L_BTC` (5 tests including the "documented behavior" test
that fixes the asset-siphon vector).
Helpers (10): `resolve_market` for forward/reverse/swapped pairs/no-
match/malformed; `parse_quote_status` for Success/LowBalance/Error/
missing/unknown.
UTXO selector (5): largest-first, accumulation, asset filtering,
non-confidential rejection, insufficient funds.
Manager forward direction (12): happy path; correct
(asset_type, trade_dir, addresses, instant_swap, UTXOs); three
malicious-PSET attack classes never sign and never POST taker_sign;
unknown wallet; LowBalance / Error quote responses; no-matching-
market; dealer offered wrong send_amount; status returns persisted;
unknown order raises.
Manager reverse direction (7): happy path with
`(asset_type=Base, trade_dir=Sell)`; 500-sat asset-side siphon
rejected (the security tightening this PR enforces); short L-BTC
delivery rejected; unrelated asset movement rejected; picks asset
UTXOs even when wallet also holds L-BTC; insufficient asset balance
raises; rejects asset_id == policy_asset.
Manual testnet smoke test still required before mainnet sign-off.
* Address PR #31 review feedback
- Drop the dead `price = float(quote_data.get("server_fee", 0)) and 0.0`
line in execute_swap — the next statement overwrites `price` from
recv/send unconditionally.
- Add `flexible_small_amount` (default False) to sideswap_execute_swap
and the underlying manager. When True, accept dealer-rounded send
amounts within ±3000 sats of the requested value. Plumbed through the
MCP tool schema.
Why: SideSwap's mkt::* dealer rounds send amounts internally. On small
swaps (5k–25k sats) the dealer's quote frequently comes back at e.g.
5_050 sats when 5_000 was requested, and the strict equality check
rejects them. The reviewer hit this manually at both 5k and 25k sats;
43k sats worked. The new flag opts callers in to accepting small dealer
adjustments while keeping the strict default for larger amounts where a
3k-sat delta would indicate a real price move rather than rounding.
Tests: 3 new cases covering within-tolerance accept, outside-tolerance
reject, and flag-off strict (preserves existing behavior for non-
interactive callers).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address PR #31 follow-up review feedback
Andy flagged two issues after the first round:
- parse_quote_status: validate that the unwrapped Success dict actually
has integer `quote_id`, `base_amount`, `quote_amount` before returning
it. Without this, a malformed payload exploded as KeyError/TypeError
inside execute_swap, far from the cause. Now raises SideSwapWSError
with a clear message — including which field is missing or malformed.
3 new tests cover missing key, non-integer amount, and non-dict Success.
- mkt::* WebSocket session binding: the previous flow opened one WS for
start_quotes / get_quote, did the verify+sign sync, and then opened a
fresh WS for taker_sign. SideSwap rejects this with `protocol error:
wrong client_id` because quote_id is bound to the issuing session.
Refactored execute_swap so a single SideSwapWSClient is held across
the entire mkt::* flow (start_quotes → get_quote → verify → sign →
taker_sign). The verify and sign steps are sync but cheap and run
inside the async with, keeping the WS open until taker_sign completes.
Reference: scripts/probe_sideswap_session_binding.py
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gonzalo Coelho <coelhogonza@gmail.com>
* Add `aqua sideswap` CLI subcommand group (#32)
* Add SideSwap atomic asset-swap execution via mkt::*
Layers atomic asset swap execution on top of the SideSwap peg integration
(#27). Uses SideSwap's modern `mkt::*` WebSocket-only flow with a
security-critical local PSET verifier between get_quote and signing.
Both directions are supported in one go: L-BTC → asset and asset → L-BTC.
Why this PR is structured this way
----------------------------------
This consolidates and supersedes #28, #29, and #30. Those three were
incrementally building toward this end state — first the legacy
`start_swap_web`+HTTP path, then bidirectional support, then the
migration to `mkt::*` — but the legacy path got entirely replaced by
the mkt::* migration, so reviewers would have been auditing scaffolding
that never runs in production. This PR lands only the code that
actually runs.
The verifier
------------
`verify_pset_balances` is a pure function that operates on the dict
returned by `wollet.pset_details(pset).balance.balances()`. Three
rules; any failure raises `PsetVerificationError` and signing is
aborted before any tx hits the wire.
| Rule | Why it matters |
|---|---|
| Wallet must gain *exactly* `recv_amount` of `recv_asset` | Blocks the canonical attack: server's PSET takes our funds and pays us nothing |
| Wallet must lose at most `send_amount + fee_tolerance_sats` of `send_asset` (only if `send_asset == fee_asset`) | Blocks overcharge attacks; tolerance covers Liquid's tens-of-sats network fee |
| No other asset may have a non-zero balance change | Blocks extra-output / siphon attacks |
The manager always passes `fee_asset = policy_asset` (L-BTC), so the
fee tolerance only ever relaxes constraints on the L-BTC side. On
asset → L-BTC the asset side is checked at strict equality — without
this pinning, a hostile server could siphon up to `fee_tolerance_sats`
of USDt (or any other asset) per swap.
Fee model (verified against AQUA Flutter + sideswap_lwk)
-------------------------------------------------------
| Direction | Fee paid by | Wallet effect |
|---|---|---|
| L-BTC → asset | User's L-BTC change | L-BTC `-(send + fee)`, asset `+recv` |
| asset → L-BTC | Dealer's L-BTC change | asset `-send` exact, L-BTC `+recv` exact |
UTXO selection picks only `send_asset` inputs in both cases — no
separate L-BTC fee inputs needed. Mirrors `swap_provider.dart`
`executeTransaction()` in aquawallet/aqua-wallet.
mkt::* wire format
------------------
Top-level method is `"market"`, params is a single-key object whose key
is the snake_case mkt::Request variant. AssetType and TradeDir are
PascalCase strings. Per `sideswap_api/src/mkt.rs`.
```
{"id":1, "method":"market", "params":{"list_markets":{}}}
{"id":2, "method":"market", "params":{"start_quotes":{
"asset_pair":{"base":"...","quote":"..."},
"asset_type":"Quote",
"amount":100000,
"trade_dir":"Sell",
"utxos":[...],
"receive_address":"lq1...",
"change_address":"lq1...",
"instant_swap":true
}}}
{"id":3, "method":"market", "params":{"get_quote":{"quote_id":42}}}
{"id":4, "method":"market", "params":{"taker_sign":{"quote_id":42, "pset":"..."}}}
```
Notification: `{"method":"market", "params":{"quote":{"status":{"Success":{...}}}}}`
What's in this PR
-----------------
- `verify_pset_balances` (pure) + `PsetVerificationError`
- `SideSwapWSClient.mkt`, `mkt_list_markets`, `mkt_start_quotes`,
`mkt_stop_quotes`, `mkt_get_quote`, `mkt_taker_sign`,
`next_market_notification`
- `resolve_market` — picks the matching market and derives
(asset_type, trade_dir) for our taker case (always Sell with
asset_type matching the side we're sending)
- `parse_quote_status` — raises on LowBalance / Error so callers
never proceed with an invalid quote
- `select_swap_utxos` — confidential UTXOs of `send_asset` only,
largest-first
- `SideSwapSwap` dataclass + storage helpers
(`~/.aqua/sideswap_swaps/{order_id}.json`, mode 0o600, atomic
writes; saved at every step for crash recovery)
- `SideSwapSwapManager.execute_swap` — end-to-end orchestrator
- MCP tools `sideswap_execute_swap` (with `send_bitcoins` parameter
for direction) and `sideswap_swap_status`
- Updated `swap_assets` prompt to drive the full quote → confirm →
execute → status flow
Tests (52 new, all 393 in suite passing)
----------------------------------------
Verifier (19): exact-match passes; canonical attacks rejected
(server keeps recv, short delivery, excess delivery, overcharge,
undercharge, unrelated asset movement, fee tolerance limits); same
send/recv asset rejected; arg validation; reverse direction with
`fee_asset=L_BTC` (5 tests including the "documented behavior" test
that fixes the asset-siphon vector).
Helpers (10): `resolve_market` for forward/reverse/swapped pairs/no-
match/malformed; `parse_quote_status` for Success/LowBalance/Error/
missing/unknown.
UTXO selector (5): largest-first, accumulation, asset filtering,
non-confidential rejection, insufficient funds.
Manager forward direction (12): happy path; correct
(asset_type, trade_dir, addresses, instant_swap, UTXOs); three
malicious-PSET attack classes never sign and never POST taker_sign;
unknown wallet; LowBalance / Error quote responses; no-matching-
market; dealer offered wrong send_amount; status returns persisted;
unknown order raises.
Manager reverse direction (7): happy path with
`(asset_type=Base, trade_dir=Sell)`; 500-sat asset-side siphon
rejected (the security tightening this PR enforces); short L-BTC
delivery rejected; unrelated asset movement rejected; picks asset
UTXOs even when wallet also holds L-BTC; insufficient asset balance
raises; rejects asset_id == policy_asset.
Manual testnet smoke test still required before mainnet sign-off.
* Add `aqua sideswap` CLI subcommand group
Mirrors the SideSwap MCP tool surface as Click commands so users can drive
pegs and atomic asset swaps from a shell without spinning up the MCP
server. The security-critical layer (PSET verification, fee_asset
pinning) lives in the manager and is unchanged here; this PR is just
argument parsing + output, following the patterns already established by
`aqua liquid`, `aqua btc`, and `aqua lightning`.
Subcommands
-----------
```
aqua sideswap status [--network …] # server_status
aqua sideswap recommend --amount … --direction … # peg vs swap helper
aqua sideswap peg-quote --amount … [--peg-out] # fee preview
aqua sideswap peg-in [--wallet-name …] # returns BTC deposit
aqua sideswap peg-out --amount … --btc-address … # broadcasts L-BTC
aqua sideswap peg-status --order-id … # poll
aqua sideswap assets [--network …]
aqua sideswap quote --asset-ticker … --send-amount … [--reverse]
aqua sideswap swap --asset-ticker … --amount … [--reverse] [--yes]
aqua sideswap swap-status --order-id …
```
Notable details:
- `swap` fetches a fresh quote and prompts for explicit confirmation
before signing; pass `--yes` to skip the prompt for scripted use
- Password resolution follows the same pattern as the rest of the CLI:
`--password-stdin` flag → `AQUA_PASSWORD` env var → no password
- Asset ID can be specified by hex (`--asset-id`) or by ticker
(`--asset-ticker`, e.g. USDt) — same as `aqua liquid send-asset`
- The `swap` subcommand resolves tickers against the wallet's network
so testnet aliases work correctly
- Output formatting (JSON vs pretty) and error handling reuse the
existing `run_tool` / `render` infrastructure
Tests (23 new, 416 total passing)
---------------------------------
The CLI tests inject fake `SideSwapPegManager` and `SideSwapSwapManager`
into the global tool layer so we exercise argument parsing, output
shape, and proper invocation of the manager methods without ever
touching the network. Coverage:
- server status: default and explicit network
- recommend: forward direction, reverse direction, bad direction errors
- peg-quote: default is peg-in, --peg-out switches direction
- peg-in: returns deposit address, passes wallet_name through
- peg-out: returns lockup_txid, validates positive amount, requires btc-address
- peg-status: passes order_id through
- assets: uses fetch_assets (patched directly)
- quote: requires exactly one of send/recv amount, exactly one of id/ticker;
resolves ticker to asset_id; unknown ticker errors
- swap: --yes skips prompt; --reverse flips send_bitcoins; rejects amount=0
- swap-status: passes order_id through
* Add SIDESWAP_API_KEY to login in SideSwapClient
* Address PR #32 review feedback
- Drop unused `from ..wallet import WalletManager` in cli/sideswap.py.
- Replace the hardcoded "the asset" label in the swap-confirmation prompt
with the real ticker from `lookup_asset` (or the user's --asset-ticker
override, or a truncated id if unknown).
- Use `click.ClickException` for quote-fetch network failures instead of
`click.UsageError` — network problems aren't user input errors.
- Document SideSwap Peg and Swap file structures in AGENTS.md (placed
next to the Lightning swap structure).
Plus: fix the preview vs execution drift the reviewer raised. The CLI's
preview goes through `subscribe_price_stream` while the executor uses the
mkt::* path, so the rate the user confirms can differ from the rate the
PSET locks in. Added a `min_recv_amount` floor to `sideswap_execute_swap`
(plumbed from CLI → tools → manager). The CLI passes the recv_amount the
user just confirmed; if the dealer's mkt::* quote comes back below it the
swap is rejected before signing. Default None preserves existing behavior
for non-interactive callers.
Drive-by: fixed `peg-quote --peg-out` flag (the `flag_value=False,
default=True` combo wasn't producing the documented default; switched to
a plain `is_flag=True` and inverted in the body). This also unbroke
`tests/test_cli.py::TestSideSwapPegQuote::test_peg_quote_default_is_peg_in`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Gonzalo Coelho <coelhogonza@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* :broom: Code clean-up
* :book: Update websockets dependency in uv.lock
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gonzalo Coelho <coelhogonza@gmail.com>
* Add SideShift.ai cross-chain swap integration (#33)
* Add SideShift.ai cross-chain swap integration
Adds cross-chain swap support via sideshift.ai. Complementary
to (not redundant with) the SideSwap stack in earlier PRs:
- SideSwap: atomic on Liquid (PSET) + Liquid Federation pegs for
BTC ↔ L-BTC. Trustless / federation. Liquid-only universe.
- SideShift: 30+ chains, 200+ assets. Use when at least one leg is on
a non-Liquid chain (Ethereum, Tron, Solana, USDt-on-other-chains).
The killer flow this unlocks is USDt-on-expensive-chains ↔ USDt-Liquid:
get users out of high-fee networks into cheap Liquid in one tool call.
What's in this PR
-----------------
- `src/aqua/sideshift.py` — REST client (stdlib urllib, like Boltz/Ankara),
data classes (`SideShiftCoin`, `SideShiftPairInfo`, `SideShiftQuote`,
`SideShiftShift`), high-level `SideShiftManager` orchestrating send /
receive / status flows, plus `recommend_shift_or_swap` to help agents
pick between SideSwap and SideShift.
- 7 new MCP tools (`sideshift_*` prefix):
sideshift_list_coins / sideshift_pair_info / sideshift_quote
sideshift_send / sideshift_receive / sideshift_status
sideshift_recommend
- 2 new prompts: `cross_chain_send`, `cross_chain_receive`.
- New CLI subcommand group: `aqua sideshift {coins,pair-info,quote,send,
receive,status,recommend}`. Mirrors the MCP tool surface; `send` shows a
fresh quote and prompts for confirmation by default (--yes to skip).
- Storage at ~/.aqua/sideshift_shifts/{shift_id}.json, mode 0o600,
atomic writes; persisted before broadcast on send so a stuck shift
is recoverable.
Affiliate
---------
Affiliate ID is `PVmPh4Mp3` — the same one AQUA Flutter wallet
ships with (publicly committed in their `lib/config/constants/api_keys.dart`).
Commission accrues to JAN3's SideShift account. Pass an empty string to
`SideShiftClient(affiliate_id="")` to disable.
Wire-format quirks documented in the module + AGENTS.md:
- L-BTC is `coin: "BTC", network: "liquid"` (not lbtc-liquid)
- Coin tickers uppercase, networks lowercase on the wire
- All amounts are decimal strings; manager converts to integer sats
before calling our wallet send methods
- Memo networks (TON, Stellar, BNB Beacon) require a memo on the
deposit; the manager surfaces it via the persisted shift record
Tests (85 new, 400 passing total)
---------------------------------
Module tests (test_sideshift.py):
- Decimal → sats conversion (5)
- Status helpers + state machine (5)
- Recommendation (SideSwap vs SideShift) for various pairs (7)
- SideShiftShift dataclass + storage round-trip + 0o600 perms (5)
- REST client: affiliate id resolution, get_coins, get_pair URL building,
request_quote argument validation, create_fixed_shift body shape,
create_variable_shift body shape, get_shift, error extraction,
unreachable host (11)
- Manager send: happy path L-BTC→USDt-Tron, USDt-Liquid asset_id,
BTC→external uses BDK manager, rejects non-native deposit chain,
rejects unknown wallet, persists before broadcast so failure is
recoverable (6)
- Manager receive: into Liquid, into Bitcoin, rejects non-native settle (3)
- Manager status: refreshes record, unknown raises, warns on remote error (3)
CLI tests (added to test_cli.py): 13 covering all 7 subcommands, fake
manager injection, argument validation.
Tool registry test updated for the 7 new tools.
Manual testnet validation still required before mainnet sign-off.
* Drop SIDESHIFT_AFFILIATE_ID env override, hardcode AFFILIATE_ID
The env-var override added complexity for a knob nobody is going to
turn — the affiliate ID is the same for every install, public, and
already overridable per-call via SideShiftClient(affiliate_id=...).
Rename DEFAULT_AFFILIATE_ID → AFFILIATE_ID since there is no longer
a non-default to fall back from.
* Deduplicate AGENTS.md SideShift sections
The two sections covered the same trust model and memo network warnings.
Keep the user-facing section (under Tools) as the canonical source for
those, scope the lower section to integration/wire-format detail with a
pointer up.
* Address PR #33 review feedback
- Hoist L-BTC asset id to assets.py (LBTC_ASSET_ID); drop the duplicate
_LIQUID_LBTC_POLICY_ASSET constant in sideshift.py.
- Drop unused SideShiftClient.set_refund_address and the
LIQUID_ASSET_TO_SIDESHIFT_COIN dict (dead code).
- "review" is no longer treated as a final SideShift status — per their
docs it's a risk-management hold that can still resolve to settled or
refunded.
- On deposit broadcast failure, always set shift.status = "failed" rather
than preserving the prior server status.
- Thread quote_id through sideshift_send → SideShiftManager.send_shift so
the CLI can pass the just-confirmed preview["id"] and the shift executes
at the rate the user actually saw. Without quote_id the manager fetches
a fresh quote (existing behavior), which is fine for non-interactive
callers but can drift from any earlier preview.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 🧹 Code clean-up
* Add 'failed' status to _FAILED_STATUSES
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gonzalo Coelho <coelhogonza@gmail.com>
* Add Changelly USDt cross-chain swap integration (#34)
* Add Changelly USDt cross-chain swap integration
Adds USDt cross-chain swap support via Changelly, routed through
AQUA's Ankara backend proxy at https://ankara.aquabtc.com/api/v1/changelly.
Scope (mirrors AQUA Flutter's `ChangellyAssetIds` set in
lib/features/changelly/models/changelly_models.dart): USDt-Liquid ↔ USDt
on the same 6 external chains we allow in SideShift — Ethereum, Tron, BSC,
Solana, Polygon, TON. 6 chains × 2 directions = 12 ordered pairs. Override
`CHANGELLY_ALLOW_ALL_PAIRS=1` to bypass for testing or power use.
What's in this PR
-----------------
- `src/aqua/changelly.py` — REST client, dataclasses (`ChangellySwap`,
`ChangellyQuote`), high-level `ChangellyManager` orchestrating send /
receive / status, plus pair-allowlist enforcement and status helpers.
- 5 new MCP tools (`changelly_*` prefix):
changelly_list_currencies / changelly_quote
changelly_send / changelly_receive / changelly_status
- 2 new prompts: `usdt_cross_chain_send`, `usdt_cross_chain_receive`.
- New CLI subcommand group: `aqua changelly {currencies,quote,send,
receive,status}`. `send` shows a fresh quote and prompts for confirmation
by default (--yes to skip).
- Storage at ~/.aqua/changelly_swaps/{order_id}.json, mode 0o600, atomic
writes; persisted before broadcast on send so a stuck order is recoverable.
Tests (62 new, 389 passing total)
---------------------------------
Module tests (test_changelly.py):
- Decimal → sats conversion (4)
- Status helpers + state machine (8)
- Network → asset id mapping (9)
- Allowlist contract + override semantics (16)
- ChangellySwap dataclass + storage round-trip + 0o600 perms (5)
- REST client: base URL handling, get_currencies (wrapped + bare), get_pairs
lowercasing, fixed/variable quote bodies, transaction creation, status
parsing, error extraction, unreachable host (12)
- Manager send: happy path, unknown network, unknown wallet, persists before
broadcast so failure is recoverable (4)
- Manager receive: deposit address, unknown network (2)
- Manager status: refreshes record, unknown raises, warns on remote error (3)
CLI tests (added to test_cli.py): 13 covering all 5 subcommands, fake
manager injection, argument validation.
Tool registry test updated for the 5 new tools.
Manual testnet validation still required before mainnet sign-off.
* Add suggestions to Changelly PR (#35)
* 🧹 Fix minor issues and added extra validations
---------
Co-authored-by: Gonzalo Coelho <34550772+coelhogonzalo@users.noreply.github.com>
Co-authored-by: Gonzalo Coelho <coelhogonza@gmail.com>
* Add Pix → DePix on-ramp (Eulen API) (#36)
* Add Pix → DePix on-ramp via Eulen API
Adds a Brazilian Real on-ramp: agents mint a Pix charge with
pix_receive(amount_cents, wallet_name); the user pays it from their
banking app's "Pix Copia e Cola" field; Eulen credits DePix to the
wallet's next address. Status polling via pix_status — no claim step,
since Eulen pushes DePix automatically once the Pix payment settles.
The flow mirrors the existing Ankara Lightning-receive architecture:
new src/aqua/pix.py module (EulenClient + PixSwap + PixManager),
~/.aqua/pix_swaps/{id}.json persistence with 0o600 permissions, and
two MCP tools (pix_receive, pix_status) plus a receive_via_pix prompt.
DePix is already in the asset registry.
Configuration: EULEN_API_TOKEN (required, from
https://depix.info/#partners) and EULEN_API_URL (defaults to
https://depix.eulen.app/api). Amount is in BRL cents (100 = R$1.00) —
tool description and prompt warn loudly to prevent 100× errors.
30 new tests (tests/test_pix.py); full suite: 343 passed, 2 skipped.
* 🧹 Minor fixes
* 🧹 Code clean-up and fixes for minor issues
---------
Co-authored-by: Gonzalo Coelho <coelhogonza@gmail.com>
* Add Lightning Address (LUD-16) support to lightning_send (#37)
* Add Lightning Address (LUD-16) support to lightning_send
`lightning_send` now accepts either a BOLT11 invoice or a Lightning Address
(user@domain). LN addresses are resolved via the LUD-16 well-known endpoint
to a BOLT11 invoice, then paid through the existing Boltz submarine-swap
flow — identical architecture to the AQUA Flutter wallet, which also routes
LN-address payments via Boltz.
Validation mirrors AQUA Flutter (lnurl_provider.dart): cross-check the
returned BOLT11 amount against the requested amount and reject zero-amount
invoices. LUD-06 description-hash verification is intentionally not enforced,
matching the Flutter wallet's pragmatic compatibility behaviour.
A new `amount_sats` parameter is required for LN addresses and optional for
BOLT11 (must match the encoded amount when supplied).
* 🧹 Code clean-up + ln amount validation
* 🧹 Adjust docs and ValueError msg for Lightning Address
---------
Co-authored-by: Gonzalo Coelho <coelhogonza@gmail.com>
* Add AQUA ASCII banner to CLI --help and bump to 0.4.0 (#38)
* banner in cli
* Update banner.py
* update version
* Update main.py
* Add agentic-aqua console script alias for uvx compatibility (#57)
Adds `agentic-aqua = "aqua.server:main"` to [project.scripts] so that
`uvx agentic-aqua` works as documented in the README. Previously the
package only exposed `aqua` and `aqua-mcp`, causing the recommended
MCP server setup to fail.
* #22 Use fallback BTC explorer when Esplora connection drops (#58)
* Add fallback
* Update test_bitcoin.py
* Limit broadcast fallback to sync_wallet only
send() was using _with_client_fallback for broadcast, which could surface
a spurious failure when the first Esplora accepted the tx but dropped the
connection before returning a response — the second client would then return
"already known" and the caller would see an error despite a successful broadcast.
Broadcast now uses the primary client only with transient-error retry.
_with_client_fallback is retained for sync_wallet where cross-client fallback
is safe and valuable (idempotent scan, expensive to restart).
* Fix on non-transient
* Address issues in the develop branch (#45)
* Fix issues on develop branch
* 🧹🧪 Test no longer mocks lightinng manager
* fix: mock Liquid sync_wallet in unified balance tests (#46)
Two tests in TestUnifiedImportAndBalance were failing in network-restricted
environments because the Liquid manager's sync_wallet was not being mocked,
causing a live Electrum connection attempt.
- Added patch.object(get_manager(), 'sync_wallet') alongside the existing
BTC patch in test_unified_balance_aggregates_both_networks
- Wrapped unified_balance() call in test_unified_balance_wallet_without_btc_descriptors
with patch.object(manager, 'sync_wallet')
- Added get_manager to imports
Co-authored-by: Nathan Leniski <natel@Nates-MacBook-Pro.local>
* fix: cast AnyUrl to str in read_resource() to fix silent documentation failure (#47)
* fix: cast AnyUrl to str in read_resource() to fix silent documentation failure
The MCP framework passes uri as a Pydantic AnyUrl object, not a plain str.
Comparing AnyUrl == str always returns False, making all three documentation
resources (quickstart, networks, security) silently unreachable via MCP.
- Added uri = str(uri) as first line of read_resource()
- Added pragma: no cover to stdio entry points (run_server, main, __main__)
* fix: cast AnyUrl to str in read_resource() to fix silent documentation failure
The MCP framework passes uri as a Pydantic AnyUrl object, not a plain str.
Comparing AnyUrl == str always returns False, making all three documentation
resources (quickstart, networks, security) silently unreachable via MCP.
- Added uri = str(uri) as first line of read_resource()
- Added pragma: no cover to stdio entry points (run_server, main, __main__)
---------
Co-authored-by: Nathan Leniski <natel@Nates-MacBook-Pro.local>
* progresive disclosure (#59)
* #54 Changelly fee deducted from received amount — inconsistent with APK version (#62)
* progresive disclosure
* feat(changelly): add --amount-to for fee-from-sender semantics
Closes #54.
The APK takes Changelly fees from the sender; the CLI was taking them
from the recipient. Adds `--amount-to` as a mutually exclusive
alternative to `--amount-from` in both `changelly send` and
`changelly receive`. When provided, the user specifies the amount the
recipient should receive and the wallet covers the fees on top.
Threaded through CLI, tools, the manager (send_swap / receive_swap),
and the MCP server tool schema. Backward compatible: `--amount-from`
continues to work unchanged.
* #50 sideshift send: requires --liquid-asset-id manually for non-L-BTC assets (#60)
* progresive disclosure
* fix(sideshift): auto-resolve liquid_asset_id from ticker
Closes #50.
When `aqua sideshift send --deposit-network liquid` is invoked with a
non-BTC coin and no explicit `--liquid-asset-id`, the CLI now looks the
asset id up in the known-asset registry via `lookup_asset_by_ticker`.
Unknown tickers raise a clear `click.UsageError` instead of a cryptic
`ValueError` from the manager layer. Explicit `--liquid-asset-id`
continues to take precedence.
* #55 expose flexible_small_amount as --flexible flag in sideswap swap CLI (#61)
* progresive disclosure
* feat(sideswap): expose --flexible flag on swap command
Closes #55.
Adds a `--flexible` flag to `aqua sideswap swap` that maps to
`flexible_small_amount=True` in the underlying executor. Required for
small swaps (<25k sats) where SideSwap's dealer rounds the send amount
and the strict check rejects every quote. Default remains False to
preserve the safer strict-equality behavior on larger amounts.
* Add ln-address support to lightning CLI
* Document develop as default PR target
* Add CI test workflow (#63)
* Create tests.yml
* Update .gitignore
* tuna (#64)
* v1 mcp name flaged
* Pin Python 3.13 for uv installs (#71)
* Pin Python 3.13 for uv installs
* Restore uv exclude-newer guardrail
* Create CONFIG.md
* #78 fix Pix/DePix smoke-test findings (nonce format, approved status, error detail, flat fee disclosure) (#79)
- Send X-Nonce as dashed UUID (str(uuid.uuid4())); Eulen rejected hex form with HTTP 400.
- Recognize Eulen intermediate status "approved" (Pix received, DePix in flight) instead of warning "unknown status".
- Extract error detail from Eulen's wrapped {"response": {"errorMessage": ...}} on 4xx so root cause surfaces.
- Surface Eulen's flat R$0,99 per-operation fee in pix_receive (fee_cents, fee_brl, net_amount_cents, net_amount_brl + message); MCP tool description and receive_via_pix prompt updated accordingly.
- Tests: update nonce regex to dashed UUID; add coverage for response.errorMessage extraction, approved-status persistence, and fee/net reporting in the tool layer.
* Filter SideShift and Changelly list endpoints to USDt allowlist (#80)
`sideshift_list_coins` returned ~100 KB / 4500 lines, exceeding the MCP
token limit and forcing the agent to read the result from a temp file.
`changelly_list_currencies` returned 764 unrelated tickers that confuse
the agent since our surface only supports USDt cross-chain swaps.
Filter both responses at the manager layer using the existing allowlists
so the agent only ever sees pairs we actually support:
- SideShift: USDT across {ethereum, tron, bsc, solana, polygon, ton,
liquid} plus mainchain BTC. `tokenDetails` and `networksWithMemo` are
pruned to the kept networks. Bypass with SIDESHIFT_ALLOW_ALL_NETWORKS=1.
- Changelly: `lusdt` + the 6 external USDt variants. Bypass with
CHANGELLY_ALLOW_ALL_PAIRS=1.
* Add swap integrations and manual smoke test prompts (#81)
* progresive disclosure
* New smokes
* update prompts
* Update prompt_test_send_real_changelly.md
* Update prompt_test_send_real_sideshift.md
* Update prompt_test_send_real_sideswap.md
* Update README.md
* Update prompt_test_real_pix.md
* Update prompt_test_read_only_integrations.md
* Disable SideSwap and Depix (PIX) tools by default (#82)
* Disable
* Update README.md
* Update commands.py
* Update test_cli.py
* Update test_cli_read_only.py
* Fix lightning send test to neutralize AQUA_PASSWORD from .env (#85)
test_send_ln_address_passes_amount_sats_to_tool asserted password=None
but the CLI loads repo .env at import, so AQUA_PASSWORD set there
(or in the user shell) leaked into resolve_secret and the test failed
locally with password='test'. Pass env=_cli_env() like the other
runner.invoke calls in this file to neutralize the loaded env.
---------
Co-authored-by: Samson <33304104+samsonmow@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gonzalo Coelho <coelhogonza@gmail.com>
Co-authored-by: Gonzalo Coelho <34550772+coelhogonzalo@users.noreply.github.com>
Co-authored-by: Nate L <152243278+marinate305@users.noreply.github.com>
Co-authored-by: Nathan Leniski <natel@Nates-MacBook-Pro.local>
Co-authored-by: Rocket <rocket@tanduwebs.com>1 parent c9a3852 commit 51a6b0b
54 files changed
Lines changed: 15909 additions & 701 deletions
File tree
- .github/workflows
- docs
- scripts/prompts
- src/aqua
- cli
- static
- tests
- smoke
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
28 | 29 | | |
29 | 30 | | |
30 | 31 | | |
| |||
0 commit comments