Prevent duplicate payment confirmations
This commit is contained in:
@@ -2059,6 +2059,26 @@ class PaymentContractCheckoutService:
|
|||||||
raise PaymentCheckoutError(
|
raise PaymentCheckoutError(
|
||||||
409, "tx_hash already used by another payment intent"
|
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(
|
def _record_duplicate_transaction(
|
||||||
self,
|
self,
|
||||||
@@ -2893,6 +2913,7 @@ class PaymentContractCheckoutService:
|
|||||||
raise PaymentCheckoutError(400, "tx_hash required")
|
raise PaymentCheckoutError(400, "tx_hash required")
|
||||||
if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66):
|
if not (tx_hash_text.startswith("0x") and len(tx_hash_text) == 66):
|
||||||
raise PaymentCheckoutError(400, "invalid tx_hash")
|
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)
|
w3 = self._get_web3(chain_id=intent.chain_id)
|
||||||
if not w3.is_connected():
|
if not w3.is_connected():
|
||||||
raise PaymentCheckoutError(503, "cannot connect payment rpc")
|
raise PaymentCheckoutError(503, "cannot connect payment rpc")
|
||||||
|
|||||||
@@ -260,6 +260,8 @@ def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path):
|
|||||||
def fake_rest(method, table, **kwargs):
|
def fake_rest(method, table, **kwargs):
|
||||||
if method == "GET" and table == "payment_transactions":
|
if method == "GET" and table == "payment_transactions":
|
||||||
return []
|
return []
|
||||||
|
if method == "GET" and table == "payment_intents":
|
||||||
|
return []
|
||||||
if method == "PATCH" and table == "payment_intents":
|
if method == "PATCH" and table == "payment_intents":
|
||||||
assert kwargs["prefer"] == "return=representation"
|
assert kwargs["prefer"] == "return=representation"
|
||||||
assert kwargs["params"]["select"] == "id"
|
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):
|
def fake_rest(method, table, **kwargs):
|
||||||
if method == "GET" and table == "payment_transactions":
|
if method == "GET" and table == "payment_transactions":
|
||||||
return []
|
return []
|
||||||
|
if method == "GET" and table == "payment_intents":
|
||||||
|
return []
|
||||||
if method == "PATCH" and table == "payment_intents":
|
if method == "PATCH" and table == "payment_intents":
|
||||||
submitted.update(kwargs["payload"])
|
submitted.update(kwargs["payload"])
|
||||||
return [{"ok": True}]
|
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):
|
def fake_rest(method, table, **kwargs):
|
||||||
if method == "GET" and table == "payment_transactions":
|
if method == "GET" and table == "payment_transactions":
|
||||||
return []
|
return []
|
||||||
|
if method == "GET" and table == "payment_intents":
|
||||||
|
return []
|
||||||
raise AssertionError(f"submit must not mutate {table} before validation passes")
|
raise AssertionError(f"submit must not mutate {table} before validation passes")
|
||||||
|
|
||||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
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):
|
def fake_rest(method, table, **kwargs):
|
||||||
if method == "GET" and table == "payment_transactions":
|
if method == "GET" and table == "payment_transactions":
|
||||||
return []
|
return []
|
||||||
|
if method == "GET" and table == "payment_intents":
|
||||||
|
return []
|
||||||
raise AssertionError(f"submit must not mutate {table} after receiver mismatch")
|
raise AssertionError(f"submit must not mutate {table} after receiver mismatch")
|
||||||
|
|
||||||
monkeypatch.setattr(service, "_rest", fake_rest)
|
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))
|
rest_calls.append((method, table, kwargs))
|
||||||
if method == "GET" and table == "payment_transactions":
|
if method == "GET" and table == "payment_transactions":
|
||||||
return []
|
return []
|
||||||
|
if method == "GET" and table == "payment_intents":
|
||||||
|
return []
|
||||||
if method == "PATCH" and table == "payment_intents":
|
if method == "PATCH" and table == "payment_intents":
|
||||||
return [{"id": intent.intent_id, "status": "confirmed"}]
|
return [{"id": intent.intent_id, "status": "confirmed"}]
|
||||||
if method == "POST" and table == "payment_transactions":
|
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")
|
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):
|
def test_submit_marks_late_tx_as_refund_required_after_intent_paid(monkeypatch, tmp_path):
|
||||||
_setup_env(monkeypatch, tmp_path)
|
_setup_env(monkeypatch, tmp_path)
|
||||||
service = PaymentContractCheckoutService()
|
service = PaymentContractCheckoutService()
|
||||||
|
|||||||
@@ -1114,13 +1114,43 @@ def test_tx_hash_unused_check_selects_only_intent_id(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
def _fake_rest(method, table, **kwargs):
|
def _fake_rest(method, table, **kwargs):
|
||||||
calls.append({"method": method, "table": 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)
|
monkeypatch.setattr(service, "_rest", _fake_rest)
|
||||||
|
|
||||||
service._ensure_tx_hash_unused("0x" + "1" * 64, "intent-1")
|
service._ensure_tx_hash_unused("0x" + "1" * 64, "intent-1")
|
||||||
|
|
||||||
assert calls[0]["params"]["select"] == "intent_id"
|
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):
|
def test_grant_subscription_keeps_unknown_active_subscription_extension(monkeypatch, tmp_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user