Skip to content

Commit 03c0f31

Browse files
authored
#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.
1 parent 0cd698a commit 03c0f31

4 files changed

Lines changed: 103 additions & 12 deletions

File tree

src/aqua/pix.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,15 @@
2424
# explain it to the user rather than passing the raw API error through.
2525
PIX_MIN_AMOUNT_CENTS = 100 # R$1.00 — Eulen's documented absolute minimum
2626

27+
# Flat fee Eulen deducts from every Pix→DePix operation, independent of amount.
28+
# Not returned by the API; documented here so the tool layer can surface it.
29+
EULEN_FEE_CENTS = 99 # R$0,99
30+
2731
# Eulen status values returned by GET /deposit-status.
2832
EULEN_STATUS_VALUES = frozenset(
2933
{
3034
"pending",
35+
"approved",
3136
"depix_sent",
3237
"under_review",
3338
"canceled",
@@ -102,7 +107,7 @@ def _api_request(
102107
"Content-Type": "application/json",
103108
"User-Agent": "agentic-aqua",
104109
"Authorization": f"Bearer {self.token}",
105-
"X-Nonce": uuid.uuid4().hex,
110+
"X-Nonce": str(uuid.uuid4()),
106111
},
107112
)
108113
try:
@@ -112,7 +117,12 @@ def _api_request(
112117
detail = ""
113118
try:
114119
err_body = json.loads(e.read().decode())
115-
detail = err_body.get("error") or err_body.get("message") or ""
120+
detail = (
121+
err_body.get("error")
122+
or err_body.get("message")
123+
or (err_body.get("response") or {}).get("errorMessage")
124+
or ""
125+
)
116126
except Exception:
117127
pass
118128
msg = f"Eulen API error ({e.code} {method} {path})"
@@ -162,6 +172,7 @@ def format_brl(amount_cents: int) -> str:
162172

163173
_STATUS_MESSAGES = {
164174
"pending": "Waiting for Pix payment. Pay the QR / Copia e Cola in your banking app.",
175+
"approved": "Pix payment received. DePix transfer in progress — should arrive shortly.",
165176
"depix_sent": "DePix delivered to your Liquid wallet.",
166177
"under_review": "Eulen is reviewing the payment (compliance/AML). This may take time.",
167178
"canceled": "The deposit was canceled.",

src/aqua/server.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@
460460
},
461461
},
462462
"pix_receive": {
463-
"description": "Mint a Pix charge (Brazil) that pays out DePix (BRL stablecoin on Liquid) to the named wallet's next address. Returns the Pix Copia e Cola string and a hosted QR image URL — the user pays from their banking app. Amount is in BRL CENTS (100 = R$1.00). Requires EULEN_API_TOKEN env var.",
463+
"description": "Mint a Pix charge (Brazil) that pays out DePix (BRL stablecoin on Liquid) to the named wallet's next address. Returns the Pix Copia e Cola string and a hosted QR image URL — the user pays from their banking app. Amount is in BRL CENTS (100 = R$1.00). Eulen deducts a FLAT FEE of R$0,99 per operation (regardless of amount), so DePix received = amount_cents − 99. The response includes fee_cents, fee_brl, net_amount_cents and net_amount_brl so the user can see the expected net up-front. Requires EULEN_API_TOKEN env var.",
464464
"inputSchema": {
465465
"type": "object",
466466
"properties": {
@@ -482,7 +482,7 @@
482482
},
483483
},
484484
"pix_status": {
485-
"description": "Check the status of a Pix → DePix deposit. Status values: pending, depix_sent, under_review, canceled, error, refunded, expired. Eulen delivers DePix automatically — no claim step.",
485+
"description": "Check the status of a Pix → DePix deposit. Status values: pending, approved (Pix received, DePix in flight), depix_sent, under_review, canceled, error, refunded, expired. Eulen delivers DePix automatically — no claim step.",
486486
"inputSchema": {
487487
"type": "object",
488488
"properties": {
@@ -1860,13 +1860,14 @@ async def get_prompt(name: str, arguments: dict[str, str] | None) -> GetPromptRe
18601860
1. Verify the EULEN_API_TOKEN environment variable is set. If not, tell me to obtain one from https://depix.info/#partners and stop here.
18611861
2. Ask me how much I want to deposit, IN REAIS (e.g. "R$50"). Convert to cents (R$50 → 5000 cents) before calling pix_receive. Be explicit about the unit so I do not get a 100× error.
18621862
3. Mention the practical first-time limit on Eulen is around R$500; offer to use a smaller amount if mine is higher.
1863-
4. Call pix_receive(amount_cents=…, wallet_name='{wallet_name}').
1864-
5. Show me BOTH:
1863+
4. BEFORE calling pix_receive, tell me Eulen will deduct a FLAT FEE of R$0,99 from the amount I pay (independent of the amount), so I will receive `amount − R$0,99` in DePix. Confirm the amount with me knowing this.
1864+
5. Call pix_receive(amount_cents=…, wallet_name='{wallet_name}').
1865+
6. Show me BOTH:
18651866
- The `qr_copy_paste` string (EMV BR-Code) — I can paste this into my banking app's "Pix Copia e Cola" field.
18661867
- The `qr_image_url` — I can open this on my phone and scan the QR with my bank app.
1867-
Explain I only need to do ONE of those, not both.
1868-
6. After I confirm I have paid, call pix_status(swap_id=…) and report the status. Re-check on request until status="depix_sent" (DePix delivered) or a terminal failure.
1869-
7. When delivered, show the `blockchain_txid` (Liquid txid) so I can verify on a block explorer.""",
1868+
Explain I only need to do ONE of those, not both. Also surface `net_amount_brl` so I see the exact DePix I will receive after the R$0,99 fee.
1869+
7. After I confirm I have paid, call pix_status(swap_id=…) and report the status. Re-check on request until status="depix_sent" (DePix delivered) or a terminal failure. Status "approved" is an intermediate step (Pix received, DePix in flight) — not a failure.
1870+
8. When delivered, show the `blockchain_txid` (Liquid txid) so I can verify on a block explorer.""",
18701871
),
18711872
)
18721873
]

src/aqua/tools.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -892,9 +892,13 @@ def pix_receive(
892892
manager = get_pix_manager()
893893
swap = manager.create_deposit(amount_cents, wallet_name, password)
894894

895-
from .pix import format_brl
895+
from .pix import EULEN_FEE_CENTS, format_brl
896896

897897
amount_brl = format_brl(swap.amount_cents)
898+
fee_cents = EULEN_FEE_CENTS
899+
fee_brl = format_brl(fee_cents)
900+
net_amount_cents = max(swap.amount_cents - fee_cents, 0)
901+
net_amount_brl = format_brl(net_amount_cents)
898902
all_wallets = get_manager().storage.list_wallets()
899903
wallet_note = f" in wallet '{wallet_name}'" if len(all_wallets) > 1 else ""
900904
return {
@@ -903,13 +907,19 @@ def pix_receive(
903907
"qr_image_url": swap.qr_image_url,
904908
"amount_cents": swap.amount_cents,
905909
"amount_brl": amount_brl,
910+
"fee_cents": fee_cents,
911+
"fee_brl": fee_brl,
912+
"net_amount_cents": net_amount_cents,
913+
"net_amount_brl": net_amount_brl,
906914
"depix_address": swap.depix_address,
907915
"expiration": swap.expiration,
908916
"wallet_name": wallet_name,
909917
"message": (
910918
f"Pay {amount_brl} via Pix to receive DePix{wallet_note}. "
911919
"Paste qr_copy_paste into your banking app's 'Pix Copia e Cola' field, "
912920
"or open qr_image_url on your phone and scan with your bank app. "
921+
f"Note: Eulen deducts a flat fee of {fee_brl} per deposit, so you will "
922+
f"receive {net_amount_brl} in DePix. "
913923
f"Check status with swap_id: {swap.swap_id}"
914924
),
915925
}

tests/test_pix.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,13 @@ def test_create_deposit_sends_bearer_and_nonce(self):
191191
assert called_req.full_url.endswith("/deposit")
192192
assert called_req.headers["Authorization"] == "Bearer test-token-xyz"
193193
nonce = called_req.headers["X-nonce"] # urllib lowercases the second char
194-
# nonce should be a uuid4 hex (32 chars, lowercase hex)
195-
assert re.fullmatch(r"[0-9a-f]{32}", nonce)
194+
# Eulen requires the standard dashed UUID format (8-4-4-4-12). The
195+
# 32-char hex form was rejected with HTTP 400 "Not a valid UUID."
196+
# See issue #78.
197+
assert re.fullmatch(
198+
r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}",
199+
nonce,
200+
)
196201
body = json.loads(called_req.data.decode())
197202
assert body == {"amountInCents": 5000, "depixAddress": "lq1test"}
198203

@@ -209,6 +214,24 @@ def test_create_deposit_http_error_includes_detail(self):
209214
assert "invalid amount" in str(exc.value)
210215
assert "400" in str(exc.value)
211216

217+
def test_create_deposit_http_error_extracts_response_errorMessage(self):
218+
# Eulen wraps 4xx detail inside {"response": {"errorMessage": "..."}}.
219+
# The previous error handler only looked at top-level "error"/"message",
220+
# so the useful cause was silently dropped — see issue #78 finding 3.
221+
client = EulenClient()
222+
with patch("urllib.request.urlopen") as mock_urlopen:
223+
err = urllib.error.HTTPError("url", 400, "Bad Request", {}, MagicMock())
224+
err.read = MagicMock(
225+
return_value=json.dumps(
226+
{"response": {"errorMessage": "Invalid X-Nonce header value - Not a valid UUID."}}
227+
).encode()
228+
)
229+
mock_urlopen.side_effect = err
230+
with pytest.raises(RuntimeError) as exc:
231+
client.create_deposit(5000, "lq1test")
232+
assert "Invalid X-Nonce" in str(exc.value)
233+
assert "400" in str(exc.value)
234+
212235
def test_create_deposit_http_error_empty_body_omits_literal_braces(self):
213236
# An HTTP error with an empty (or unrecognised-shape) JSON body must
214237
# not surface "{}" or "{'foo': 'bar'}" as a fake "error detail" —
@@ -371,6 +394,34 @@ def test_pending_to_settled_transition_persists(self, test_wallet):
371394
assert reloaded.status == "depix_sent"
372395
assert reloaded.blockchain_txid == "deadbeef" * 8
373396

397+
def test_approved_status_recognized_and_persisted(self, test_wallet):
398+
# Eulen returns "approved" as the intermediate "Pix received, DePix
399+
# in flight" state. Before issue #78, this was logged as an unknown
400+
# status and the cached "pending" was kept — confusing the user.
401+
storage, wm = test_wallet
402+
seeded = self._seed_swap(storage, wm)
403+
404+
manager = PixManager(storage=storage, wallet_manager=wm)
405+
approved_resp = {
406+
"response": {
407+
"status": "approved",
408+
"valueInCents": 5000,
409+
"expiration": "2026-05-08T23:59:59Z",
410+
},
411+
"async": False,
412+
}
413+
with patch("urllib.request.urlopen") as mock_urlopen:
414+
mock_urlopen.return_value = _mock_response(approved_resp)
415+
result = manager.get_deposit_status(seeded.swap_id)
416+
417+
assert result["status"] == "approved"
418+
assert "warning" not in result
419+
assert "Pix payment received" in result["message"]
420+
421+
reloaded = storage.load_pix_swap(seeded.swap_id)
422+
assert reloaded is not None
423+
assert reloaded.status == "approved"
424+
374425
def test_warning_on_remote_failure(self, test_wallet):
375426
storage, wm = test_wallet
376427
seeded = self._seed_swap(storage, wm)
@@ -494,6 +545,24 @@ def test_pix_receive_returns_qr_payload(self, test_wallet):
494545
assert result["amount_brl"] == "R$50,00"
495546
assert "Copia e Cola" in result["message"]
496547

548+
def test_pix_receive_reports_fee_and_net(self, test_wallet):
549+
# Eulen charges a flat R$0,99 per operation. The tool must surface
550+
# both the fee and the expected net DePix so the user is not
551+
# surprised on settlement — see issue #78 finding 4.
552+
from aqua.pix import EULEN_FEE_CENTS
553+
from aqua.tools import pix_receive
554+
555+
with patch("urllib.request.urlopen") as mock_urlopen:
556+
mock_urlopen.return_value = _mock_response(MOCK_DEPOSIT_RESPONSE)
557+
result = pix_receive(amount_cents=5000)
558+
559+
assert result["fee_cents"] == EULEN_FEE_CENTS == 99
560+
assert result["fee_brl"] == "R$0,99"
561+
assert result["net_amount_cents"] == 5000 - 99
562+
assert result["net_amount_brl"] == "R$49,01"
563+
assert "R$0,99" in result["message"]
564+
assert "R$49,01" in result["message"]
565+
497566
def test_pix_status_dispatches(self, test_wallet):
498567
from aqua.tools import pix_receive, pix_status
499568

0 commit comments

Comments
 (0)