Skip to content

Fix lightning send test to neutralize AQUA_PASSWORD from .env#85

Merged
andycreed0x merged 1 commit into
developfrom
lightning-tests-error
May 27, 2026
Merged

Fix lightning send test to neutralize AQUA_PASSWORD from .env#85
andycreed0x merged 1 commit into
developfrom
lightning-tests-error

Conversation

@andycreed0x

Copy link
Copy Markdown
Collaborator

Purpose

Fix TestLightningCommands.test_send_ln_address_passes_amount_sats_to_tool which fails locally for anyone with AQUA_PASSWORD exported or set in the repo .env. The test asserts password=None is passed to lightning_send, but the CLI loads .env at import time and resolve_secret correctly returns the env value, so the assertion fails.

Description

This is a test bug, not a code bug. The CLI behavior (honor AQUA_PASSWORD env var when --password-stdin isn't passed) is documented and covered by TestResolveSecret::test_resolve_from_env_var. The file already has a _cli_env() helper (tests/test_cli.py:69) whose docstring spells out exactly this trap — "CliRunner's env argument is an overlay, not a full replacement. Because the CLI loads repo .env at import time, omitting AQUA_* keys from a copied env does not remove them from os.environ during invoke()." Other tests in the file use it consistently; this one was missed.

Main Changes

  • 🐛 Pass env=_cli_env() to runner.invoke in test_send_ln_address_passes_amount_sats_to_tool so the loaded AQUA_PASSWORD is neutralized for the assertion.

Checklist

  • No hardcoded values (they should go in constants.py, .env, or our database)
  • Added/updated tests (if necessary)
  • Added/updated relevant documentation (if necessary)

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.
@andycreed0x andycreed0x marked this pull request as ready for review May 27, 2026 22:33
@andycreed0x andycreed0x merged commit 8fa448d into develop May 27, 2026
4 checks passed
@andycreed0x andycreed0x deleted the lightning-tests-error branch May 27, 2026 22:33
andycreed0x added a commit that referenced this pull request May 27, 2026
…ing 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant