From edce0f5f8ac6b1f7896de1a6e2026004226a810f Mon Sep 17 00:00:00 2001 From: Rocket Date: Fri, 17 Jul 2026 20:59:24 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20L-BTC=20funding?= =?UTF-8?q?=20option=20to=20WapuPay=20direct-fiat=20orders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct-fiat orders can now be funded with L-BTC on Liquid, not just USDT. Add a `funding_method` param (default "USDT") threaded through create_order → the MCP tool → the MCP schema → the CLI (`--funding-method`), and make pay_instructions currency-aware: the L-BTC rail sends real satoshis (funding_amount_sat) instead of USDT-derived base units. Also fixes a persistence bug this feature would otherwise trigger: WapuPayOrder.from_dict dropped funding_amount_sat for any Liquid-network order, assuming Liquid always meant USDT. That wiped the real L-BTC sat amount on every reload (order-status, orders, fund-order). Now keyed off funding_currency instead, and total_funding_amount_base_units is cleared on the L-BTC rail so a USDT-scale figure is never paired with the L-BTC asset_id. The quote/preview endpoint stays USDT-only (WapuPay 500s on LBTC there), so wapupay_quote and the CLI's pre-confirm preview are untouched. Verified live against WapuPay's stage sandbox for both rails, including the order-status/fund-order reload paths. --- AGENTS.md | 9 ++- src/aqua/cli/wapupay.py | 28 +++++--- src/aqua/server.py | 24 +++++-- src/aqua/tools.py | 22 +++--- src/aqua/wapupay.py | 102 +++++++++++++++++++++------- tests/test_wapupay.py | 146 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 279 insertions(+), 52 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index afadd16..b3e57e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -180,9 +180,9 @@ which now builds a `Jan3AccountsManager`) and delegates provisioning to it. ## WapuPay (Argentine direct-fiat) `wapupay.py` lets a user pay an Argentine bank account in **ARS**, funded with -**USDT on Liquid**. WapuPay's API is called **directly** (`https://be-prod.wapu.app`), +**USDT or L-BTC on Liquid**. WapuPay's API is called **directly** (`https://be-prod.wapu.app`), not through Ankara. Each call carries WapuPay's own **`X-API-Key`**; -`wapupay_create_order` returns a Liquid USDT funding address; the user pays it +`wapupay_create_order` returns a Liquid funding address; the user pays it with `lw_send_asset` (no auto-pay). `wapupay_exchange_rates` is **public** (no key). WapuPay logic (orders, quotes, `X-API-Key` calls) @@ -218,7 +218,10 @@ lives in `wapupay.py`. - **Enabled by default.** All `jan3_*` / `wapupay_*` tools ship enabled (not in `features._SHIPPED_DISABLED`). Business calls still need a key — env var or provisioned via `wapupay_provision_account`. -- **Rail pinned** to Liquid USDT; WapuPay rejects any other funding rail (400). +- **Funding rail is selectable per order** — USDT (default) or L-BTC — both + settle from a Liquid address (`network=LIQUID`). The quote/preview endpoint + is USDT-only (LBTC returns a 500), so `wapupay_quote` and the create-order + confirmation preview never pass `funding_method`. - JAN3 sessions persist per-email at `~/.aqua/jan3/{email}.json`; the provisioned API key and order records persist under `~/.aqua/wapupay/` — all at `0o600`. Bank PII + tokens + API key are never logged (see `ankara._redact` / diff --git a/src/aqua/cli/wapupay.py b/src/aqua/cli/wapupay.py index 9aad9ce..b231a3b 100644 --- a/src/aqua/cli/wapupay.py +++ b/src/aqua/cli/wapupay.py @@ -1,8 +1,8 @@ -"""WapuPay CLI — Argentine direct-fiat payments funded with USDT on Liquid. +"""WapuPay CLI — Argentine direct-fiat payments funded with USDT or L-BTC on Liquid. -Pay an Argentine bank account (alias / CBU / CVU) in ARS via P2P swap, funded with USDT on -Liquid. These commands call WapuPay directly and require `WAPUPAY_API_KEY` set in -the environment. `create-order` returns a Liquid USDT address to fund; pay it with +Pay an Argentine bank account (alias / CBU / CVU) in ARS via P2P swap, funded with USDT or +L-BTC on Liquid. These commands call WapuPay directly and require `WAPUPAY_API_KEY` set in +the environment. `create-order` returns a Liquid funding address; pay it with `aqua liquid send-asset` and WapuPay settles the ARS payout. """ @@ -30,10 +30,10 @@ @click.group() def wapupay(): - """WapuPay — pay Argentine bank accounts in ARS, funded with USDT on Liquid. + """WapuPay — pay Argentine bank accounts in ARS, funded with USDT or L-BTC on Liquid. Calls WapuPay directly; set WAPUPAY_API_KEY in your environment first. - `create-order` returns a Liquid USDT address to fund — pay it with + `create-order` returns a Liquid address to fund — pay it with `aqua liquid send-asset` and WapuPay settles the pesos. """ @@ -90,18 +90,25 @@ def quote(ctx, amount_ars, transfer_type, alias): "--wallet-name", default="default", show_default=True, help="Wallet you intend to fund from.", ) +@click.option( + "--funding-method", "funding_method", + type=click.Choice(["USDT", "LBTC"]), default="USDT", show_default=True, + help="Funding rail: USDT (default) or LBTC. Both settle on Liquid; LBTC " + "is paid as L-BTC sats, USDT as USDT base units.", +) @click.option( "--yes", "-y", "skip_confirm", is_flag=True, default=False, help="Skip the interactive quote-confirmation prompt.", ) @click.pass_obj def create_order(ctx, amount_ars, alias, transfer_type, receiver_name, refund_address, - wallet_name, skip_confirm): - """Create a direct-fiat order and get a Liquid USDT funding address. + wallet_name, funding_method, skip_confirm): + """Create a direct-fiat order and get a Liquid funding address. Fetches a quote for confirmation, then creates the order and issues funding - instructions. Fund the returned address with `aqua liquid send-asset`; - WapuPay then pays the pesos. This command never broadcasts a payment. + instructions (USDT by default, or L-BTC via --funding-method). Fund the + returned address with `aqua liquid send-asset`; WapuPay then pays the pesos. + This command never broadcasts a payment. """ if refund_address and refund_address.strip(): try: @@ -137,6 +144,7 @@ def create_order(ctx, amount_ars, alias, transfer_type, receiver_name, refund_ad receiver_name=receiver_name, refund_address=refund_address, wallet_name=wallet_name, + funding_method=funding_method, ), ) diff --git a/src/aqua/server.py b/src/aqua/server.py index ccde754..9bb02d5 100644 --- a/src/aqua/server.py +++ b/src/aqua/server.py @@ -1137,12 +1137,14 @@ }, "wapupay_create_order": { "description": ( - "Create a WapuPay order and get a Liquid USDT funding address. " + "Create a WapuPay order and get a Liquid funding address. Funds from " + "USDT (default) or L-BTC — both settle from a Liquid address. " "Creates the tentative (freezing the quote) and issues " - "funding instructions. Returns address_destination (Liquid), asset_id " - "(USDT), funding_amount_usdt, total_amount_usdt, " - "total_funding_amount_base_units, funding_expires_at and a QR. Pay the " - "TOTAL with lw_send_asset (amount = total_funding_amount_base_units); " + "funding instructions. Returns address_destination (Liquid), asset_id, " + "funding_amount_usdt, total_amount_usdt, " + "total_funding_amount_base_units (for USDT), funding_amount_sat (for LBTC), " + "funding_expires_at and a QR. Pay the TOTAL with lw_send_asset (amount " + "and unit depend on funding_method — follow pay_instructions); " "WapuPay then makes a P2P payer settle ARS to the bank account. Does NOT broadcast the " "payment itself — confirm the quote with the user first via wapupay_quote." ), @@ -1167,6 +1169,18 @@ "receiver_name": {"type": "string", "description": "Recipient name (optional)"}, "refund_address": {"type": "string", "description": "Liquid mainnet refund address (lq1…/ex1…) if funding cannot execute (optional); validated before the order is created"}, "wallet_name": {"type": "string", "default": "default", "description": "Wallet you intend to fund from (recorded for tracking)"}, + "funding_method": { + "type": "string", + "enum": ["USDT", "LBTC"], + "default": "USDT", + "description": ( + "Funding rail for the payout — 'USDT' (default) or 'LBTC'. Both " + "settle from a Liquid address. For 'LBTC', WapuPay returns " + "funding_amount_sat (the exact sats of L-BTC to send); for 'USDT', " + "send total_funding_amount_base_units. Follow the returned " + "pay_instructions for the exact amount and unit." + ), + }, }, "required": ["amount_ars", "alias"], }, diff --git a/src/aqua/tools.py b/src/aqua/tools.py index 03e1fec..76da0da 100644 --- a/src/aqua/tools.py +++ b/src/aqua/tools.py @@ -1223,21 +1223,24 @@ def wapupay_create_order( receiver_name: str | None = None, refund_address: str | None = None, wallet_name: str = "default", + funding_method: str = "USDT", ) -> dict[str, Any]: - """Create a WapuPay direct-fiat order and get a Liquid USDT funding address. + """Create a WapuPay direct-fiat order and get a Liquid funding address. Creates the payment tentative (freezing the quote) and immediately issues funding instructions. The order is persisted before funding, so if funding fails you get the order back with `funded=False` and can retry via `wapupay_fund_order` — no silent failure. - The result includes `address_destination` (a Liquid address), `asset_id` - (USDT on Liquid), `funding_amount_usdt`, `total_amount_usdt`, - `total_funding_amount_base_units`, and `funding_expires_at`. Pay the TOTAL - with `lw_send_asset` (amount = `total_funding_amount_base_units`, the exact - total_amount_usdt in USDT base units; asset_id from the response); WapuPay - then settles `amount_ars` ARS to the bank account. This tool never - broadcasts a payment itself. + The result includes `address_destination` (a Liquid address), + `funding_amount_usdt`, `total_amount_usdt`, `funding_expires_at`, and a + `pay_instructions` field that tells you the exact amount and unit to send — + follow it. For the default `USDT` rail, pay the TOTAL with `lw_send_asset` + (amount = `total_funding_amount_base_units`, the exact total_amount_usdt in + USDT base units; asset_id from the response). For the `LBTC` rail, WapuPay + returns `funding_amount_sat` instead, and you send that many sats of L-BTC + via `lw_send_asset`. WapuPay then settles `amount_ars` ARS to the bank + account. This tool never broadcasts a payment itself. Args: amount_ars: amount to pay in Argentine pesos (decimal string, e.g. "10000"). @@ -1254,6 +1257,8 @@ def wapupay_create_order( refund_address: Liquid mainnet address (lq1…/ex1…/VJL…) for a refund if funding cannot execute (optional); validated before the order is created. wallet_name: wallet you intend to fund from (recorded for tracking). + funding_method: funding rail — "USDT" (default) or "LBTC"; both settle + from a Liquid address. Returns: The order record incl. tentative_id, status, address_destination, @@ -1268,6 +1273,7 @@ def wapupay_create_order( receiver_name=receiver_name, refund_address=refund_address, wallet_name=wallet_name, + funding_method=funding_method, ) return _attach_deposit_qr(result, "address_destination") diff --git a/src/aqua/wapupay.py b/src/aqua/wapupay.py index 64317fc..a7e0b0c 100644 --- a/src/aqua/wapupay.py +++ b/src/aqua/wapupay.py @@ -5,7 +5,8 @@ WapuPay's API is called **directly** (``https://be-prod.wapu.app`` by default; override with ``WAPUPAY_BASE_URL`` for staging, e.g. ``be-stage.wapu.app``). Each call carries WapuPay's own ``X-API-Key`` (read lazily from the -``WAPUPAY_API_KEY`` env var); the funding rail is pinned to Liquid USDT. +``WAPUPAY_API_KEY`` env var); the payout is funded from a Liquid address, +with either USDT (default) or L-BTC as the funding rail. WapuPay is the source of truth; we keep only a lightweight local order record for CLI / MCP recovery and tracking. @@ -70,8 +71,14 @@ # WapuPay API key, read lazily per call (see WapuPayManager._require_api_key) WAPUPAY_API_KEY_ENV = "WAPUPAY_API_KEY" -# Fixed funding rail (v1: Liquid USDT only). So we send them explicitly and never offer a choice. +# Funding rail: the payout is always settled from a Liquid address, but the +# caller may fund it with either USDT or L-BTC. Both are sent explicitly on the +# wire. WapuPay returns funding_amount_sat ONLY for the L-BTC rail (USDT is +# priced purely in USDT terms), so downstream code branches on funding_currency +# when telling the user exactly what to send. FUNDING_METHOD_USDT = "USDT" +FUNDING_METHOD_LBTC = "LBTC" +FUNDING_METHODS = (FUNDING_METHOD_USDT, FUNDING_METHOD_LBTC) FUNDING_NETWORK_LIQUID = "LIQUID" # Fiat side is always Argentine pesos @@ -346,10 +353,17 @@ def __post_init__(self) -> None: setattr(self, fld, _to_decimal(value)) def _derive_base_units(self, *, only_if_missing: bool = False) -> None: - """Set total_funding_amount_base_units from total_amount_usdt, - unless it is already set (if only_if_missing is True). - Ensures the correct integer USDT amount (precision-8) for Liquid funding.""" + """Set total_funding_amount_base_units from total_amount_usdt: the exact + integer USDT amount (precision-8) to send on the USDT rail. + USDT-only. On the L-BTC rail the amount to send is funding_amount_sat; + this USDT-scale figure must NEVER be advertised alongside the L-BTC + asset_id (a consumer pairing the two would send ~10^8x too much), so it + is cleared rather than derived.""" + + if self.funding_currency == FUNDING_METHOD_LBTC: + self.total_funding_amount_base_units = None + return if only_if_missing and self.total_funding_amount_base_units is not None: return if self.total_amount_usdt is not None: @@ -365,9 +379,14 @@ def to_dict(self) -> dict: @classmethod def from_dict(cls, data: dict) -> "WapuPayOrder": data = dict(data) - # This cleans up data from older records predating the rename. + # Drop a stale USDT-derived funding_amount_sat from legacy records: the + # USDT-on-Liquid rail never has real sats. The L-BTC-on-Liquid rail DOES + # (funding_amount_sat is the real amount to send), so it must survive a + # reload — key off funding_currency, not just the network, to tell them + # apart. A non-Liquid rail (e.g. Lightning) already short-circuits here. network = (data.get("funding_network") or "").upper() - if network in ("", FUNDING_NETWORK_LIQUID): + currency = (data.get("funding_currency") or "").upper() + if network in ("", FUNDING_NETWORK_LIQUID) and currency in ("", FUNDING_METHOD_USDT): data.pop("funding_amount_sat", None) known = {f.name for f in fields(cls)} # __post_init__ coerces money to Decimal; back-fill the send amount for @@ -711,8 +730,9 @@ def create_order( receiver_name: Optional[str] = None, refund_address: Optional[str] = None, wallet_name: str = "default", + funding_method: str = FUNDING_METHOD_USDT, ) -> dict: - """Create a direct-fiat order and issue Liquid USDT funding instructions. + """Create a direct-fiat order and issue Liquid funding instructions. Two upstream steps, run back-to-back: create-tentative (freezes the quote) then issue-funding (returns the Liquid address). The local order @@ -721,16 +741,23 @@ def create_order( orphan. On funding failure we return the order flagged ``funded=False`` with the error (no silent fake-success); recover with ``fund_order``. + ``funding_method`` selects the rail used to fund the payout — ``"USDT"`` + (default) or ``"LBTC"`` — both settle from a Liquid address. WapuPay + returns ``funding_amount_sat`` (real sats to send) for the L-BTC rail; + for USDT the amount to send is ``total_funding_amount_base_units``. + Returns the order record including ``address_destination`` (Liquid), - ``asset_id`` (USDT), ``funding_amount_usdt`` / ``total_amount_usdt`` / - ``total_funding_amount_base_units`` and ``funding_expires_at``. The - caller pays the TOTAL with ``lw_send_asset`` — this method never + ``asset_id``, ``funding_amount_usdt`` / ``total_amount_usdt`` and + ``funding_expires_at`` — plus ``total_funding_amount_base_units`` (USDT + rail) or ``funding_amount_sat`` (L-BTC rail). The caller pays the amount + named in ``pay_instructions`` with ``lw_send_asset`` — this method never broadcasts. """ # Read the API key up front — before any network call or persistence — # so a missing key fails fast and never leaves a half-created order. key = self._require_api_key() self._validate_type(transfer_type) + self._validate_funding_method(funding_method) if not alias or not alias.strip(): raise ValueError("alias (recipient bank alias / CBU / CVU) is required") refund = ( @@ -744,7 +771,7 @@ def create_order( "amount_ars": _ars_for_wire(d), "type": transfer_type, "alias": alias.strip(), - "funding_method": FUNDING_METHOD_USDT, + "funding_method": funding_method, "network": FUNDING_NETWORK_LIQUID, } if receiver_name and receiver_name.strip(): @@ -885,25 +912,48 @@ def _validate_type(transfer_type: str) -> None: f"type must be one of {TRANSFER_TYPES}, got {transfer_type!r}" ) + @staticmethod + def _validate_funding_method(funding_method: str) -> None: + if funding_method not in FUNDING_METHODS: + raise ValueError( + f"funding_method must be one of {FUNDING_METHODS}, got {funding_method!r}" + ) + @staticmethod def _funded_result(order: "WapuPayOrder") -> dict: result = order.to_dict() result["funded"] = bool(order.address_destination) - if order.address_destination and order.total_funding_amount_base_units is not None: - fee_display = order.fee_amount_usdt if order.fee_amount_usdt is not None else 0 - expires_note = ( - f" Funding window: expires at {order.funding_expires_at} UTC" - f" (convert to the user's local timezone before displaying)." - if order.funding_expires_at - else "" - ) + if not order.address_destination: + return result + + expires_note = ( + f" Funding window: expires at {order.funding_expires_at} UTC" + f" (convert to the user's local timezone before displaying)." + if order.funding_expires_at + else "" + ) + payout_note = ( + f" WapuPay then pays {order.amount_ars} ARS to {order.alias}." + if order.amount_ars and order.alias + else " The ARS payout details (recipient and amount) are not " + "stored locally for this order." + ) - payout_note = ( - f" WapuPay then pays {order.amount_ars} ARS to {order.alias}." - if order.amount_ars and order.alias - else " The ARS payout details (recipient and amount) are not " - "stored locally for this order." + if order.funding_currency == FUNDING_METHOD_LBTC and order.funding_amount_sat is not None: + # L-BTC rail: WapuPay returns the REAL sat amount to send (already + # fee-inclusive). Send sats of L-BTC, never the USDT base units. + result["pay_instructions"] = ( + f"Send exactly {order.funding_amount_sat} sats of L-BTC on Liquid " + f"to {order.address_destination} using lw_send_asset " + f"(asset_id={order.asset_id}). This amount already includes " + f"WapuPay's fee — send the full amount or WapuPay won't " + f"settle.{payout_note}{expires_note}" ) + elif ( + order.funding_currency != FUNDING_METHOD_LBTC + and order.total_funding_amount_base_units is not None + ): + fee_display = order.fee_amount_usdt if order.fee_amount_usdt is not None else 0 result["pay_instructions"] = ( f"Send exactly {order.total_amount_usdt} USDT " f"({order.total_funding_amount_base_units} base units) on Liquid " @@ -912,7 +962,7 @@ def _funded_result(order: "WapuPayOrder") -> dict: f"WapuPay's {fee_display} USDT fee — send the full " f"amount or WapuPay won't settle.{payout_note}{expires_note}" ) - elif order.address_destination: + else: # Thin record (e.g. order created on another device): the funding # response carries no total_amount_usdt, so the exact total isn't # known locally. Don't fabricate a "None" amount (No-lies rule) — diff --git a/tests/test_wapupay.py b/tests/test_wapupay.py index d0d4e79..beed746 100644 --- a/tests/test_wapupay.py +++ b/tests/test_wapupay.py @@ -31,10 +31,13 @@ _mask, _redact, ) +from aqua.assets import LBTC_ASSET_ID from aqua.jan3_accounts import Jan3AccountsManager, Jan3Session from aqua.storage import Storage from aqua.wapupay import ( + FUNDING_METHOD_LBTC, FUNDING_METHOD_USDT, + FUNDING_METHODS, FUNDING_NETWORK_LIQUID, WAPUPAY_BASE_URL, WapuPayApiKey, @@ -222,6 +225,34 @@ def side_effect(req, timeout=None): "funding_expires_at": "2026-05-24T14:35:00Z", } +# L-BTC funding rail: WapuPay returns a real funding_amount_sat (sats to send) +# instead of pricing purely in USDT terms. +CREATE_RESP_LBTC = { + "tentative_id": TENTATIVE_ID, + "status": "CREATED", + "funding_currency": "LBTC", + "funding_network": "LIQUID", + "exchange_rate": 1533.34, + "fee_amount_usdt": 0.13, + "funding_amount_usdt": 15.65, + "funding_amount_sat": 25127, + "total_amount_usdt": 15.78, + "expires_at": "2026-07-17 17:22:10", +} + +FUNDING_RESP_LBTC = { + "tentative_id": TENTATIVE_ID, + "status": "FUNDING_ISSUED", + "address_destination": "lq1qqvx4mehdn5lbtctestaddr", + "asset_id": LBTC_ASSET_ID, + "funding_currency": "LBTC", + "funding_network": "LIQUID", + "funding_amount_sat": 25127, + "funding_amount_usdt": 15.65, + "total_amount_usdt": 15.78, + "funding_expires_at": "2026-07-17T20:22:10Z", +} + # --------------------------------------------------------------------------- # Pure helpers @@ -1254,3 +1285,118 @@ def test_usdt_to_base_units_rejects_non_positive_and_non_finite(): # Sig:4 usdt_to_base_units(bad) # Smallest valid positive amount still works. assert usdt_to_base_units("0.00000001") == 1 + + +# --------------------------------------------------------------------------- +# L-BTC funding rail (funding_method) +# --------------------------------------------------------------------------- + + +def test_create_order_with_lbtc_funding_method(storage): # Sig:5 + """funding_method="LBTC" pins the LBTC rail on the wire and surfaces the real + sat amount to send. Also exercises the from_dict save -> load round-trip: + an LBTC-on-Liquid record must keep its real funding_amount_sat (the bug this + guards against dropped it, mistaking it for a stale USDT-derived value).""" + fake = FakeClient({ + "create_tentative": dict(CREATE_RESP_LBTC), + "issue_funding": dict(FUNDING_RESP_LBTC), + }) + m = make_manager(storage, fake) + out = m.create_order( + amount_ars="24000", alias="al.cbu", transfer_type="fast_fiat_transfer", + funding_method=FUNDING_METHOD_LBTC, + ) + create_call = next(c for c in fake.calls if c[0] == "create_tentative") + body, _create_key = create_call[1] + assert body["funding_method"] == FUNDING_METHOD_LBTC + assert body["network"] == FUNDING_NETWORK_LIQUID + assert out["funded"] is True + assert out["funding_amount_sat"] == 25127 + assert out["funding_currency"] == FUNDING_METHOD_LBTC + instr = out["pay_instructions"] + assert "25127" in instr + assert "sats" in instr + assert "L-BTC" in instr + assert "base units" not in instr + assert "None" not in instr + # Persisted order (reloaded via storage) keeps the real sat amount. + saved = storage.load_wapupay_order(TENTATIVE_ID) + assert saved.funding_amount_sat == 25127 + + +def test_funded_result_lbtc_without_sats_never_emits_usdt_base_units(): # Sig:5 + """Defensive money-safety guard: if an LBTC order ever reaches _funded_result + with a funded address but WITHOUT funding_amount_sat (a WapuPay contract + deviation), it must NOT fall through to the USDT base-units branch. That + branch would emit 'Send exactly USDT () ... + asset_id=' — a USDT-scale figure paired with the L-BTC asset, i.e. + a ~10^8x overpayment (~15.78 L-BTC instead of ~25k sats). It must route to + the safe fetch-status fallback and never advertise a USDT send amount or + base units for the L-BTC rail.""" + order = WapuPayOrder( + tentative_id=TENTATIVE_ID, status="FUNDING_ISSUED", type="fast_fiat_transfer", + amount_ars="24000", alias="al.cbu", created_at="t0", + ) + # LBTC rail, funded address + a USDT total present, but sats absent. + order.apply_tentative({ + "funding_currency": FUNDING_METHOD_LBTC, + "funding_network": FUNDING_NETWORK_LIQUID, + "address_destination": "lq1qqlbtcaddr", + "asset_id": LBTC_ASSET_ID, + "total_amount_usdt": 15.78, + }) + # The USDT-scale base units must never be advertised on the L-BTC rail. + assert order.total_funding_amount_base_units is None + result = WapuPayManager._funded_result(order) + assert result["total_funding_amount_base_units"] is None + instr = result["pay_instructions"] + # No dangerous "Send exactly USDT / base units" instruction for LBTC. + assert "base units" not in instr + assert "Send exactly" not in instr + assert "None" not in instr + + +def test_create_order_rejects_bogus_funding_method(storage): # Sig:5 + """An invalid funding_method is rejected before any network call or + persistence, mirroring the no-api-key / bogus transfer_type safety tests.""" + assert "DOGE" not in FUNDING_METHODS + fake = FakeClient({ + "create_tentative": dict(CREATE_RESP), + "issue_funding": dict(FUNDING_RESP), + }) + m = make_manager(storage, fake) + with pytest.raises(ValueError): + m.create_order( + amount_ars="10000", alias="al.cbu", transfer_type="fiat_transfer", + funding_method="DOGE", + ) + assert fake.calls == [] # never reached the network + assert storage.list_wapupay_orders() == [] # nothing persisted + + +def test_from_dict_keeps_real_sat_on_lbtc_liquid(): # Sig:5 + """Regression guard: an LBTC-on-Liquid record must KEEP its real + funding_amount_sat across a load — only a USDT-on-Liquid (or none/none) + record drops a stale sat value.""" + lbtc = { + "tentative_id": TENTATIVE_ID, "status": "FUNDING_ISSUED", + "type": "fast_fiat_transfer", "amount_ars": "24000", "alias": "al.cbu", + "created_at": "t0", "funding_network": FUNDING_NETWORK_LIQUID, + "funding_currency": FUNDING_METHOD_LBTC, + "funding_amount_sat": 25127, + } + o = WapuPayOrder.from_dict(lbtc) + assert o.funding_amount_sat == 25127 # NOT dropped + + +def test_create_order_defaults_to_usdt_funding_method(storage): # Sig:5 + """Omitting funding_method preserves the USDT default on the wire.""" + fake = FakeClient({ + "create_tentative": dict(CREATE_RESP), + "issue_funding": dict(FUNDING_RESP), + }) + m = make_manager(storage, fake) + m.create_order(amount_ars="10000", alias="al.cbu", transfer_type="fiat_transfer") + create_call = next(c for c in fake.calls if c[0] == "create_tentative") + body, _create_key = create_call[1] + assert body["funding_method"] == FUNDING_METHOD_USDT From f18388a526d028c983b49fa0d761cc71db073db5 Mon Sep 17 00:00:00 2001 From: AndyCreed <11888868+andycreed0x@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:09:59 -0300 Subject: [PATCH 2/4] expires at (#129) --- scripts/prompts/prompt_test_wapupay.md | 2 +- src/aqua/__init__.py | 2 +- src/aqua/server.py | 2 +- src/aqua/tools.py | 4 ++-- src/aqua/wapupay.py | 8 +++----- tests/test_wapupay.py | 4 ++-- 6 files changed, 10 insertions(+), 12 deletions(-) diff --git a/scripts/prompts/prompt_test_wapupay.md b/scripts/prompts/prompt_test_wapupay.md index ae7d948..bc56d6e 100644 --- a/scripts/prompts/prompt_test_wapupay.md +++ b/scripts/prompts/prompt_test_wapupay.md @@ -146,7 +146,7 @@ as the refund address. Show me the funding instructions but DO NOT pay yet. - Invokes `wapupay_create_order(amount_ars="10000", alias="test.alias.mp", receiver_name="Test Receiver", refund_address="lq1qqw4...k6lng")` - Returns `tentative_id`, `status` (`FUNDING_ISSUED`), `address_destination` (Liquid `lq1…/ex1…/VJL…`), `asset_id` (USDT on Liquid), `funding_amount_usdt`, `total_amount_usdt`, - `total_funding_amount_base_units`, `funding_expires_at`, `pay_instructions`, `qr_code_path` + `total_funding_amount_base_units`, `expires_at`, `pay_instructions`, `qr_code_path` - ⚠️ The agent shows the funding address/QR **but must NOT call `lw_send_asset`** — keep this dry --- diff --git a/src/aqua/__init__.py b/src/aqua/__init__.py index 6fed12b..be226a6 100644 --- a/src/aqua/__init__.py +++ b/src/aqua/__init__.py @@ -1,3 +1,3 @@ """Agentic AQUA - Manage Liquid Network and Bitcoin wallets through AI assistants.""" -__version__ = "0.5.1b1" +__version__ = "0.5.2" diff --git a/src/aqua/server.py b/src/aqua/server.py index 9bb02d5..1e93a8a 100644 --- a/src/aqua/server.py +++ b/src/aqua/server.py @@ -1143,7 +1143,7 @@ "funding instructions. Returns address_destination (Liquid), asset_id, " "funding_amount_usdt, total_amount_usdt, " "total_funding_amount_base_units (for USDT), funding_amount_sat (for LBTC), " - "funding_expires_at and a QR. Pay the TOTAL with lw_send_asset (amount " + "expires_at and a QR. Pay the TOTAL with lw_send_asset (amount " "and unit depend on funding_method — follow pay_instructions); " "WapuPay then makes a P2P payer settle ARS to the bank account. Does NOT broadcast the " "payment itself — confirm the quote with the user first via wapupay_quote." diff --git a/src/aqua/tools.py b/src/aqua/tools.py index 76da0da..eefbcd1 100644 --- a/src/aqua/tools.py +++ b/src/aqua/tools.py @@ -1233,7 +1233,7 @@ def wapupay_create_order( `wapupay_fund_order` — no silent failure. The result includes `address_destination` (a Liquid address), - `funding_amount_usdt`, `total_amount_usdt`, `funding_expires_at`, and a + `funding_amount_usdt`, `total_amount_usdt`, `expires_at`, and a `pay_instructions` field that tells you the exact amount and unit to send — follow it. For the default `USDT` rail, pay the TOTAL with `lw_send_asset` (amount = `total_funding_amount_base_units`, the exact total_amount_usdt in @@ -1263,7 +1263,7 @@ def wapupay_create_order( Returns: The order record incl. tentative_id, status, address_destination, asset_id, funding_amount_usdt, total_amount_usdt, - total_funding_amount_base_units, funding_expires_at, funded, + total_funding_amount_base_units, expires_at, funded, pay_instructions, and qr_code_path (QR of the funding address). """ result = get_wapupay_manager().create_order( diff --git a/src/aqua/wapupay.py b/src/aqua/wapupay.py index a7e0b0c..57f1653 100644 --- a/src/aqua/wapupay.py +++ b/src/aqua/wapupay.py @@ -236,7 +236,6 @@ def _to_decimal(value: str | int | float | Decimal) -> Decimal: "address_destination", "asset_id", "expires_at", - "funding_expires_at", "refund_address", "funding_transaction_id", "executed_transaction_id", @@ -337,7 +336,6 @@ class WapuPayOrder: address_destination: Optional[str] = None asset_id: Optional[str] = None expires_at: Optional[str] = None - funding_expires_at: Optional[str] = None refund_address: Optional[str] = None funding_transaction_id: Optional[str] = None executed_transaction_id: Optional[str] = None @@ -748,7 +746,7 @@ def create_order( Returns the order record including ``address_destination`` (Liquid), ``asset_id``, ``funding_amount_usdt`` / ``total_amount_usdt`` and - ``funding_expires_at`` — plus ``total_funding_amount_base_units`` (USDT + ``expires_at`` — plus ``total_funding_amount_base_units`` (USDT rail) or ``funding_amount_sat`` (L-BTC rail). The caller pays the amount named in ``pay_instructions`` with ``lw_send_asset`` — this method never broadcasts. @@ -927,9 +925,9 @@ def _funded_result(order: "WapuPayOrder") -> dict: return result expires_note = ( - f" Funding window: expires at {order.funding_expires_at} UTC" + f" Funding window: expires at {order.expires_at} UTC" f" (convert to the user's local timezone before displaying)." - if order.funding_expires_at + if order.expires_at else "" ) payout_note = ( diff --git a/tests/test_wapupay.py b/tests/test_wapupay.py index beed746..0faaf69 100644 --- a/tests/test_wapupay.py +++ b/tests/test_wapupay.py @@ -222,7 +222,7 @@ def side_effect(req, timeout=None): "address_destination": "lq1qqfunding0address", "asset_id": "ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2", "funding_amount_usdt": 6.99, - "funding_expires_at": "2026-05-24T14:35:00Z", + "expires_at": "2026-05-24T14:35:00Z", } # L-BTC funding rail: WapuPay returns a real funding_amount_sat (sats to send) @@ -250,7 +250,7 @@ def side_effect(req, timeout=None): "funding_amount_sat": 25127, "funding_amount_usdt": 15.65, "total_amount_usdt": 15.78, - "funding_expires_at": "2026-07-17T20:22:10Z", + "expires_at": "2026-07-17T20:22:10Z", } From 2c0ee4a35777d3517a2fd989e53471188000f8ed Mon Sep 17 00:00:00 2001 From: andycreed0x <11888868+andycreed0x@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:55:47 -0300 Subject: [PATCH 3/4] Update test_cli_read_only.py --- tests/smoke/test_cli_read_only.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/smoke/test_cli_read_only.py b/tests/smoke/test_cli_read_only.py index db146d6..41a67db 100644 --- a/tests/smoke/test_cli_read_only.py +++ b/tests/smoke/test_cli_read_only.py @@ -55,6 +55,18 @@ def wallet_name(): reason="slow Esplora scan (50–120s); runs locally, skipped on CI", ) +# TEMPORARY — Boltz outage. Boltz is the swap provider behind Ankara, so +# `lightning receive` fails with `500 POST /api/v1/lightning/swaps/create/`. +# That is an upstream outage, not a regression in this repo, so the Lightning +# smoke tests are skipped by default rather than reported as failures. +# Run them anyway with AQUA_SMOKE_LIGHTNING=1, and delete this marker (plus its +# two usages below) once Boltz is back up. +_SKIP_LIGHTNING_OUTAGE = pytest.mark.skipif( + os.getenv("AQUA_SMOKE_LIGHTNING") != "1", + reason="Boltz is down: Ankara swap creation returns 500. " + "Set AQUA_SMOKE_LIGHTNING=1 to run these anyway.", +) + @pytest.fixture(scope="module") def cli_runner(): @@ -171,6 +183,7 @@ def test_export_btc_descriptor(self, cli_runner, wallet_name): assert result["external_descriptor"].startswith("wpkh(") +@_SKIP_LIGHTNING_OUTAGE class TestSmokeLightningReceive: def test_lightning_receive(self, cli_runner, wallet_name): """Generate a Lightning invoice for 500 sats.""" @@ -185,6 +198,7 @@ def test_lightning_receive(self, cli_runner, wallet_name): TestSmokeLightningReceive._swap_id = result["swap_id"] +@_SKIP_LIGHTNING_OUTAGE class TestSmokeLightningStatus: def test_lightning_status(self, cli_runner): """Check status of the receive swap created above.""" From 8c303b2952c98be720e83c5a9d27347081f3a115 Mon Sep 17 00:00:00 2001 From: andycreed0x <11888868+andycreed0x@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:42:12 -0300 Subject: [PATCH 4/4] Update docs and test to use LBTC also --- README.md | 8 +- src/aqua/AGENTS.md | 2 +- src/aqua/cli/AGENTS.md | 2 +- src/aqua/cli/wapupay.py | 4 +- src/aqua/server.py | 20 +++-- src/aqua/tools.py | 11 +-- src/aqua/wapupay.py | 148 ++++++++++++++++++++++-------- tests/test_cli.py | 35 ++++++++ tests/test_wapupay.py | 194 ++++++++++++++++++++++++++++++++++++++++ 9 files changed, 364 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 2e3f915..c21e73f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ MCP server and CLI for managing **Bitcoin** and **Liquid Network** wallets throu - **Lightning** - Send and receive via Lightning using L-BTC - **Assets** - Native support for L-BTC, USDt, and all Liquid assets - **Swaps & Pegs** - Convert BTC ↔ L-BTC and swap Liquid/cross-chain assets via SideSwap, SideShift, and Changelly -- **JAN3 Account** - Login, Lightning Address, and WapuPay (pay ARS bank accounts with USDT) via your JAN3 account +- **JAN3 Account** - Login, Lightning Address, and WapuPay (pay ARS bank accounts with USDT or L-BTC) via your JAN3 account - **Secure** - Encrypted storage, no remote servers for keys ## Installation @@ -195,13 +195,13 @@ Once connected, you can ask Claude to: | `changelly_receive` | Receive USDt-Liquid from USDt on another chain | | `changelly_status` | Check status of a swap order | -**WapuPay (`wapupay_*`)** — pay Argentine bank accounts in ARS, funded with USDT on Liquid +**WapuPay (`wapupay_*`)** — pay Argentine bank accounts in ARS, funded with USDT or L-BTC on Liquid | Tool | Description | |------|-------------| | `wapupay_exchange_rates` | Current exchange rates (e.g. USDT/ARS); public, no key needed | | `wapupay_quote` | Preview USDT cost, fee, and rate for an ARS payment | -| `wapupay_create_order` | Create a direct-fiat order; returns a Liquid USDT funding address | +| `wapupay_create_order` | Create a direct-fiat order (USDT or L-BTC rail); returns a Liquid funding address | | `wapupay_fund_order` | Re-issue funding instructions for an existing order | | `wapupay_order_status` | Check a direct-fiat order's status | | `wapupay_orders` | List locally-tracked orders | @@ -297,7 +297,7 @@ aqua sideshift send --deposit-coin btc --deposit-network liquid --settle-coin us --settle-address T... --deposit-amount 0.001 --wallet-name default aqua changelly send --external-network tron --settle-address T... --amount-from 100 --wallet-name default -# WapuPay (pay ARS bank accounts, funded with USDT on Liquid) +# WapuPay (pay ARS bank accounts, funded with USDT or L-BTC on Liquid) aqua wapupay quote --amount-ars 10000 --alias some.alias aqua wapupay create-order --amount-ars 10000 --alias some.alias --wallet-name default # then fund the returned address: diff --git a/src/aqua/AGENTS.md b/src/aqua/AGENTS.md index 0536f67..840f2da 100644 --- a/src/aqua/AGENTS.md +++ b/src/aqua/AGENTS.md @@ -22,7 +22,7 @@ Python package implementing the MCP server, wallet engines, and third-party swap | `lnurl.py` | LUD-16 Lightning Address resolution → BOLT11. | `is_lightning_address`, `resolve_lightning_address` | | `changelly.py` | Custodial USDt cross-chain swaps via AQUA's Ankara proxy. Curated allowlist (mirrors AQUA Flutter). | `ChangellyClient`, `ChangellyManager` | | `sideshift.py` | Custodial cross-chain swaps via SideShift.ai. Curated allowlist mirrors AQUA Flutter; affiliate ID `PVmPh4Mp3`. | `SideShiftClient`, `SideShiftManager` | -| `wapupay.py` | WapuPay Argentine direct-fiat calls, made directly with `X-API-Key`. `exchange_rates` is public. create-order returns a Liquid USDT funding address. Rail pinned USDT/LIQUID. API key resolves env `WAPUPAY_API_KEY` → stored `api_key.json` (`_require_api_key`). `wapupay_provision_account(email)` delegates the AQUA-backend call to `Jan3AccountsManager.provision_wapupay_token(email)` (in `jan3_accounts.py`), then stores the key; no-op when one already exists (env or stored) since the backend rotates on every call. JAN3-account auth (the `jan3_*` login / session) lives in `jan3_accounts.py` (injected as `self.jan3`). Enabled by default (wapupay calls still need `WAPUPAY_API_KEY`). | `WapuPayClient`, `WapuPayManager`, `WapuPayApiKey`, `WapuPayOrder` | +| `wapupay.py` | WapuPay Argentine direct-fiat calls, made directly with `X-API-Key`. `exchange_rates` is public. create-order returns a Liquid funding address; the rail is selectable per order via `funding_method` (USDT default / LBTC), both on LIQUID. The L-BTC send amount is `total_amount_sats` (`funding_amount_sat` is the pre-fee payout, record-only). API key resolves env `WAPUPAY_API_KEY` → stored `api_key.json` (`_require_api_key`). `wapupay_provision_account(email)` delegates the AQUA-backend call to `Jan3AccountsManager.provision_wapupay_token(email)` (in `jan3_accounts.py`), then stores the key; no-op when one already exists (env or stored) since the backend rotates on every call. JAN3-account auth (the `jan3_*` login / session) lives in `jan3_accounts.py` (injected as `self.jan3`). Enabled by default (wapupay calls still need `WAPUPAY_API_KEY`). | `WapuPayClient`, `WapuPayManager`, `WapuPayApiKey`, `WapuPayOrder` | | `banner.py` | CLI ASCII banner rendering. | `render_banner` | | `cli/` | Click CLI mirroring MCP tools (see `cli/AGENTS.md`). | — | | `static/` | MCP resource markdown (quickstart, networks, security). Loaded by `server.py` via `aqua://docs/*`. | — | diff --git a/src/aqua/cli/AGENTS.md b/src/aqua/cli/AGENTS.md index f072ac9..5db8b8d 100644 --- a/src/aqua/cli/AGENTS.md +++ b/src/aqua/cli/AGENTS.md @@ -19,7 +19,7 @@ rendered to terminal/JSON instead of MCP responses. | `sideshift.py` | `aqua sideshift` | Cross-chain quote, send, receive, status. | | `changelly.py` | `aqua changelly` | USDt cross-chain quote, send, receive, status. | | `jan3.py` | `aqua jan3` | JAN3 account login + sessions (multi-account, one per email): `login`/`verify` (free email-OTP, default), `login-start`/`login-complete` (paid captchaless fallback), `session-info`/`list-sessions`/`logout`. Maps to the `jan3_*` tools; decoupled from WapuPay's API key. | -| `wapupay.py` | `aqua wapupay` | Argentine direct-fiat (calls WapuPay directly; needs an API key — env `WAPUPAY_API_KEY` or `provision-account --email`): rates (public), quote, create-order (→ Liquid USDT address), fund-order, order-status, transactions, transaction, spending-limit, provision-account (get/rotate the API key via your JAN3 login for `--email`, stored locally). | +| `wapupay.py` | `aqua wapupay` | Argentine direct-fiat (calls WapuPay directly; needs an API key — env `WAPUPAY_API_KEY` or `provision-account --email`): rates (public), quote, create-order (→ Liquid funding address; `--funding-method USDT|LBTC`), fund-order, order-status, transactions, transaction, spending-limit, provision-account (get/rotate the API key via your JAN3 login for `--email`, stored locally). | | `serve.py` | `aqua serve` | Run the MCP stdio server from the CLI. | | `output.py` | helper | JSON / pretty rendering. Pretty mode uses `click.echo`; JSON dumps via `json.dumps(..., indent=2)`. | | `password.py` | helper | Secret resolution chain. See below. | diff --git a/src/aqua/cli/wapupay.py b/src/aqua/cli/wapupay.py index b231a3b..837ed31 100644 --- a/src/aqua/cli/wapupay.py +++ b/src/aqua/cli/wapupay.py @@ -94,7 +94,7 @@ def quote(ctx, amount_ars, transfer_type, alias): "--funding-method", "funding_method", type=click.Choice(["USDT", "LBTC"]), default="USDT", show_default=True, help="Funding rail: USDT (default) or LBTC. Both settle on Liquid; LBTC " - "is paid as L-BTC sats, USDT as USDT base units.", + "is paid as L-BTC sats (total_amount_sats), USDT as USDT base units.", ) @click.option( "--yes", "-y", "skip_confirm", is_flag=True, default=False, @@ -153,7 +153,7 @@ def create_order(ctx, amount_ars, alias, transfer_type, receiver_name, refund_ad @click.option("--tentative-id", required=True, help="Order id from `create-order`.") @click.pass_obj def fund_order(ctx, tentative_id): - """Issue (or re-issue) the Liquid USDT funding instructions for an order.""" + """Issue (or re-issue) the Liquid funding instructions for an order.""" run_tool(ctx, lambda: wapupay_fund_order(tentative_id)) diff --git a/src/aqua/server.py b/src/aqua/server.py index 1e93a8a..819cdbb 100644 --- a/src/aqua/server.py +++ b/src/aqua/server.py @@ -1142,7 +1142,7 @@ "Creates the tentative (freezing the quote) and issues " "funding instructions. Returns address_destination (Liquid), asset_id, " "funding_amount_usdt, total_amount_usdt, " - "total_funding_amount_base_units (for USDT), funding_amount_sat (for LBTC), " + "total_funding_amount_base_units (for USDT), total_amount_sats (for LBTC), " "expires_at and a QR. Pay the TOTAL with lw_send_asset (amount " "and unit depend on funding_method — follow pay_instructions); " "WapuPay then makes a P2P payer settle ARS to the bank account. Does NOT broadcast the " @@ -1176,7 +1176,7 @@ "description": ( "Funding rail for the payout — 'USDT' (default) or 'LBTC'. Both " "settle from a Liquid address. For 'LBTC', WapuPay returns " - "funding_amount_sat (the exact sats of L-BTC to send); for 'USDT', " + "total_amount_sats (the exact sats of L-BTC to send); for 'USDT', " "send total_funding_amount_base_units. Follow the returned " "pay_instructions for the exact amount and unit." ), @@ -1187,7 +1187,7 @@ }, "wapupay_fund_order": { "description": ( - "Issue (or re-issue) Liquid USDT funding instructions for an existing " + "Issue (or re-issue) Liquid funding instructions for an existing " "order. Use to recover an order created without funding, or to refresh " "the funding address before it expires. Returns the funding address + QR." ), @@ -1791,17 +1791,19 @@ def create_server(config: Config | None = None) -> Server: - Memo networks (BNB Beacon, Stellar, etc.) require a memo on either the deposit or settle side — pass settle_memo / refund_memo when prompted. -WAPUPAY (Argentine fiat payouts, funded with USDT on Liquid): +WAPUPAY (Argentine fiat payouts, funded with USDT or L-BTC on Liquid): - WHAT IT IS: WapuPay is NOT an exchange. It is an automated peer-to-peer (P2P) platform — it finds a trusted P2P payer who settles the payment in Argentine pesos (ARS) on the user's behalf (think "Uber for P2P"). The user funds with - USDT on Liquid; a matched payer pushes the pesos to the recipient's bank account. + USDT (default) or L-BTC on Liquid; a matched payer pushes the pesos to the + recipient's bank account. If the user asks "what is WapuPay / what can I do with it", explain this; the full blurb is the aqua://docs/wapupay resource. -- FLOW: wapupay_quote (preview cost) → wapupay_create_order (returns a Liquid USDT - address + amount) → pay it with lw_send_asset → WapuPay settles the ARS payout. +- FLOW: wapupay_quote (preview cost) → wapupay_create_order (funding_method USDT or + LBTC; returns a Liquid address — follow pay_instructions for the exact amount and + unit) → pay it with lw_send_asset → WapuPay settles the ARS payout. This never auto-pays; always confirm the quote with the user first. - After the user pays the Liquid USDT address, WapuPay orchestrates the operation with a P2P payer that settles the ARS. + After the user pays the Liquid funding address, WapuPay orchestrates the operation with a P2P payer that settles the ARS. Offer the user to check the status of the order with `wapupay_order_status` and the executed_transaction_id with `wapupay_transaction`, the executed_transaction contain the details of the fiat transfer that the user wants to know about. - wapupay_exchange_rates is public (use USDT/ARS ignore the others rates, no key). The order/transaction tools @@ -2641,7 +2643,7 @@ async def list_resources() -> list[Resource]: Resource( uri="aqua://docs/wapupay", name="What is WapuPay?", - description="WapuPay overview: automated P2P ARS payouts funded with USDT on Liquid", + description="WapuPay overview: automated P2P ARS payouts funded with USDT or L-BTC on Liquid", mimeType="text/markdown", ), ] diff --git a/src/aqua/tools.py b/src/aqua/tools.py index eefbcd1..b194198 100644 --- a/src/aqua/tools.py +++ b/src/aqua/tools.py @@ -1238,7 +1238,7 @@ def wapupay_create_order( follow it. For the default `USDT` rail, pay the TOTAL with `lw_send_asset` (amount = `total_funding_amount_base_units`, the exact total_amount_usdt in USDT base units; asset_id from the response). For the `LBTC` rail, WapuPay - returns `funding_amount_sat` instead, and you send that many sats of L-BTC + returns `total_amount_sats` instead, and you send that many sats of L-BTC via `lw_send_asset`. WapuPay then settles `amount_ars` ARS to the bank account. This tool never broadcasts a payment itself. @@ -1262,9 +1262,10 @@ def wapupay_create_order( Returns: The order record incl. tentative_id, status, address_destination, - asset_id, funding_amount_usdt, total_amount_usdt, - total_funding_amount_base_units, expires_at, funded, - pay_instructions, and qr_code_path (QR of the funding address). + asset_id, funding_amount_usdt, total_amount_usdt, expires_at, funded, + pay_instructions, and qr_code_path (QR of the funding address). The send + amount is rail-specific: total_funding_amount_base_units on the USDT rail, + total_amount_sats on the LBTC rail. Follow pay_instructions. """ result = get_wapupay_manager().create_order( amount_ars=amount_ars, @@ -1279,7 +1280,7 @@ def wapupay_create_order( def wapupay_fund_order(tentative_id: str) -> dict[str, Any]: - """Issue (or re-issue) Liquid USDT funding instructions for an existing order. + """Issue (or re-issue) Liquid funding instructions for an existing order. Use this to recover an order created without funding, or to re-fetch the funding address before it expires. diff --git a/src/aqua/wapupay.py b/src/aqua/wapupay.py index 57f1653..5616cbb 100644 --- a/src/aqua/wapupay.py +++ b/src/aqua/wapupay.py @@ -1,7 +1,7 @@ -"""WapuPay direct-fiat payments (Argentine ARS payouts funded with Liquid USDT). +"""WapuPay direct-fiat payments (Argentine ARS payouts funded on Liquid). A WapuPay **direct-fiat** order lets a user pay an Argentine bank account -(by alias / CBU / CVU) in **ARS**, funding the payout with **USDT on Liquid**. +(by alias / CBU / CVU) in **ARS**, funding the payout with **USDT or L-BTC on Liquid**. WapuPay's API is called **directly** (``https://be-prod.wapu.app`` by default; override with ``WAPUPAY_BASE_URL`` for staging, e.g. ``be-stage.wapu.app``). Each call carries WapuPay's own ``X-API-Key`` (read lazily from the @@ -25,7 +25,7 @@ Direct-fiat flow (the "order"): quote (preview) → create_order (create-tentative + issue-funding) - → pay the returned Liquid USDT address (lw_send_asset) + → pay the returned Liquid address (lw_send_asset) → WapuPay settles ARS to the bank account. Tentative status machine: ``CREATED → FUNDING_ISSUED → EXECUTED`` with terminals @@ -73,9 +73,10 @@ # Funding rail: the payout is always settled from a Liquid address, but the # caller may fund it with either USDT or L-BTC. Both are sent explicitly on the -# wire. WapuPay returns funding_amount_sat ONLY for the L-BTC rail (USDT is -# priced purely in USDT terms), so downstream code branches on funding_currency -# when telling the user exactly what to send. +# wire. WapuPay returns the sat figures ONLY for the L-BTC rail (USDT is priced +# purely in USDT terms), so downstream code branches on funding_currency when +# telling the user exactly what to send. The send amount is total_amount_sats; +# funding_amount_sat is the pre-fee payout and is kept for the record only. FUNDING_METHOD_USDT = "USDT" FUNDING_METHOD_LBTC = "LBTC" FUNDING_METHODS = (FUNDING_METHOD_USDT, FUNDING_METHOD_LBTC) @@ -96,11 +97,11 @@ # What is WapuPay? WapuPay lets you pay an Argentine bank account in **pesos (ARS)**, funding the -payout with **USDT on the Liquid network**. +payout with **USDT or L-BTC on the Liquid network**. **It is NOT an exchange.** WapuPay is an *automated peer-to-peer (P2P) platform*: it finds a trusted P2P payer who settles the payment in Argentine pesos on your -behalf — think of it as an "Uber for P2P". You send USDT on Liquid; a matched +behalf — think of it as an "Uber for P2P". You send USDT or L-BTC on Liquid; a matched payer pushes the pesos to the recipient's bank account. WapuPay operates as an escrow: @@ -111,7 +112,7 @@ - Check the USDT/ARS exchange rate (`wapupay_exchange_rates` / `aqua wapupay rates`). - Preview the cost of a payment without committing (`wapupay_quote` / `aqua wapupay quote`). -- Create an order and get a Liquid USDT address to fund (`wapupay_create_order` / +- Create an order and get a Liquid funding address (`wapupay_create_order` / `aqua wapupay create-order`); pay that address and WapuPay orchestrates the operation with a P2P payer that settles the ARS. - Track your orders/transactions and check your monthly spending limit. - Where can I send money? To a bank account, alias, CBU, CVU, MercadoPago, Wapu ID, or USDT address, depending on operational availability. @@ -128,13 +129,13 @@ ## After funding -After paying the Liquid USDT address, WapuPay orchestrates the operation with a P2P payer that settles the ARS. +After paying the Liquid funding address, WapuPay orchestrates the operation with a P2P payer that settles the ARS. Check the status of the order often with order-status and take the executed_transaction_id to use it with `transaction --id`, the executed_transaction contain the details of the fiat transfer and the fiat transfer receipt. ## What happens if the order fails? -If the order fails, you will receive the funds back to the Liquid USDT address that you provided in the field refund_address after 24 hours. +If the order fails, you will receive the funds back to the Liquid address that you provided in the field refund_address after 24 hours. If you need support, you can contact WapuPay support at wapupay.com """ @@ -196,7 +197,7 @@ def usdt_to_base_units(amount_usdt: str | int | float | Decimal) -> int: L-USDt has precision 8 (8 decimal places), so 1 USDT = 100_000_000 base units — the same scale as L-BTC satoshis, but these are USDT units, not bitcoin sats. Kept deliberately distinct from WapuPay's wire - ``funding_amount_sat`` (real BTC/Lightning satoshis) so the two are never + ``total_amount_sats`` (real bitcoin satoshis) so the two are never conflated. """ try: @@ -232,6 +233,7 @@ def _to_decimal(value: str | int | float | Decimal) -> Decimal: "fee_amount_usdt", "funding_amount_usdt", "funding_amount_sat", + "total_amount_sats", "total_amount_usdt", "address_destination", "asset_id", @@ -276,7 +278,7 @@ def validate_liquid_refund_address(address: str) -> str: if not parsed.network().is_mainnet(): raise ValueError( f"refund_address {address!r} is not a Liquid mainnet address. " - "WapuPay refunds USDT on Liquid mainnet — use an lq1…/ex1… address." + "WapuPay refunds on Liquid mainnet — use an lq1…/ex1… address." ) return addr @@ -328,8 +330,13 @@ class WapuPayOrder: exchange_rate: Optional[Decimal] = None fee_amount_usdt: Optional[Decimal] = None funding_amount_usdt: Optional[Decimal] = None - # Amount in satoshis for Lightning/BTC funding only; None otherwise. + # RECORD ONLY — never the send amount. WapuPay returns funding_amount_sat as + # the pre-fee payout, mirroring funding_amount_usdt; the amount to send is + # total_amount_sats (mirroring total_amount_usdt). The two sat figures are + # equal today, so reading this one would happen to work — until it doesn't. funding_amount_sat: Optional[int] = None + # The exact L-BTC satoshis to send, fee included. Authoritative on the L-BTC rail. + total_amount_sats: Optional[int] = None total_amount_usdt: Optional[Decimal] = None # Integer USDT amount (precision-8) to send on Liquid; derived from total_amount_usdt. total_funding_amount_base_units: Optional[int] = None @@ -350,16 +357,26 @@ def __post_init__(self) -> None: if value is not None and not isinstance(value, Decimal): setattr(self, fld, _to_decimal(value)) + @property + def is_lbtc(self) -> bool: + """True when the payout is funded with L-BTC rather than USDT. + + Case-insensitive: this drives every money-denomination branch, and + ``funding_currency`` can arrive either from the caller's request or from + WapuPay's echo. + """ + return (self.funding_currency or "").upper() == FUNDING_METHOD_LBTC + def _derive_base_units(self, *, only_if_missing: bool = False) -> None: """Set total_funding_amount_base_units from total_amount_usdt: the exact integer USDT amount (precision-8) to send on the USDT rail. - USDT-only. On the L-BTC rail the amount to send is funding_amount_sat; + USDT-only. On the L-BTC rail the amount to send is total_amount_sats; this USDT-scale figure must NEVER be advertised alongside the L-BTC asset_id (a consumer pairing the two would send ~10^8x too much), so it is cleared rather than derived.""" - if self.funding_currency == FUNDING_METHOD_LBTC: + if self.is_lbtc: self.total_funding_amount_base_units = None return if only_if_missing and self.total_funding_amount_base_units is not None: @@ -377,15 +394,16 @@ def to_dict(self) -> dict: @classmethod def from_dict(cls, data: dict) -> "WapuPayOrder": data = dict(data) - # Drop a stale USDT-derived funding_amount_sat from legacy records: the - # USDT-on-Liquid rail never has real sats. The L-BTC-on-Liquid rail DOES - # (funding_amount_sat is the real amount to send), so it must survive a - # reload — key off funding_currency, not just the network, to tell them - # apart. A non-Liquid rail (e.g. Lightning) already short-circuits here. + # Drop stale sat amounts from legacy records: the USDT-on-Liquid rail + # never has real sats. The L-BTC-on-Liquid rail DOES (total_amount_sats + # is the real amount to send), so it must survive a reload — key off + # funding_currency, not just the network, to tell them apart. A + # non-Liquid rail (e.g. Lightning) already short-circuits here. network = (data.get("funding_network") or "").upper() currency = (data.get("funding_currency") or "").upper() if network in ("", FUNDING_NETWORK_LIQUID) and currency in ("", FUNDING_METHOD_USDT): data.pop("funding_amount_sat", None) + data.pop("total_amount_sats", None) known = {f.name for f in fields(cls)} # __post_init__ coerces money to Decimal; back-fill the send amount for # legacy records that predate total_funding_amount_base_units. @@ -414,7 +432,18 @@ def apply_tentative(self, resp: dict) -> None: # total_amount_usdt to avoid stale values; distinct from funding_amount_sat (BTC). self._derive_base_units() - # Ensure funding_amount_sat remains an integer per WapuPay spec. + # Sats are integers end-to-end (see CLAUDE.md invariant 1). total_amount_sats + # is the L-BTC send amount, so a fractional wire value is a contract + # violation, not something to round: truncating it would underpay and + # WapuPay would not settle. + if isinstance(self.total_amount_sats, float): + if not self.total_amount_sats.is_integer(): + raise ValueError( + f"WapuPay returned a fractional total_amount_sats: " + f"{self.total_amount_sats!r} (satoshis must be whole)" + ) + self.total_amount_sats = int(self.total_amount_sats) + # funding_amount_sat is record-only; keep it an int for a clean round-trip. if isinstance(self.funding_amount_sat, float): self.funding_amount_sat = int(self.funding_amount_sat) @@ -530,7 +559,7 @@ def create_tentative(self, body: dict, *, api_key: str) -> dict: ) or {} def issue_funding(self, tentative_id: str, *, api_key: str) -> dict: - """POST …/tentatives/{uuid}/funding — issue Liquid USDT funding instructions.""" + """POST …/tentatives/{uuid}/funding — issue Liquid funding instructions.""" return self._proxy( "POST", f"transactions/direct-fiat/tentatives/{tentative_id}/funding", @@ -741,13 +770,13 @@ def create_order( ``funding_method`` selects the rail used to fund the payout — ``"USDT"`` (default) or ``"LBTC"`` — both settle from a Liquid address. WapuPay - returns ``funding_amount_sat`` (real sats to send) for the L-BTC rail; + returns ``total_amount_sats`` (real sats to send) for the L-BTC rail; for USDT the amount to send is ``total_funding_amount_base_units``. Returns the order record including ``address_destination`` (Liquid), ``asset_id``, ``funding_amount_usdt`` / ``total_amount_usdt`` and ``expires_at`` — plus ``total_funding_amount_base_units`` (USDT - rail) or ``funding_amount_sat`` (L-BTC rail). The caller pays the amount + rail) or ``total_amount_sats`` (L-BTC rail). The caller pays the amount named in ``pay_instructions`` with ``lw_send_asset`` — this method never broadcasts. """ @@ -802,8 +831,15 @@ def create_order( receiver_name=(receiver_name.strip() if receiver_name else None), refund_address=refund, wallet_name=wallet_name, + # The REQUESTED rail is authoritative and is recorded before any + # response is merged. funding_currency drives every money-denomination + # branch, so leaving it to WapuPay's optional echo would let a single + # missing key re-denominate an L-BTC order in USDT terms. + funding_currency=funding_method, + funding_network=FUNDING_NETWORK_LIQUID, ) order.apply_tentative(created) + self._assert_rail(order, funding_method, funded=False) # Persist BEFORE funding — a crash/failure mid-funding stays recoverable. self.storage.save_wapupay_order(order) @@ -821,10 +857,44 @@ def create_order( return result order.apply_tentative(funding) + # Re-check after the SECOND merge: the funding response overwrites + # funding_currency, so a rail that flips here would re-derive the other + # rail's amounts while asset_id still points at the first one. + self._assert_rail(order, funding_method, funded=True) order.last_error = None self.storage.save_wapupay_order(order) return self._funded_result(order) + def _assert_rail(self, order: "WapuPayOrder", funding_method: str, *, funded: bool) -> None: + """Refuse to continue if WapuPay's echoed rail contradicts the request. + + The rail selects the denomination of the amount the user is told to send + (sats vs USDT base units) while ``asset_id`` selects the asset. If the two + disagree the caller can overpay by ~10^8x, so this raises rather than + re-deriving (CLAUDE.md invariant 5 — no silent fallback). + """ + echoed = (order.funding_currency or "").upper() + if not echoed or echoed == funding_method: + return + detail = ( + f"WapuPay echoed funding_currency={order.funding_currency!r} for a " + f"funding_method={funding_method!r} order; refusing to continue. " + f"The tentative exists upstream as {order.tentative_id}" + ) + if funded: + # Already persisted: record why it stalled so the local record isn't + # a silent orphan, then refuse to hand back pay_instructions. + order.last_error = detail + # Restore the REQUESTED rail before saving. Clearing the derived + # amount here would not stick — from_dict re-derives it on every + # load — so the record must be left self-consistent (requested rail + # + matching asset_id) instead of carrying a contradictory mix. + order.funding_currency = funding_method + order._derive_base_units() + self.storage.save_wapupay_order(order) + raise ValueError(f"{detail}; funding was issued but is NOT safe to pay.") + raise ValueError(f"{detail} and will expire on its own; it was NOT funded.") + def fund_order(self, tentative_id: str) -> dict: """Issue (or re-issue) funding instructions for an existing order.""" # Validate the id BEFORE it reaches URL construction / the network. @@ -937,20 +1007,18 @@ def _funded_result(order: "WapuPayOrder") -> dict: "stored locally for this order." ) - if order.funding_currency == FUNDING_METHOD_LBTC and order.funding_amount_sat is not None: - # L-BTC rail: WapuPay returns the REAL sat amount to send (already - # fee-inclusive). Send sats of L-BTC, never the USDT base units. + if order.is_lbtc and order.total_amount_sats is not None: + # L-BTC rail: total_amount_sats is the fee-inclusive amount to send. + # NOT funding_amount_sat — that is the pre-fee payout, the sat + # analogue of funding_amount_usdt. Send sats, never USDT base units. result["pay_instructions"] = ( - f"Send exactly {order.funding_amount_sat} sats of L-BTC on Liquid " + f"Send exactly {order.total_amount_sats} sats of L-BTC on Liquid " f"to {order.address_destination} using lw_send_asset " f"(asset_id={order.asset_id}). This amount already includes " f"WapuPay's fee — send the full amount or WapuPay won't " f"settle.{payout_note}{expires_note}" ) - elif ( - order.funding_currency != FUNDING_METHOD_LBTC - and order.total_funding_amount_base_units is not None - ): + elif not order.is_lbtc and order.total_funding_amount_base_units is not None: fee_display = order.fee_amount_usdt if order.fee_amount_usdt is not None else 0 result["pay_instructions"] = ( f"Send exactly {order.total_amount_usdt} USDT " @@ -962,14 +1030,18 @@ def _funded_result(order: "WapuPayOrder") -> dict: ) else: # Thin record (e.g. order created on another device): the funding - # response carries no total_amount_usdt, so the exact total isn't - # known locally. Don't fabricate a "None" amount (No-lies rule) — - # point the user at order-status to fetch the real total first. + # response carries no total, so the exact amount isn't known locally. + # Don't fabricate a "None" amount (No-lies rule) — point the user at + # order-status to fetch the real total first. The missing field and + # the unit differ per rail, so name the right one: telling an L-BTC + # payer to fetch a USDT figure invites a ~10^8x overpayment. + missing = "total_amount_sats" if order.is_lbtc else "total_amount_usdt" + unit = "L-BTC satoshi" if order.is_lbtc else "USDT" result["pay_instructions"] = ( f"Funding address ready ({order.address_destination}, " - f"asset_id={order.asset_id}), but the exact USDT total to send " + f"asset_id={order.asset_id}), but the exact {unit} amount to send " f"is not available locally yet. Call wapupay_order_status with " - f"tentative_id={order.tentative_id} to fetch total_amount_usdt, " + f"tentative_id={order.tentative_id} to fetch {missing}, " f"then pay that exact amount with lw_send_asset." ) return result diff --git a/tests/test_cli.py b/tests/test_cli.py index 6d19d67..8e7aa61 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2467,6 +2467,41 @@ def test_create_order_with_yes_skips_confirm(self, runner, wapupay_cli): assert call[1]["amount_ars"] == "10000" assert call[1]["alias"] == "al.cbu" + def test_create_order_threads_funding_method(self, runner, wapupay_cli): + """--funding-method must reach the manager. Without this hop the L-BTC + rail is unreachable from the CLI no matter what the manager supports.""" + result = runner.invoke( + cli, + ["--format", "json", "wapupay", "create-order", + "--amount-ars", "10000", "--alias", "al.cbu", + "--funding-method", "LBTC", "--yes"], + ) + assert result.exit_code == 0 + call = next(c for c in wapupay_cli.calls if c[0] == "create_order") + assert call[1]["funding_method"] == "LBTC" + + def test_create_order_defaults_funding_method_to_usdt(self, runner, wapupay_cli): + """Omitting the flag keeps the USDT default — adding the L-BTC rail must + not change the behaviour of existing callers.""" + result = runner.invoke( + cli, + ["--format", "json", "wapupay", "create-order", + "--amount-ars", "10000", "--alias", "al.cbu", "--yes"], + ) + assert result.exit_code == 0 + call = next(c for c in wapupay_cli.calls if c[0] == "create_order") + assert call[1]["funding_method"] == "USDT" + + def test_create_order_rejects_bogus_funding_method(self, runner, wapupay_cli): + """Click rejects an unknown rail before any manager call.""" + result = runner.invoke( + cli, + ["wapupay", "create-order", "--amount-ars", "10000", "--alias", "al.cbu", + "--funding-method", "DOGE", "--yes"], + ) + assert result.exit_code != 0 + assert not any(c[0] == "create_order" for c in wapupay_cli.calls) + def test_create_order_rejects_invalid_refund_address(self, runner, wapupay_cli): """A bad --refund-address fails fast: before the quote fetch and before any manager call (no order is created).""" diff --git a/tests/test_wapupay.py b/tests/test_wapupay.py index 0faaf69..858690e 100644 --- a/tests/test_wapupay.py +++ b/tests/test_wapupay.py @@ -236,6 +236,7 @@ def side_effect(req, timeout=None): "fee_amount_usdt": 0.13, "funding_amount_usdt": 15.65, "funding_amount_sat": 25127, + "total_amount_sats": 25127, "total_amount_usdt": 15.78, "expires_at": "2026-07-17 17:22:10", } @@ -248,6 +249,7 @@ def side_effect(req, timeout=None): "funding_currency": "LBTC", "funding_network": "LIQUID", "funding_amount_sat": 25127, + "total_amount_sats": 25127, "funding_amount_usdt": 15.65, "total_amount_usdt": 15.78, "expires_at": "2026-07-17T20:22:10Z", @@ -1322,6 +1324,9 @@ def test_create_order_with_lbtc_funding_method(storage): # Sig:5 # Persisted order (reloaded via storage) keeps the real sat amount. saved = storage.load_wapupay_order(TENTATIVE_ID) assert saved.funding_amount_sat == 25127 + # The authoritative send amount must survive the storage round-trip that + # order_status / orders / fund_order all go through. + assert saved.total_amount_sats == 25127 def test_funded_result_lbtc_without_sats_never_emits_usdt_base_units(): # Sig:5 @@ -1400,3 +1405,192 @@ def test_create_order_defaults_to_usdt_funding_method(storage): # Sig:5 create_call = next(c for c in fake.calls if c[0] == "create_tentative") body, _create_key = create_call[1] assert body["funding_method"] == FUNDING_METHOD_USDT + + +# --------------------------------------------------------------------------- +# L-BTC send amount: total_amount_sats is authoritative, funding_amount_sat is +# record-only. WapuPay returns BOTH on the L-BTC rail (verified live against +# be-stage 2026-08-12). They are equal today, but the USDT rail proves the +# naming convention: funding_amount_usdt (6.43) is the pre-fee payout while +# total_amount_usdt (6.69) is what you actually send. If the sat pair ever +# splits the same way, reading funding_amount_sat underpays by the fee. +# --------------------------------------------------------------------------- + + +def _lbtc_order(**overrides): + """An L-BTC order with a funded address, for _funded_result probes.""" + order = WapuPayOrder( + tentative_id=TENTATIVE_ID, status="FUNDING_ISSUED", type="fast_fiat_transfer", + amount_ars="24000", alias="al.cbu", created_at="t0", + ) + resp = { + "funding_currency": FUNDING_METHOD_LBTC, + "funding_network": FUNDING_NETWORK_LIQUID, + "address_destination": "lq1qqlbtcaddr", + "asset_id": LBTC_ASSET_ID, + "total_amount_usdt": 15.78, + } + resp.update(overrides) + order.apply_tentative(resp) + return order + + +def test_lbtc_send_amount_comes_from_total_amount_sats(): # Sig:5 + """The L-BTC instruction must quote total_amount_sats — the fee-inclusive + total — not funding_amount_sat.""" + order = _lbtc_order(total_amount_sats=10498, funding_amount_sat=10498) + assert order.total_amount_sats == 10498 + instr = WapuPayManager._funded_result(order)["pay_instructions"] + assert "10498" in instr + assert "sats" in instr + assert "L-BTC" in instr + + +def test_lbtc_ignores_funding_amount_sat_when_it_differs(): # Sig:5 + """funding_amount_sat is the pre-fee payout and must never reach the user. + If the two figures ever diverge, the fee-inclusive total wins.""" + order = _lbtc_order(total_amount_sats=10498, funding_amount_sat=10203) + instr = WapuPayManager._funded_result(order)["pay_instructions"] + assert "10498" in instr # the total, fee included + assert "10203" not in instr # never the pre-fee payout + + +def test_lbtc_without_total_sats_states_no_amount_and_never_says_usdt(): # Sig:5 + """No silent fallback (CLAUDE.md invariant 5): with total_amount_sats absent + the order has no payable figure, so the message must say so in L-BTC terms. + It must never route an L-BTC payer at a USDT number — the old USDT-worded + fallback named total_amount_usdt beside the L-BTC asset_id.""" + order = _lbtc_order(funding_amount_sat=10203) # present but ignored + instr = WapuPayManager._funded_result(order)["pay_instructions"] + assert "USDT" not in instr + assert "total_amount_usdt" not in instr + assert "10203" not in instr # the ignored field must not leak + assert "total_amount_sats" in instr # names what is actually missing + assert "None" not in instr + + +def test_create_order_persists_requested_rail_when_wapupay_omits_it(storage): # Sig:5 + """The caller's requested rail is authoritative. If WapuPay's responses omit + funding_currency, the order must still be L-BTC — otherwise every + denomination branch silently falls back to USDT semantics while the + asset_id stays L-BTC.""" + create = {k: v for k, v in CREATE_RESP_LBTC.items() if k != "funding_currency"} + funding = {k: v for k, v in FUNDING_RESP_LBTC.items() if k != "funding_currency"} + fake = FakeClient({"create_tentative": create, "issue_funding": funding}) + m = make_manager(storage, fake) + out = m.create_order( + amount_ars="24000", alias="al.cbu", transfer_type="fast_fiat_transfer", + funding_method=FUNDING_METHOD_LBTC, + ) + assert out["funding_currency"] == FUNDING_METHOD_LBTC + assert out["total_funding_amount_base_units"] is None + instr = out["pay_instructions"] + assert "sats" in instr + assert "base units" not in instr + + +def test_create_order_rejects_rail_mismatch_from_wapupay(storage): # Sig:5 + """If WapuPay echoes a different rail than the one requested, that is a + contract violation on a money path — raise, never re-denominate silently.""" + create = dict(CREATE_RESP_LBTC, funding_currency=FUNDING_METHOD_USDT) + fake = FakeClient({"create_tentative": create, "issue_funding": dict(FUNDING_RESP_LBTC)}) + m = make_manager(storage, fake) + with pytest.raises(ValueError, match="funding_method"): + m.create_order( + amount_ars="24000", alias="al.cbu", transfer_type="fast_fiat_transfer", + funding_method=FUNDING_METHOD_LBTC, + ) + + +def test_create_order_rejects_rail_flip_on_the_funding_response(storage): # Sig:5 + """The rail must be re-checked after the SECOND merge. create_order applies + the create response and then the funding response; a rail that flips on the + funding leg would re-derive USDT base units against the L-BTC asset_id — the + ~10^8x pairing this whole rail-detection exists to prevent.""" + funding = dict(FUNDING_RESP_LBTC, funding_currency=FUNDING_METHOD_USDT) + fake = FakeClient({"create_tentative": dict(CREATE_RESP_LBTC), "issue_funding": funding}) + m = make_manager(storage, fake) + with pytest.raises(ValueError, match="funding_method"): + m.create_order( + amount_ars="24000", alias="al.cbu", transfer_type="fast_fiat_transfer", + funding_method=FUNDING_METHOD_LBTC, + ) + # The order was already persisted before funding; it must not be left + # carrying a USDT-scale send amount next to the L-BTC asset. + saved = storage.load_wapupay_order(TENTATIVE_ID) + assert saved.total_funding_amount_base_units is None + assert saved.last_error + + +def test_total_amount_sats_must_be_a_whole_number(): # Sig:5 + """Sats are integers end-to-end (invariant 1). A fractional wire value is a + contract violation — truncating it downward would underpay.""" + order = _lbtc_order(total_amount_sats=10498.0) + assert order.total_amount_sats == 10498 + assert isinstance(order.total_amount_sats, int) + with pytest.raises(ValueError, match="total_amount_sats"): + _lbtc_order(total_amount_sats=10498.5) + + +def test_from_dict_drops_stale_total_amount_sats_on_usdt_record(): # Sig:5 + """A USDT-on-Liquid record has no real sats; a stale sat total must not + survive a reload and become an L-BTC send amount.""" + usdt = { + "tentative_id": TENTATIVE_ID, "status": "FUNDING_ISSUED", + "type": "fast_fiat_transfer", "amount_ars": "10000", "alias": "al.cbu", + "created_at": "t0", "funding_network": FUNDING_NETWORK_LIQUID, + "funding_currency": FUNDING_METHOD_USDT, + "total_amount_sats": 10498, "funding_amount_sat": 10498, + } + o = WapuPayOrder.from_dict(usdt) + assert o.total_amount_sats is None + assert o.funding_amount_sat is None + + +def test_from_dict_keeps_total_amount_sats_on_lbtc_record(): # Sig:5 + """The L-BTC rail's send amount must survive a reload (order-status, orders, + fund-order all round-trip through storage).""" + lbtc = { + "tentative_id": TENTATIVE_ID, "status": "FUNDING_ISSUED", + "type": "fast_fiat_transfer", "amount_ars": "24000", "alias": "al.cbu", + "created_at": "t0", "funding_network": FUNDING_NETWORK_LIQUID, + "funding_currency": FUNDING_METHOD_LBTC, + "total_amount_sats": 10498, + } + o = WapuPayOrder.from_dict(lbtc) + assert o.total_amount_sats == 10498 + + +# --------------------------------------------------------------------------- +# funding_method plumbing: MCP schema -> tools -> manager. The CLI hop is +# covered in tests/test_cli.py. +# --------------------------------------------------------------------------- + + +def test_tool_create_order_threads_funding_method(monkeypatch, storage): # Sig:5 + """tools.wapupay_create_order must pass funding_method through to the + manager — the MCP tool layer was previously untested for this hop.""" + fake = FakeClient({ + "create_tentative": dict(CREATE_RESP_LBTC), + "issue_funding": dict(FUNDING_RESP_LBTC), + }) + mgr = make_manager(storage, fake) + monkeypatch.setattr(tools, "_wapupay_manager", mgr) + monkeypatch.setattr(tools, "get_manager", lambda: mgr) + out = tools.wapupay_create_order( + amount_ars="24000", alias="al.cbu", funding_method=FUNDING_METHOD_LBTC, + ) + body, _key = next(c for c in fake.calls if c[0] == "create_tentative")[1] + assert body["funding_method"] == FUNDING_METHOD_LBTC + assert out["funding_currency"] == FUNDING_METHOD_LBTC + + +def test_mcp_schema_offers_both_funding_rails(): # Sig:4 + """The MCP surface must let an assistant choose the rail: dispatch is + tool_fn(**arguments), so a missing schema property makes L-BTC unreachable + over MCP no matter what the tool function accepts.""" + from aqua.server import TOOL_SCHEMAS + + props = TOOL_SCHEMAS["wapupay_create_order"]["inputSchema"]["properties"] + assert set(props["funding_method"]["enum"]) == set(FUNDING_METHODS) + assert props["funding_method"]["default"] == FUNDING_METHOD_USDT