2026-05-18 16:18:26 +08:00
|
|
|
from src.payments.contract_checkout import PaymentContractCheckoutService, PaymentIntentRecord
|
|
|
|
|
|
|
|
|
|
|
2026-05-29 01:57:42 +08:00
|
|
|
ETHEREUM_USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
|
|
|
|
|
POLYGON_USDC = "0x3c499c542cef5E3811e1192ce70d8cc03d5c3359"
|
|
|
|
|
RECEIVER = "0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32"
|
|
|
|
|
|
|
|
|
|
|
2026-05-18 16:18:26 +08:00
|
|
|
def _setup_env(monkeypatch, tmp_path):
|
|
|
|
|
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
|
|
|
|
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
|
|
|
|
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
|
|
|
|
monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://rpc-1.example")
|
|
|
|
|
monkeypatch.setenv(
|
|
|
|
|
"POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON",
|
|
|
|
|
'[{"code":"usdc_e","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xeD2f13Aa5fF033c58FB436E178451Cd07f693f32","is_default":true}]',
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
|
2026-05-20 09:20:07 +08:00
|
|
|
monkeypatch.delenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS", raising=False)
|
2026-05-18 16:18:26 +08:00
|
|
|
|
|
|
|
|
|
2026-05-29 01:57:42 +08:00
|
|
|
def _setup_multichain_env(monkeypatch, tmp_path):
|
|
|
|
|
monkeypatch.setenv("POLYWEATHER_PAYMENT_ENABLED", "true")
|
|
|
|
|
monkeypatch.setenv("SUPABASE_URL", "https://example.supabase.co")
|
|
|
|
|
monkeypatch.setenv("SUPABASE_SERVICE_ROLE_KEY", "service-role")
|
|
|
|
|
monkeypatch.setenv("POLYWEATHER_PAYMENT_RPC_URL", "https://polygon-rpc.example")
|
|
|
|
|
monkeypatch.setenv(
|
|
|
|
|
"POLYWEATHER_PAYMENT_RPC_URLS_BY_CHAIN_JSON",
|
|
|
|
|
'{"1":["https://ethereum-rpc.example"],"137":["https://polygon-rpc.example"]}',
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setenv(
|
|
|
|
|
"POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON",
|
|
|
|
|
(
|
|
|
|
|
"["
|
|
|
|
|
f'{{"code":"usdc_polygon","symbol":"USDC","name":"USDC on Polygon",'
|
|
|
|
|
f'"chain_id":137,"chain_code":"polygon","chain_name":"Polygon",'
|
|
|
|
|
f'"address":"{POLYGON_USDC}","decimals":6,'
|
|
|
|
|
f'"receiver_contract":"{RECEIVER}","is_default":true}},'
|
|
|
|
|
f'{{"code":"usdc_ethereum","symbol":"USDC","name":"USDC on Ethereum",'
|
|
|
|
|
f'"chain_id":1,"chain_code":"ethereum","chain_name":"Ethereum Mainnet",'
|
|
|
|
|
f'"address":"{ETHEREUM_USDC}","decimals":6,'
|
|
|
|
|
f'"receiver_contract":"{RECEIVER}",'
|
|
|
|
|
f'"direct_receiver_address":"{RECEIVER}",'
|
|
|
|
|
f'"supports_contract_checkout":false,'
|
|
|
|
|
f'"explorer_tx_url":"https://etherscan.io/tx/{{tx_hash}}"}}'
|
|
|
|
|
"]"
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setenv("POLYWEATHER_DB_PATH", str(tmp_path / "payments.db"))
|
|
|
|
|
monkeypatch.delenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS", raising=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_config_payload_exposes_polygon_and_ethereum_payment_routes(
|
|
|
|
|
monkeypatch, tmp_path
|
|
|
|
|
):
|
|
|
|
|
_setup_multichain_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
|
|
|
|
|
payload = service.get_config_payload()
|
|
|
|
|
|
|
|
|
|
assert payload["default_chain_id"] == 137
|
|
|
|
|
assert {row["chain_id"] for row in payload["chains"]} == {1, 137}
|
|
|
|
|
eth_token = next(
|
|
|
|
|
row for row in payload["tokens"] if row["chain_id"] == 1
|
|
|
|
|
)
|
|
|
|
|
assert eth_token["chain_code"] == "ethereum"
|
|
|
|
|
assert eth_token["address"] == ETHEREUM_USDC.lower()
|
|
|
|
|
assert eth_token["supports_direct_transfer"] is True
|
|
|
|
|
assert eth_token["supports_contract_checkout"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_direct_intent_can_target_ethereum_usdc(monkeypatch, tmp_path):
|
|
|
|
|
_setup_multichain_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
posts = []
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "POST" and table == "payment_intents":
|
|
|
|
|
payload = dict(kwargs["payload"])
|
|
|
|
|
payload["id"] = "intent-eth-1"
|
|
|
|
|
payload["tx_hash"] = None
|
|
|
|
|
posts.append(payload)
|
|
|
|
|
return [payload]
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
|
|
|
|
|
result = service.create_intent(
|
|
|
|
|
"user-1",
|
|
|
|
|
"pro_monthly",
|
|
|
|
|
payment_mode="direct",
|
|
|
|
|
chain_id=1,
|
|
|
|
|
token_address=ETHEREUM_USDC,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert posts[0]["chain_id"] == 1
|
|
|
|
|
assert posts[0]["token_address"] == ETHEREUM_USDC.lower()
|
|
|
|
|
assert result["direct_payment"]["chain_id"] == 1
|
|
|
|
|
assert result["direct_payment"]["chain"] == "ethereum"
|
|
|
|
|
assert result["direct_payment"]["chain_name"] == "Ethereum Mainnet"
|
|
|
|
|
assert result["direct_payment"]["explorer_tx_url"] == (
|
|
|
|
|
"https://etherscan.io/tx/{tx_hash}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_pending_confirm_intents_include_supported_non_default_chains(
|
|
|
|
|
monkeypatch, tmp_path
|
|
|
|
|
):
|
|
|
|
|
_setup_multichain_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
captured_params = {}
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "GET" and table == "payment_intents":
|
|
|
|
|
captured_params.update(kwargs["params"])
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"id": "intent-eth-1",
|
|
|
|
|
"user_id": "user-1",
|
|
|
|
|
"tx_hash": "0x" + "a" * 64,
|
|
|
|
|
"status": "submitted",
|
|
|
|
|
"chain_id": 1,
|
|
|
|
|
"updated_at": "2026-05-29T00:00:00+00:00",
|
|
|
|
|
"created_at": "2026-05-29T00:00:00+00:00",
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
|
|
|
|
|
rows = service.list_pending_confirm_intents(limit=5)
|
|
|
|
|
|
|
|
|
|
assert "chain_id" not in captured_params
|
|
|
|
|
assert rows[0]["intent_id"] == "intent-eth-1"
|
|
|
|
|
assert rows[0]["chain_id"] == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_insert_payment_record_stores_ethereum_chain(monkeypatch, tmp_path):
|
|
|
|
|
_setup_multichain_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
inserted = {}
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "POST" and table == "payments":
|
|
|
|
|
inserted.update(kwargs["payload"])
|
|
|
|
|
return [kwargs["payload"]]
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
|
|
|
|
|
service._insert_payment_record(
|
|
|
|
|
user_id="user-1",
|
|
|
|
|
tx_hash="0x" + "b" * 64,
|
|
|
|
|
amount_units=10_000_000,
|
|
|
|
|
token_address=ETHEREUM_USDC,
|
|
|
|
|
chain_id=1,
|
|
|
|
|
payload={},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert inserted["chain"] == "ethereum"
|
|
|
|
|
assert inserted["currency"] == "USDC"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_confirm_direct_transfer_uses_intent_chain_rpc(monkeypatch, tmp_path):
|
|
|
|
|
_setup_multichain_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
tx_hash = "0x" + "c" * 64
|
|
|
|
|
intent = PaymentIntentRecord(
|
|
|
|
|
intent_id="intent-direct-eth",
|
|
|
|
|
order_id_hex="0x" + "1" * 64,
|
|
|
|
|
plan_code="pro_monthly",
|
|
|
|
|
plan_id=101,
|
|
|
|
|
chain_id=1,
|
|
|
|
|
amount_units=10_000_000,
|
|
|
|
|
amount_usdc="10",
|
|
|
|
|
token_address=ETHEREUM_USDC.lower(),
|
|
|
|
|
token_decimals=6,
|
|
|
|
|
token_symbol="USDC",
|
|
|
|
|
receiver_address=RECEIVER.lower(),
|
|
|
|
|
status="submitted",
|
|
|
|
|
payment_mode="direct",
|
|
|
|
|
allowed_wallet=None,
|
|
|
|
|
expires_at="2099-01-01T00:00:00+00:00",
|
|
|
|
|
tx_hash=tx_hash,
|
|
|
|
|
metadata={},
|
|
|
|
|
)
|
|
|
|
|
confirmed_intent = PaymentIntentRecord(**{**intent.__dict__, "status": "confirmed"})
|
|
|
|
|
intents = [intent, confirmed_intent]
|
2026-05-29 17:22:33 +08:00
|
|
|
get_intent_calls = []
|
2026-05-29 01:57:42 +08:00
|
|
|
requested_chains = []
|
|
|
|
|
tx_rows = []
|
|
|
|
|
|
2026-05-29 17:22:33 +08:00
|
|
|
def fake_get_intent(user_id, intent_id):
|
|
|
|
|
get_intent_calls.append((user_id, intent_id))
|
|
|
|
|
return intents.pop(0) if intents else confirmed_intent
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "get_intent", fake_get_intent)
|
2026-05-29 01:57:42 +08:00
|
|
|
|
|
|
|
|
class _Eth:
|
|
|
|
|
chain_id = 1
|
|
|
|
|
block_number = 20
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_transaction(_tx_hash):
|
|
|
|
|
return {
|
|
|
|
|
"to": ETHEREUM_USDC,
|
|
|
|
|
"from": "0x9999999999999999999999999999999999999999",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _Web3:
|
|
|
|
|
eth = _Eth()
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def is_connected():
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def fake_get_web3(*args, **kwargs):
|
|
|
|
|
requested_chains.append(kwargs.get("chain_id") or (args[0] if args else None))
|
|
|
|
|
return _Web3()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_get_web3", fake_get_web3)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_wait_receipt",
|
|
|
|
|
lambda _tx_hash, *args, **kwargs: {
|
|
|
|
|
"status": 1,
|
|
|
|
|
"to": ETHEREUM_USDC,
|
|
|
|
|
"from": "0x9999999999999999999999999999999999999999",
|
|
|
|
|
"blockNumber": 10,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_extract_direct_transfer_event",
|
|
|
|
|
lambda receipt, local_intent: {
|
|
|
|
|
"from": "0x9999999999999999999999999999999999999999",
|
|
|
|
|
"to": local_intent.receiver_address,
|
|
|
|
|
"token_address": local_intent.token_address,
|
|
|
|
|
"amount_units": local_intent.amount_units,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_consume_points_for_intent",
|
|
|
|
|
lambda user_id, local_intent: {"applied": False},
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(service, "_select_plan", lambda plan_code: {"duration_days": 30})
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_insert_payment_record",
|
|
|
|
|
lambda **kwargs: {"chain_id": kwargs["chain_id"], "tx_hash": kwargs["tx_hash"]},
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_grant_subscription",
|
|
|
|
|
lambda **kwargs: {"status": "active", "plan_code": kwargs["plan_code"]},
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(service, "_notify_telegram", lambda **kwargs: None)
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "GET" and table == "payment_transactions":
|
|
|
|
|
return []
|
2026-05-30 16:17:15 +08:00
|
|
|
if method == "GET" and table == "payment_intents":
|
|
|
|
|
return []
|
2026-05-29 01:57:42 +08:00
|
|
|
if method == "PATCH" and table == "payment_intents":
|
2026-05-29 17:22:33 +08:00
|
|
|
assert kwargs["prefer"] == "return=representation"
|
|
|
|
|
assert kwargs["params"]["select"] == "id"
|
|
|
|
|
return [{"id": intent.intent_id}]
|
2026-05-29 01:57:42 +08:00
|
|
|
if method == "POST" and table == "payment_transactions":
|
|
|
|
|
tx_rows.append(kwargs["payload"])
|
|
|
|
|
return [kwargs["payload"]]
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
|
|
|
|
|
result = service.confirm_intent_tx("user-1", intent.intent_id, tx_hash)
|
|
|
|
|
|
|
|
|
|
assert 1 in requested_chains
|
|
|
|
|
assert tx_rows[0]["chain_id"] == 1
|
|
|
|
|
assert result["payment"]["chain_id"] == 1
|
2026-05-29 17:22:33 +08:00
|
|
|
assert get_intent_calls == [("user-1", intent.intent_id)]
|
2026-05-29 01:57:42 +08:00
|
|
|
|
|
|
|
|
|
2026-05-18 16:18:26 +08:00
|
|
|
def test_direct_intent_does_not_require_bound_wallet(monkeypatch, tmp_path):
|
|
|
|
|
_setup_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
posts = []
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "GET" and table == "user_wallets":
|
|
|
|
|
return []
|
|
|
|
|
if method == "POST" and table == "payment_intents":
|
|
|
|
|
payload = dict(kwargs["payload"])
|
|
|
|
|
payload["id"] = "intent-direct-1"
|
|
|
|
|
payload["tx_hash"] = None
|
|
|
|
|
posts.append(payload)
|
|
|
|
|
return [payload]
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
|
|
|
|
|
result = service.create_intent("user-1", "pro_monthly", payment_mode="direct")
|
|
|
|
|
|
|
|
|
|
assert result["intent"]["payment_mode"] == "direct"
|
|
|
|
|
assert result["intent"]["allowed_wallet"] is None
|
|
|
|
|
assert "direct_payment" in result
|
|
|
|
|
assert result["direct_payment"]["receiver_address"] == "0xed2f13aa5ff033c58fb436e178451cd07f693f32"
|
2026-05-29 19:24:46 +08:00
|
|
|
assert result["direct_payment"]["amount_usdc"] == "29.9"
|
2026-05-18 16:18:26 +08:00
|
|
|
assert posts[0]["payment_mode"] == "direct"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_direct_submit_tx_does_not_require_from_address(monkeypatch, tmp_path):
|
|
|
|
|
_setup_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
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 fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "GET" and table == "payment_transactions":
|
|
|
|
|
return []
|
2026-05-30 16:17:15 +08:00
|
|
|
if method == "GET" and table == "payment_intents":
|
|
|
|
|
return []
|
2026-05-18 16:18:26 +08:00
|
|
|
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)
|
2026-05-29 12:11:19 +08:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
2026-05-29 17:22:33 +08:00
|
|
|
"_validate_loaded_intent_tx",
|
2026-05-29 12:11:19 +08:00
|
|
|
lambda *args, **kwargs: {"valid": True, "checks": {"tx_mined": True}},
|
|
|
|
|
)
|
2026-05-18 16:18:26 +08:00
|
|
|
|
|
|
|
|
result = service.submit_intent_tx("user-1", "intent-direct-1", "0x" + "2" * 64, "")
|
|
|
|
|
|
|
|
|
|
assert result["status"] == "submitted"
|
|
|
|
|
assert result["from_address"] is None
|
|
|
|
|
assert submitted["tx_hash"] == "0x" + "2" * 64
|
|
|
|
|
|
|
|
|
|
|
2026-05-29 12:11:19 +08:00
|
|
|
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,
|
2026-05-29 17:22:33 +08:00
|
|
|
"_validate_loaded_intent_tx",
|
2026-05-29 12:11:19 +08:00
|
|
|
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 []
|
2026-05-30 16:17:15 +08:00
|
|
|
if method == "GET" and table == "payment_intents":
|
|
|
|
|
return []
|
2026-05-29 12:11:19 +08:00
|
|
|
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,
|
2026-05-29 17:22:33 +08:00
|
|
|
"_validate_loaded_intent_tx",
|
2026-05-29 12:11:19 +08:00
|
|
|
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 []
|
2026-05-30 16:17:15 +08:00
|
|
|
if method == "GET" and table == "payment_intents":
|
|
|
|
|
return []
|
2026-05-29 12:11:19 +08:00
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
2026-05-18 16:18:26 +08:00
|
|
|
|
|
|
|
|
def test_confirm_direct_transfer_uses_erc20_transfer_without_wallet_binding(monkeypatch, tmp_path):
|
|
|
|
|
_setup_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
tx_hash = "0x" + "3" * 64
|
|
|
|
|
intent = PaymentIntentRecord(
|
|
|
|
|
intent_id="intent-direct-2",
|
|
|
|
|
order_id_hex="0x" + "1" * 64,
|
|
|
|
|
plan_code="pro_monthly",
|
|
|
|
|
plan_id=101,
|
|
|
|
|
chain_id=137,
|
|
|
|
|
amount_units=5000000,
|
|
|
|
|
amount_usdc="5",
|
|
|
|
|
token_address="0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
|
|
|
|
|
token_decimals=6,
|
|
|
|
|
token_symbol="USDC.e",
|
|
|
|
|
receiver_address="0xed2f13aa5ff033c58fb436e178451cd07f693f32",
|
|
|
|
|
status="submitted",
|
|
|
|
|
payment_mode="direct",
|
|
|
|
|
allowed_wallet=None,
|
|
|
|
|
expires_at="2099-01-01T00:00:00+00:00",
|
|
|
|
|
tx_hash=tx_hash,
|
|
|
|
|
metadata={},
|
|
|
|
|
)
|
|
|
|
|
confirmed_intent = PaymentIntentRecord(**{**intent.__dict__, "status": "confirmed"})
|
|
|
|
|
intents = [intent, confirmed_intent]
|
|
|
|
|
rest_calls = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "get_intent", lambda user_id, intent_id: intents.pop(0) if intents else confirmed_intent)
|
|
|
|
|
|
|
|
|
|
class _Eth:
|
|
|
|
|
chain_id = 137
|
|
|
|
|
block_number = 20
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def get_transaction(_tx_hash):
|
|
|
|
|
return {
|
|
|
|
|
"to": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
|
|
|
|
|
"from": "0x9999999999999999999999999999999999999999",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _Web3:
|
|
|
|
|
eth = _Eth()
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def is_connected():
|
|
|
|
|
return True
|
|
|
|
|
|
2026-05-29 01:57:42 +08:00
|
|
|
monkeypatch.setattr(service, "_get_web3", lambda *args, **kwargs: _Web3())
|
2026-05-18 16:18:26 +08:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_wait_receipt",
|
2026-05-29 01:57:42 +08:00
|
|
|
lambda _tx_hash, *args, **kwargs: {
|
2026-05-18 16:18:26 +08:00
|
|
|
"status": 1,
|
|
|
|
|
"to": "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
|
|
|
|
|
"from": "0x9999999999999999999999999999999999999999",
|
|
|
|
|
"blockNumber": 10,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
service,
|
|
|
|
|
"_extract_direct_transfer_event",
|
|
|
|
|
lambda receipt, local_intent: {
|
|
|
|
|
"from": "0x9999999999999999999999999999999999999999",
|
|
|
|
|
"to": local_intent.receiver_address,
|
|
|
|
|
"token_address": local_intent.token_address,
|
|
|
|
|
"amount_units": local_intent.amount_units,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(service, "_consume_points_for_intent", lambda user_id, local_intent: {"applied": False})
|
|
|
|
|
monkeypatch.setattr(service, "_select_plan", lambda plan_code: {"duration_days": 30})
|
|
|
|
|
monkeypatch.setattr(service, "_insert_payment_record", lambda **kwargs: {"tx_hash": kwargs["tx_hash"]})
|
|
|
|
|
monkeypatch.setattr(service, "_grant_subscription", lambda **kwargs: {"status": "active", "plan_code": kwargs["plan_code"]})
|
|
|
|
|
monkeypatch.setattr(service, "_notify_telegram", lambda **kwargs: None)
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
rest_calls.append((method, table, kwargs))
|
|
|
|
|
if method == "GET" and table == "payment_transactions":
|
|
|
|
|
return []
|
2026-05-30 16:17:15 +08:00
|
|
|
if method == "GET" and table == "payment_intents":
|
|
|
|
|
return []
|
2026-05-18 16:18:26 +08:00
|
|
|
if method == "PATCH" and table == "payment_intents":
|
|
|
|
|
return [{"id": intent.intent_id, "status": "confirmed"}]
|
|
|
|
|
if method == "POST" and table == "payment_transactions":
|
2026-05-29 17:22:33 +08:00
|
|
|
return []
|
2026-05-18 16:18:26 +08:00
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
monkeypatch.setattr(service, "_require_user_wallet", lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("wallet not required")))
|
|
|
|
|
|
|
|
|
|
result = service.confirm_intent_tx("user-1", intent.intent_id, tx_hash)
|
|
|
|
|
|
|
|
|
|
assert result["subscription"]["status"] == "active"
|
2026-05-29 17:22:33 +08:00
|
|
|
assert result["transaction"]["tx_hash"] == tx_hash
|
2026-05-18 16:18:26 +08:00
|
|
|
assert result["tx"]["event"]["amount_units"] == 5000000
|
2026-05-29 17:22:33 +08:00
|
|
|
transaction_write = next(
|
|
|
|
|
call
|
|
|
|
|
for call in rest_calls
|
|
|
|
|
if call[0] == "POST" and call[1] == "payment_transactions"
|
|
|
|
|
)
|
|
|
|
|
assert transaction_write[2]["prefer"] == "resolution=merge-duplicates,return=minimal"
|
2026-05-18 16:18:26 +08:00
|
|
|
|
|
|
|
|
def test_submit_rejects_tx_hash_used_by_another_intent(monkeypatch, tmp_path):
|
|
|
|
|
_setup_env(monkeypatch, tmp_path)
|
|
|
|
|
service = PaymentContractCheckoutService()
|
|
|
|
|
tx_hash = "0x" + "4" * 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", "tx_hash": tx_hash, "status": "confirmed"}]
|
|
|
|
|
raise AssertionError((method, table, kwargs))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(service, "_rest", fake_rest)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
service.submit_intent_tx("user-1", "intent-direct-3", tx_hash, "")
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
assert getattr(exc, "status_code", None) == 409
|
2026-05-30 16:17:15 +08:00
|
|
|
assert "tx_hash already used" in getattr(exc, "detail", "")
|
|
|
|
|
else:
|
|
|
|
|
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
|
2026-05-18 16:18:26 +08:00
|
|
|
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()
|
|
|
|
|
paid_tx = "0x" + "5" * 64
|
|
|
|
|
late_tx = "0x" + "6" * 64
|
|
|
|
|
duplicate_rows = []
|
|
|
|
|
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": "confirmed",
|
|
|
|
|
"expires_at": "2099-01-01T00:00:00+00:00",
|
|
|
|
|
"tx_hash": paid_tx,
|
|
|
|
|
"metadata": {},
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def fake_rest(method, table, **kwargs):
|
|
|
|
|
if method == "POST" and table == "payment_transactions":
|
|
|
|
|
duplicate_rows.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-paid", late_tx, "")
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
assert getattr(exc, "status_code", None) == 409
|
|
|
|
|
assert "已支付" in getattr(exc, "detail", "")
|
|
|
|
|
else:
|
|
|
|
|
raise AssertionError("expected already-paid rejection")
|
|
|
|
|
|
|
|
|
|
assert duplicate_rows[0]["tx_hash"] == late_tx
|
|
|
|
|
assert duplicate_rows[0]["status"] == "refund_required"
|
|
|
|
|
assert duplicate_rows[0]["payment_method"] == "direct"
|