diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index 6e9a5b2e..f8acbaa4 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -1356,8 +1356,8 @@ export function AccountCenter() { } disabled={ paymentBusy || - (txValidation.checked && - !txValidation.valid) + !(txValidation.checked && + txValidation.valid === true) } className="w-full rounded-xl border border-emerald-700 bg-emerald-600 px-3 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-700 disabled:opacity-50" > diff --git a/frontend/components/account/__tests__/paymentSecurity.test.ts b/frontend/components/account/__tests__/paymentSecurity.test.ts index 075c9f19..b1a2234a 100644 --- a/frontend/components/account/__tests__/paymentSecurity.test.ts +++ b/frontend/components/account/__tests__/paymentSecurity.test.ts @@ -71,6 +71,23 @@ export function runTests() { paymentFlowSource.includes("assertExpectedPaymentReceiver"), "AccountCenter must validate backend-returned manual payment receiver before displaying it", ); + assert( + /!\(\s*txValidation\.checked\s*&&\s*txValidation\.valid === true\s*\)/.test( + accountCenterSource, + ), + "manual payment submit button must require checked && valid === true", + ); + assert( + paymentFlowSource.includes("validateTxHash") && + paymentFlowSource.includes("/validate"), + "manual payment flow must validate tx hashes with the backend before submission", + ); + assert( + paymentFlowSource.includes("await waitForReceipt(txHashNorm, eth)") && + paymentFlowSource.indexOf("await waitForReceipt(txHashNorm, eth)") < + paymentFlowSource.indexOf("const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`"), + "wallet payment flow must wait for the payment tx receipt before submitting tx hash to the backend", + ); assert( accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") || hookSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") || diff --git a/frontend/components/account/usePaymentFlow.ts b/frontend/components/account/usePaymentFlow.ts index 0dfc4ee1..ba542ccc 100644 --- a/frontend/components/account/usePaymentFlow.ts +++ b/frontend/components/account/usePaymentFlow.ts @@ -549,6 +549,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) { const txHashNorm = String(txHash || "").toLowerCase(); setLastTxHash(txHashNorm); setLastPaymentStartedAt(Date.now()); + setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上回执...`); + await waitForReceipt(txHashNorm, eth); const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, { method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }), diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index 4f2559b0..b3043c36 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -2282,6 +2282,19 @@ class PaymentContractCheckoutService: else: self._require_user_wallet(user_id, from_addr) + try: + validation = self.validate_intent_tx(user_id, intent.intent_id, tx_hash_text) + except Exception as exc: + raise PaymentCheckoutError( + 400, + f"payment_tx_validation_failed: {exc}", + ) from 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) + now_iso = _to_iso(now) self._rest( "PATCH", diff --git a/tests/test_direct_payment.py b/tests/test_direct_payment.py index 821c4b2c..cd446141 100644 --- a/tests/test_direct_payment.py +++ b/tests/test_direct_payment.py @@ -342,6 +342,11 @@ def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path): raise AssertionError((method, table, kwargs)) monkeypatch.setattr(service, "_rest", fake_rest) + monkeypatch.setattr( + service, + "validate_intent_tx", + lambda *args, **kwargs: {"valid": True, "checks": {"tx_mined": True}}, + ) result = service.submit_intent_tx("user-1", "intent-direct-1", "0x" + "2" * 64, "") @@ -350,6 +355,112 @@ 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): + _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": {}, + } + ), + ) + monkeypatch.setattr( + service, + "validate_intent_tx", + lambda *args, **kwargs: { + "valid": False, + "reason": "tx_not_mined", + "detail": "tx has not been mined yet", + }, + ) + + def fake_rest(method, table, **kwargs): + if method == "GET" and table == "payment_transactions": + return [] + raise AssertionError(f"submit must not mutate {table} before validation passes") + + 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") + + +def test_submit_rejects_mined_direct_tx_when_receiver_mismatches(monkeypatch, tmp_path): + _setup_env(monkeypatch, tmp_path) + service = PaymentContractCheckoutService() + tx_hash = "0x" + "8" * 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": {}, + } + ), + ) + monkeypatch.setattr( + service, + "validate_intent_tx", + lambda *args, **kwargs: { + "valid": False, + "reason": "receiver_mismatch", + "detail": "Transfer went to 0xd216..., expected 0xed2f...", + }, + ) + + def fake_rest(method, table, **kwargs): + if method == "GET" and table == "payment_transactions": + return [] + raise AssertionError(f"submit must not mutate {table} after receiver mismatch") + + monkeypatch.setattr(service, "_rest", fake_rest) + + try: + service.submit_intent_tx("user-1", "intent-direct-wrong", tx_hash, "") + except Exception as exc: + assert getattr(exc, "status_code", None) == 400 + assert "receiver_mismatch" in getattr(exc, "detail", "") + else: + raise AssertionError("expected receiver mismatch rejection") + + def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monkeypatch, tmp_path): _setup_env(monkeypatch, tmp_path)