From 91896b4dad33c20f208f8502b53ccc4d63f39ab3 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sat, 30 May 2026 16:17:15 +0800 Subject: [PATCH] Prevent duplicate payment confirmations --- src/payments/contract_checkout.py | 21 ++++++++++++ tests/test_direct_payment.py | 53 +++++++++++++++++++++++++++++++ tests/test_payments_runtime.py | 32 ++++++++++++++++++- 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index 247348b9..6208f9cf 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -2059,6 +2059,26 @@ class PaymentContractCheckoutService: raise PaymentCheckoutError( 409, "tx_hash already used by another payment intent" ) + intent_rows = self._rest( + "GET", + "payment_intents", + params={ + "select": "id", + "tx_hash": f"eq.{tx_hash_text}", + "limit": "5", + }, + allowed_status=[200], + ) + if not isinstance(intent_rows, list): + return + for row in intent_rows: + if not isinstance(row, dict): + continue + existing_intent = str(row.get("id") or "").strip() + if existing_intent and existing_intent != str(intent_id): + raise PaymentCheckoutError( + 409, "tx_hash already used by another payment intent" + ) def _record_duplicate_transaction( self, @@ -2893,6 +2913,7 @@ class PaymentContractCheckoutService: raise PaymentCheckoutError(400, "tx_hash required") if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66): raise PaymentCheckoutError(400, "invalid tx_hash") + self._ensure_tx_hash_unused(tx_hash_text, intent.intent_id) w3 = self._get_web3(chain_id=intent.chain_id) if not w3.is_connected(): raise PaymentCheckoutError(503, "cannot connect payment rpc") diff --git a/tests/test_direct_payment.py b/tests/test_direct_payment.py index 5322267d..46fb677e 100644 --- a/tests/test_direct_payment.py +++ b/tests/test_direct_payment.py @@ -260,6 +260,8 @@ def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path): def fake_rest(method, table, **kwargs): if method == "GET" and table == "payment_transactions": return [] + if method == "GET" and table == "payment_intents": + return [] if method == "PATCH" and table == "payment_intents": assert kwargs["prefer"] == "return=representation" assert kwargs["params"]["select"] == "id" @@ -338,6 +340,8 @@ def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path): def fake_rest(method, table, **kwargs): if method == "GET" and table == "payment_transactions": return [] + if method == "GET" and table == "payment_intents": + return [] if method == "PATCH" and table == "payment_intents": submitted.update(kwargs["payload"]) return [{"ok": True}] @@ -399,6 +403,8 @@ def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path) def fake_rest(method, table, **kwargs): if method == "GET" and table == "payment_transactions": return [] + if method == "GET" and table == "payment_intents": + return [] raise AssertionError(f"submit must not mutate {table} before validation passes") monkeypatch.setattr(service, "_rest", fake_rest) @@ -452,6 +458,8 @@ def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tm def fake_rest(method, table, **kwargs): if method == "GET" and table == "payment_transactions": return [] + if method == "GET" and table == "payment_intents": + return [] raise AssertionError(f"submit must not mutate {table} after receiver mismatch") monkeypatch.setattr(service, "_rest", fake_rest) @@ -544,6 +552,8 @@ def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monk rest_calls.append((method, table, kwargs)) if method == "GET" and table == "payment_transactions": return [] + if method == "GET" and table == "payment_intents": + return [] if method == "PATCH" and table == "payment_intents": return [{"id": intent.intent_id, "status": "confirmed"}] if method == "POST" and table == "payment_transactions": @@ -608,6 +618,49 @@ def test_submit_rejects_tx_hash_used_by_another_intent(monkeypatch, tmp_path): raise AssertionError("expected duplicate tx_hash rejection") +def test_confirm_rejects_tx_hash_used_by_another_intent(monkeypatch, tmp_path): + _setup_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + tx_hash = "0x" + "7" * 64 + monkeypatch.setattr( + service, + "get_intent", + lambda user_id, intent_id: service._serialize_intent( + { + "id": intent_id, + "plan_code": "pro_monthly", + "plan_id": 101, + "chain_id": 137, + "token_address": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", + "receiver_address": "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32", + "amount_units": "5000000", + "payment_mode": "direct", + "allowed_wallet": None, + "order_id_hex": "0x" + "1" * 64, + "status": "created", + "expires_at": "2099-01-01T00:00:00+00:00", + "tx_hash": None, + "metadata": {}, + } + ), + ) + + def fake_rest(method, table, **kwargs): + if method == "GET" and table == "payment_transactions": + return [{"intent_id": "other-intent"}] + raise AssertionError((method, table, kwargs)) + + monkeypatch.setattr(service, "_rest", fake_rest) + + try: + service.confirm_intent_tx("user-1", "intent-direct-4", tx_hash) + except Exception as exc: + assert getattr(exc, "status_code", None) == 409 + assert "tx_hash already used" in getattr(exc, "detail", "") + else: + raise AssertionError("expected duplicate tx_hash rejection") + + def test_submit_marks_late_tx_as_refund_required_after_intent_paid(monkeypatch, tmp_path): _setup_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() diff --git a/tests/test_payments_runtime.py b/tests/test_payments_runtime.py index bee71c0b..f6369d0c 100644 --- a/tests/test_payments_runtime.py +++ b/tests/test_payments_runtime.py @@ -1114,13 +1114,43 @@ def test_tx_hash_unused_check_selects_only_intent_id(monkeypatch, tmp_path): def _fake_rest(method, table, **kwargs): calls.append({"method": method, "table": table, **kwargs}) - return [{"intent_id": "intent-1"}] + if table == "payment_transactions": + return [{"intent_id": "intent-1"}] + if table == "payment_intents": + return [{"id": "intent-1"}] + raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", _fake_rest) service._ensure_tx_hash_unused("0x" + "1" * 64, "intent-1") assert calls[0]["params"]["select"] == "intent_id" + assert calls[1]["params"]["select"] == "id" + + +def test_tx_hash_unused_check_rejects_existing_intent_tx_hash( + monkeypatch, + tmp_path, +): + _payment_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + + def _fake_rest(method, table, **kwargs): + if table == "payment_transactions": + return [] + if table == "payment_intents": + return [{"id": "other-intent"}] + raise AssertionError((method, table, kwargs)) + + monkeypatch.setattr(service, "_rest", _fake_rest) + + try: + service._ensure_tx_hash_unused("0x" + "1" * 64, "intent-1") + except PaymentCheckoutError as exc: + assert exc.status_code == 409 + assert "tx_hash already used" in exc.detail + else: + raise AssertionError("expected duplicate tx_hash rejection") def test_grant_subscription_keeps_unknown_active_subscription_extension(monkeypatch, tmp_path):