From 07c5451f1d15f734ba921b38937c00b5e4beb86f Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Wed, 24 Jun 2026 17:03:45 +0800 Subject: [PATCH] Allow manual payment tx submission during transient validation --- .../account/__tests__/paymentSecurity.test.ts | 6 ++ frontend/components/account/usePaymentFlow.ts | 3 + src/payments/contract_checkout.py | 24 ++++-- tests/test_direct_payment.py | 79 ++++++++++++++++--- 4 files changed, 98 insertions(+), 14 deletions(-) diff --git a/frontend/components/account/__tests__/paymentSecurity.test.ts b/frontend/components/account/__tests__/paymentSecurity.test.ts index 09406c4b..e176b74d 100644 --- a/frontend/components/account/__tests__/paymentSecurity.test.ts +++ b/frontend/components/account/__tests__/paymentSecurity.test.ts @@ -94,6 +94,12 @@ export function runTests() { paymentFlowSource.includes("/validate"), "manual payment flow must validate tx hashes with the backend before submission", ); + assert( + paymentFlowSource.includes("confirmRes.status === 503") && + paymentFlowSource.includes('lowerRaw.includes("cannot connect payment rpc")') && + paymentFlowSource.includes('lowerRaw.includes("payment rpc chain mismatch")'), + "manual payment confirm must treat transient payment RPC failures as pending after tx hash submission", + ); assert( paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") && paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") < diff --git a/frontend/components/account/usePaymentFlow.ts b/frontend/components/account/usePaymentFlow.ts index 27f64c38..8893a85d 100644 --- a/frontend/components/account/usePaymentFlow.ts +++ b/frontend/components/account/usePaymentFlow.ts @@ -826,6 +826,9 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { const lowerRaw = raw.toLowerCase(); const maybePending = confirmRes.status === 408 || + (confirmRes.status === 503 && + (lowerRaw.includes("cannot connect payment rpc") || + lowerRaw.includes("payment rpc chain mismatch"))) || (confirmRes.status === 409 && (lowerRaw.includes("confirmations not enough") || lowerRaw.includes("tx indexed partially"))); if (maybePending) { setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`); diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index a484235d..fb9af3b2 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -2470,18 +2470,32 @@ class PaymentContractCheckoutService: else: self._require_user_wallet(user_id, from_addr) + validation_pending_reasons = { + "payment_tx_validation_failed", + "tx_not_mined", + } try: validation = self._validate_loaded_intent_tx(intent, tx_hash_text) except Exception as exc: - raise PaymentCheckoutError( - 400, - f"payment_tx_validation_failed: {exc}", - ) from exc + if intent.payment_mode != "direct": + raise PaymentCheckoutError( + 400, + f"payment_tx_validation_failed: {exc}", + ) from exc + validation = { + "valid": False, + "reason": "payment_tx_validation_failed", + "detail": str(exc), + } if not bool(validation.get("valid")): reason = str(validation.get("reason") or "payment_tx_invalid").strip() detail = str(validation.get("detail") or reason).strip() message = reason if detail == reason else f"{reason}: {detail}" - raise PaymentCheckoutError(400, message) + is_pending_direct_validation = ( + intent.payment_mode == "direct" and reason in validation_pending_reasons + ) + if not is_pending_direct_validation: + raise PaymentCheckoutError(400, message) now_iso = _to_iso(now) self._rest( diff --git a/tests/test_direct_payment.py b/tests/test_direct_payment.py index 2df3f55f..55cc305b 100644 --- a/tests/test_direct_payment.py +++ b/tests/test_direct_payment.py @@ -416,10 +416,12 @@ def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path): assert submitted["tx_hash"] == "0x" + "2" * 64 -def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path): +def test_direct_submit_accepts_pending_validation_for_background_confirm(monkeypatch, tmp_path): _setup_env(monkeypatch, tmp_path) service = PaymentContractCheckoutService() tx_hash = "0x" + "7" * 64 + submitted = {} + transactions = [] monkeypatch.setattr( service, @@ -458,17 +460,76 @@ def test_submit_rejects_direct_tx_until_validation_passes(monkeypatch, tmp_path) return [] if method == "GET" and table == "payment_intents": return [] - raise AssertionError(f"submit must not mutate {table} before validation passes") + if method == "PATCH" and table == "payment_intents": + submitted.update(kwargs["payload"]) + return [{"ok": True}] + if method == "POST" and table == "payment_transactions": + transactions.append(kwargs["payload"]) + return [kwargs["payload"]] + raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) - try: - service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "") - except Exception as exc: - assert getattr(exc, "status_code", None) == 400 - assert "tx_not_mined" in getattr(exc, "detail", "") - else: - raise AssertionError("expected tx_not_mined rejection") + result = service.submit_intent_tx("user-1", "intent-direct-pending", tx_hash, "") + + assert result["status"] == "submitted" + assert submitted["status"] == "submitted" + assert submitted["tx_hash"] == tx_hash + assert transactions[0]["status"] == "submitted" + + +def test_direct_submit_accepts_validation_exception_for_background_confirm(monkeypatch, tmp_path): + _setup_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + tx_hash = "0x" + "a" * 64 + submitted = {} + + 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 fail_validation(*args, **kwargs): + raise RuntimeError("rpc timeout") + + monkeypatch.setattr(service, "_validate_loaded_intent_tx", fail_validation) + + 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}] + if method == "POST" and table == "payment_transactions": + return [kwargs["payload"]] + raise AssertionError((method, table, kwargs)) + + monkeypatch.setattr(service, "_rest", fake_rest) + + result = service.submit_intent_tx("user-1", "intent-direct-rpc-pending", tx_hash, "") + + assert result["status"] == "submitted" + assert submitted["tx_hash"] == tx_hash def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tmp_path):