Tighten payment tx validation before submit
This commit is contained in:
@@ -1356,8 +1356,8 @@ export function AccountCenter() {
|
|||||||
}
|
}
|
||||||
disabled={
|
disabled={
|
||||||
paymentBusy ||
|
paymentBusy ||
|
||||||
(txValidation.checked &&
|
!(txValidation.checked &&
|
||||||
!txValidation.valid)
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -71,6 +71,23 @@ export function runTests() {
|
|||||||
paymentFlowSource.includes("assertExpectedPaymentReceiver"),
|
paymentFlowSource.includes("assertExpectedPaymentReceiver"),
|
||||||
"AccountCenter must validate backend-returned manual payment receiver before displaying it",
|
"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(
|
assert(
|
||||||
accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
|
accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
|
||||||
hookSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
|
hookSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS") ||
|
||||||
|
|||||||
@@ -549,6 +549,8 @@ export function usePaymentFlow(params: UsePaymentFlowParams) {
|
|||||||
const txHashNorm = String(txHash || "").toLowerCase();
|
const txHashNorm = String(txHash || "").toLowerCase();
|
||||||
setLastTxHash(txHashNorm);
|
setLastTxHash(txHashNorm);
|
||||||
setLastPaymentStartedAt(Date.now());
|
setLastPaymentStartedAt(Date.now());
|
||||||
|
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待链上回执...`);
|
||||||
|
await waitForReceipt(txHashNorm, eth);
|
||||||
|
|
||||||
const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
|
const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
|
||||||
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }),
|
method: "POST", headers: authHeaders, body: JSON.stringify({ tx_hash: txHashNorm, from_address: payingWallet }),
|
||||||
|
|||||||
@@ -2282,6 +2282,19 @@ class PaymentContractCheckoutService:
|
|||||||
else:
|
else:
|
||||||
self._require_user_wallet(user_id, from_addr)
|
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)
|
now_iso = _to_iso(now)
|
||||||
self._rest(
|
self._rest(
|
||||||
"PATCH",
|
"PATCH",
|
||||||
|
|||||||
@@ -342,6 +342,11 @@ def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path):
|
|||||||
raise AssertionError((method, table, kwargs))
|
raise AssertionError((method, table, kwargs))
|
||||||
|
|
||||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
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, "")
|
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
|
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):
|
def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monkeypatch, tmp_path):
|
||||||
_setup_env(monkeypatch, tmp_path)
|
_setup_env(monkeypatch, tmp_path)
|
||||||
|
|||||||
Reference in New Issue
Block a user