修复支付代币匹配问题:direct 模式遍历所有支持代币查 Transfer 事件,未知代币不再伪装 USDC

- _extract_direct_transfer_event: 遍历 supported_tokens 所有合约而不仅是 intent.token_address
- _default_token_meta: 未知代币显示地址缩写而非 USDC
- _token_symbol_for: 未知代币同样不伪装 USDC
This commit is contained in:
2569718930@qq.com
2026-05-23 11:41:01 +08:00
parent f661350990
commit 442a9c8560
+227 -96
View File
@@ -257,7 +257,9 @@ class PaymentContractCheckoutService:
self.supabase_service_role_key = str( self.supabase_service_role_key = str(
os.getenv("SUPABASE_SERVICE_ROLE_KEY") or "" os.getenv("SUPABASE_SERVICE_ROLE_KEY") or ""
).strip() ).strip()
self.chain_id = _env_int("POLYWEATHER_PAYMENT_CHAIN_ID", DEFAULT_POLYGON_CHAIN_ID) self.chain_id = _env_int(
"POLYWEATHER_PAYMENT_CHAIN_ID", DEFAULT_POLYGON_CHAIN_ID
)
self.token_decimals = _env_int("POLYWEATHER_PAYMENT_TOKEN_DECIMALS", 6) self.token_decimals = _env_int("POLYWEATHER_PAYMENT_TOKEN_DECIMALS", 6)
self.rpc_url = str(os.getenv("POLYWEATHER_PAYMENT_RPC_URL") or "").strip() self.rpc_url = str(os.getenv("POLYWEATHER_PAYMENT_RPC_URL") or "").strip()
self.rpc_urls = self._load_rpc_urls( self.rpc_urls = self._load_rpc_urls(
@@ -267,7 +269,8 @@ class PaymentContractCheckoutService:
os.getenv("POLYWEATHER_PAYMENT_RECEIVER_CONTRACT") or "" os.getenv("POLYWEATHER_PAYMENT_RECEIVER_CONTRACT") or ""
) )
legacy_token_address = ( legacy_token_address = (
os.getenv("POLYWEATHER_PAYMENT_TOKEN_ADDRESS") or DEFAULT_NATIVE_USDC_ADDRESS os.getenv("POLYWEATHER_PAYMENT_TOKEN_ADDRESS")
or DEFAULT_NATIVE_USDC_ADDRESS
) )
self.supported_tokens = self._load_supported_tokens( self.supported_tokens = self._load_supported_tokens(
os.getenv("POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON") or "", os.getenv("POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON") or "",
@@ -287,21 +290,25 @@ class PaymentContractCheckoutService:
self.default_token_address = next(iter(self.supported_tokens.keys())) self.default_token_address = next(iter(self.supported_tokens.keys()))
default_token = self.supported_tokens.get(self.default_token_address) default_token = self.supported_tokens.get(self.default_token_address)
self.token_address = default_token.address if default_token else "" self.token_address = default_token.address if default_token else ""
self.receiver_contract = default_token.receiver_contract if default_token else "" self.receiver_contract = (
default_token.receiver_contract if default_token else ""
)
self.direct_receiver_address = ( self.direct_receiver_address = (
_normalize_address(os.getenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS") or "") _normalize_address(
os.getenv("POLYWEATHER_PAYMENT_DIRECT_RECEIVER_ADDRESS") or ""
)
or self.receiver_contract or self.receiver_contract
) )
self.token_decimals = ( self.token_decimals = (
int(default_token.decimals) if default_token else int(self.token_decimals) int(default_token.decimals) if default_token else int(self.token_decimals)
) )
self.intent_ttl_sec = max(300, _env_int("POLYWEATHER_PAYMENT_INTENT_TTL_SEC", 1800)) self.intent_ttl_sec = max(
300, _env_int("POLYWEATHER_PAYMENT_INTENT_TTL_SEC", 1800)
)
self.challenge_ttl_sec = max( self.challenge_ttl_sec = max(
60, _env_int("POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC", 600) 60, _env_int("POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC", 600)
) )
self.confirmations = max( self.confirmations = max(1, _env_int("POLYWEATHER_PAYMENT_CONFIRMATIONS", 2))
1, _env_int("POLYWEATHER_PAYMENT_CONFIRMATIONS", 2)
)
self.timeout_sec = max(5, _env_int("POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC", 10)) self.timeout_sec = max(5, _env_int("POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC", 10))
self.poll_interval_sec = max( self.poll_interval_sec = max(
2, _env_int("POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC", 4) 2, _env_int("POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC", 4)
@@ -329,7 +336,9 @@ class PaymentContractCheckoutService:
"POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED", True "POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED", True
) )
self.points_enabled = _env_bool("POLYWEATHER_PAYMENT_POINTS_ENABLED", True) self.points_enabled = _env_bool("POLYWEATHER_PAYMENT_POINTS_ENABLED", True)
self.points_per_usdc = max(1, _env_int("POLYWEATHER_PAYMENT_POINTS_PER_USDC", 500)) self.points_per_usdc = max(
1, _env_int("POLYWEATHER_PAYMENT_POINTS_PER_USDC", 500)
)
self.points_max_discount_usdc = max( self.points_max_discount_usdc = max(
0, _env_int("POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC", 3) 0, _env_int("POLYWEATHER_PAYMENT_POINTS_MAX_DISCOUNT_USDC", 3)
) )
@@ -387,7 +396,8 @@ class PaymentContractCheckoutService:
return {"code": "pusd", "symbol": "pUSD", "name": "Polymarket pUSD"} return {"code": "pusd", "symbol": "pUSD", "name": "Polymarket pUSD"}
if normalized == _normalize_address(DEFAULT_USDC_E_ADDRESS): if normalized == _normalize_address(DEFAULT_USDC_E_ADDRESS):
return {"code": "usdc_e", "symbol": "USDC.e", "name": "USDC.e (PoS)"} return {"code": "usdc_e", "symbol": "USDC.e", "name": "USDC.e (PoS)"}
return {"code": "usdc_token", "symbol": "USDC", "name": "USDC"} short = f"{normalized[:6]}...{normalized[-4:]}"
return {"code": f"token_{short}", "symbol": short, "name": short}
def _to_token_config( def _to_token_config(
self, self,
@@ -460,7 +470,9 @@ class PaymentContractCheckoutService:
elif isinstance(parsed, dict): elif isinstance(parsed, dict):
if isinstance(parsed.get("tokens"), list): if isinstance(parsed.get("tokens"), list):
parsed_rows = [ parsed_rows = [
row for row in parsed.get("tokens") or [] if isinstance(row, dict) row
for row in parsed.get("tokens") or []
if isinstance(row, dict)
] ]
else: else:
for key, value in parsed.items(): for key, value in parsed.items():
@@ -508,7 +520,8 @@ class PaymentContractCheckoutService:
if token: if token:
return token return token
available = ", ".join( available = ", ".join(
f"{item.symbol}:{item.address}" for item in self.supported_tokens.values() f"{item.symbol}:{item.address}"
for item in self.supported_tokens.values()
) )
raise PaymentCheckoutError( raise PaymentCheckoutError(
400, 400,
@@ -529,7 +542,10 @@ class PaymentContractCheckoutService:
token = self.supported_tokens.get(_normalize_address(token_address)) token = self.supported_tokens.get(_normalize_address(token_address))
if token and token.symbol: if token and token.symbol:
return str(token.symbol) return str(token.symbol)
return "USDC" normalized = _normalize_address(token_address)
if normalized:
return f"{normalized[:6]}...{normalized[-4:]}"
return "Unknown"
def _service_headers(self, prefer: Optional[str] = None) -> Dict[str, str]: def _service_headers(self, prefer: Optional[str] = None) -> Dict[str, str]:
headers = { headers = {
@@ -606,7 +622,9 @@ class PaymentContractCheckoutService:
timeout=self.timeout_sec, timeout=self.timeout_sec,
) )
except Exception as exc: except Exception as exc:
raise PaymentCheckoutError(503, f"supabase auth request failed: {exc}") from exc raise PaymentCheckoutError(
503, f"supabase auth request failed: {exc}"
) from exc
if response.status_code not in status_ok: if response.status_code not in status_ok:
detail = response.text[:350] if response.text else response.reason detail = response.text[:350] if response.text else response.reason
raise PaymentCheckoutError( raise PaymentCheckoutError(
@@ -629,7 +647,9 @@ class PaymentContractCheckoutService:
if isinstance(user_payload.get("user_metadata"), dict): if isinstance(user_payload.get("user_metadata"), dict):
return dict(user_payload.get("user_metadata") or {}) return dict(user_payload.get("user_metadata") or {})
user_obj = user_payload.get("user") user_obj = user_payload.get("user")
if isinstance(user_obj, dict) and isinstance(user_obj.get("user_metadata"), dict): if isinstance(user_obj, dict) and isinstance(
user_obj.get("user_metadata"), dict
):
return dict(user_obj.get("user_metadata") or {}) return dict(user_obj.get("user_metadata") or {})
return {} return {}
@@ -809,10 +829,14 @@ class PaymentContractCheckoutService:
if points_before <= 0: if points_before <= 0:
return result return result
redeemable = min(points_before, planned_points) redeemable = min(points_before, planned_points)
redeemable = (redeemable // int(self.points_per_usdc)) * int(self.points_per_usdc) redeemable = (redeemable // int(self.points_per_usdc)) * int(
self.points_per_usdc
)
if redeemable <= 0: if redeemable <= 0:
return result return result
spend_result = self._db.spend_points_by_supabase_user_id(user_id, redeemable) spend_result = self._db.spend_points_by_supabase_user_id(
user_id, redeemable
)
if not bool(spend_result.get("ok")): if not bool(spend_result.get("ok")):
return result return result
points_after = int(spend_result.get("balance") or 0) points_after = int(spend_result.get("balance") or 0)
@@ -831,7 +855,9 @@ class PaymentContractCheckoutService:
return result return result
redeemable = min(points_before, planned_points) redeemable = min(points_before, planned_points)
redeemable = (redeemable // int(self.points_per_usdc)) * int(self.points_per_usdc) redeemable = (redeemable // int(self.points_per_usdc)) * int(
self.points_per_usdc
)
if redeemable <= 0: if redeemable <= 0:
return result return result
@@ -898,7 +924,9 @@ class PaymentContractCheckoutService:
def _get_contract(self, receiver_address: Optional[str] = None): def _get_contract(self, receiver_address: Optional[str] = None):
w3 = self._get_web3() w3 = self._get_web3()
contract_address = _normalize_address(receiver_address or self.receiver_contract) contract_address = _normalize_address(
receiver_address or self.receiver_contract
)
if not contract_address: if not contract_address:
contract_address = self.receiver_contract contract_address = self.receiver_contract
return w3.eth.contract( return w3.eth.contract(
@@ -915,9 +943,13 @@ class PaymentContractCheckoutService:
"address": token.address, "address": token.address,
"decimals": int(token.decimals), "decimals": int(token.decimals),
"receiver_contract": token.receiver_contract, "receiver_contract": token.receiver_contract,
"is_default": bool(token.is_default or token.address == self.default_token_address), "is_default": bool(
token.is_default or token.address == self.default_token_address
),
} }
for token in sorted(self.supported_tokens.values(), key=lambda row: row.code) for token in sorted(
self.supported_tokens.values(), key=lambda row: row.code
)
] ]
return { return {
"enabled": self.enabled, "enabled": self.enabled,
@@ -950,7 +982,9 @@ class PaymentContractCheckoutService:
} }
def _serialize_intent(self, row: Dict[str, Any]) -> PaymentIntentRecord: def _serialize_intent(self, row: Dict[str, Any]) -> PaymentIntentRecord:
token_address = _normalize_address(row.get("token_address") or self.token_address) token_address = _normalize_address(
row.get("token_address") or self.token_address
)
token_decimals = self._token_decimals_for(token_address) token_decimals = self._token_decimals_for(token_address)
amount_units = int(_parse_decimal(row.get("amount_units"), Decimal("0"))) amount_units = int(_parse_decimal(row.get("amount_units"), Decimal("0")))
amount_display = _units_to_decimal(amount_units, token_decimals) amount_display = _units_to_decimal(amount_units, token_decimals)
@@ -973,7 +1007,9 @@ class PaymentContractCheckoutService:
allowed_wallet=_normalize_address(row.get("allowed_wallet") or "") or None, allowed_wallet=_normalize_address(row.get("allowed_wallet") or "") or None,
expires_at=str(row.get("expires_at")), expires_at=str(row.get("expires_at")),
tx_hash=str(row.get("tx_hash") or "") or None, tx_hash=str(row.get("tx_hash") or "") or None,
metadata=dict(row.get("metadata") or {}) if isinstance(row.get("metadata"), dict) else {}, metadata=dict(row.get("metadata") or {})
if isinstance(row.get("metadata"), dict)
else {},
) )
def list_wallets(self, user_id: str) -> List[WalletBindingRecord]: def list_wallets(self, user_id: str) -> List[WalletBindingRecord]:
@@ -1101,7 +1137,9 @@ class PaymentContractCheckoutService:
allowed_status=[200], allowed_status=[200],
) )
if not isinstance(challenge_rows, list) or not challenge_rows: if not isinstance(challenge_rows, list) or not challenge_rows:
raise PaymentCheckoutError(400, "wallet challenge not found or already used") raise PaymentCheckoutError(
400, "wallet challenge not found or already used"
)
challenge = challenge_rows[0] challenge = challenge_rows[0]
try: try:
@@ -1137,8 +1175,14 @@ class PaymentContractCheckoutService:
) )
if isinstance(existing, list) and existing: if isinstance(existing, list) and existing:
owner_id = str(existing[0].get("user_id") or "") owner_id = str(existing[0].get("user_id") or "")
if owner_id and owner_id != user_id and str(existing[0].get("status")) == "active": if (
raise PaymentCheckoutError(409, "wallet already bound by another account") owner_id
and owner_id != user_id
and str(existing[0].get("status")) == "active"
):
raise PaymentCheckoutError(
409, "wallet already bound by another account"
)
has_primary = self._rest( has_primary = self._rest(
"GET", "GET",
@@ -1231,7 +1275,9 @@ class PaymentContractCheckoutService:
new_primary = "" new_primary = ""
if isinstance(active_primary_rows, list) and active_primary_rows: if isinstance(active_primary_rows, list) and active_primary_rows:
new_primary = _normalize_address(active_primary_rows[0].get("address") or "") new_primary = _normalize_address(
active_primary_rows[0].get("address") or ""
)
else: else:
active_wallet_rows = self._rest( active_wallet_rows = self._rest(
"GET", "GET",
@@ -1305,7 +1351,9 @@ class PaymentContractCheckoutService:
except Exception: except Exception:
telegram_id = None telegram_id = None
price_payload = pricing.resolve_price_for_telegram_id(telegram_id) price_payload = pricing.resolve_price_for_telegram_id(telegram_id)
amount_dec = _parse_decimal(price_payload.get("amount_usdc"), out["amount_usdc_decimal"]) amount_dec = _parse_decimal(
price_payload.get("amount_usdc"), out["amount_usdc_decimal"]
)
if amount_dec <= 0: if amount_dec <= 0:
return out return out
out["amount_usdc"] = _format_decimal(amount_dec) out["amount_usdc"] = _format_decimal(amount_dec)
@@ -1358,7 +1406,9 @@ class PaymentContractCheckoutService:
if mode == "manual": if mode == "manual":
mode = "direct" mode = "direct"
if mode not in {"strict", "flex", "direct"}: if mode not in {"strict", "flex", "direct"}:
raise PaymentCheckoutError(400, "payment_mode must be strict, flex, or direct") raise PaymentCheckoutError(
400, "payment_mode must be strict, flex, or direct"
)
bound_wallets = [] if mode == "direct" else self.list_wallets(user_id) bound_wallets = [] if mode == "direct" else self.list_wallets(user_id)
if mode != "direct" and not bound_wallets: if mode != "direct" and not bound_wallets:
raise PaymentCheckoutError(403, "bind wallet first") raise PaymentCheckoutError(403, "bind wallet first")
@@ -1384,7 +1434,9 @@ class PaymentContractCheckoutService:
requested_points_to_consume=points_to_consume, requested_points_to_consume=points_to_consume,
) )
final_amount_usdc = redemption["pay_amount_usdc"] final_amount_usdc = redemption["pay_amount_usdc"]
amount_units = _decimal_to_units(final_amount_usdc, int(selected_token.decimals)) amount_units = _decimal_to_units(
final_amount_usdc, int(selected_token.decimals)
)
if amount_units <= 0: if amount_units <= 0:
raise PaymentCheckoutError(400, "invalid final payment amount") raise PaymentCheckoutError(400, "invalid final payment amount")
combined_metadata = dict(metadata or {}) combined_metadata = dict(metadata or {})
@@ -1397,15 +1449,27 @@ class PaymentContractCheckoutService:
if mode == "direct" if mode == "direct"
else selected_token.receiver_contract else selected_token.receiver_contract
) )
combined_metadata["amount_before_discount_usdc"] = _format_decimal(plan_amount_usdc) combined_metadata["amount_before_discount_usdc"] = _format_decimal(
combined_metadata["amount_after_discount_usdc"] = _format_decimal(final_amount_usdc) plan_amount_usdc
)
combined_metadata["amount_after_discount_usdc"] = _format_decimal(
final_amount_usdc
)
combined_metadata["points_redemption"] = { combined_metadata["points_redemption"] = {
"enabled": bool(redemption.get("enabled")), "enabled": bool(redemption.get("enabled")),
"applied": bool(redemption.get("applied")), "applied": bool(redemption.get("applied")),
"points_per_usdc": int(redemption.get("points_per_usdc") or self.points_per_usdc), "points_per_usdc": int(
"max_discount_usdc": int(redemption.get("max_discount_usdc") or self.points_max_discount_usdc), redemption.get("points_per_usdc") or self.points_per_usdc
"points_source": str(redemption.get("points_source") or "supabase_metadata"), ),
"points_balance_snapshot": int(redemption.get("points_balance_snapshot") or 0), "max_discount_usdc": int(
redemption.get("max_discount_usdc") or self.points_max_discount_usdc
),
"points_source": str(
redemption.get("points_source") or "supabase_metadata"
),
"points_balance_snapshot": int(
redemption.get("points_balance_snapshot") or 0
),
"points_to_consume": int(redemption.get("points_to_consume") or 0), "points_to_consume": int(redemption.get("points_to_consume") or 0),
"discount_usdc": str(redemption.get("discount_usdc") or "0"), "discount_usdc": str(redemption.get("discount_usdc") or "0"),
} }
@@ -1457,10 +1521,14 @@ class PaymentContractCheckoutService:
}, },
"points_redemption": { "points_redemption": {
"applied": bool(redemption.get("applied")), "applied": bool(redemption.get("applied")),
"points_source": str(redemption.get("points_source") or "supabase_metadata"), "points_source": str(
redemption.get("points_source") or "supabase_metadata"
),
"points_to_consume": int(redemption.get("points_to_consume") or 0), "points_to_consume": int(redemption.get("points_to_consume") or 0),
"discount_usdc": str(redemption.get("discount_usdc") or "0"), "discount_usdc": str(redemption.get("discount_usdc") or "0"),
"points_balance_snapshot": int(redemption.get("points_balance_snapshot") or 0), "points_balance_snapshot": int(
redemption.get("points_balance_snapshot") or 0
),
}, },
} }
if mode == "direct": if mode == "direct":
@@ -1477,6 +1545,7 @@ class PaymentContractCheckoutService:
"expires_at": intent.expires_at, "expires_at": intent.expires_at,
} }
return response return response
def get_intent(self, user_id: str, intent_id: str) -> PaymentIntentRecord: def get_intent(self, user_id: str, intent_id: str) -> PaymentIntentRecord:
self._ensure_enabled() self._ensure_enabled()
rows = self._rest( rows = self._rest(
@@ -1623,7 +1692,9 @@ class PaymentContractCheckoutService:
continue continue
existing_intent = str(row.get("intent_id") or "").strip() existing_intent = str(row.get("intent_id") or "").strip()
if existing_intent and existing_intent != str(intent_id): if existing_intent and existing_intent != str(intent_id):
raise PaymentCheckoutError(409, "tx_hash already used by another payment intent") raise PaymentCheckoutError(
409, "tx_hash already used by another payment intent"
)
def _record_duplicate_transaction( def _record_duplicate_transaction(
self, self,
@@ -1649,8 +1720,11 @@ class PaymentContractCheckoutService:
"chain_id": self.chain_id, "chain_id": self.chain_id,
"tx_hash": tx_hash_text, "tx_hash": tx_hash_text,
"from_address": _normalize_address(from_address) or None, "from_address": _normalize_address(from_address) or None,
"to_address": _normalize_address(to_address) or intent.receiver_address, "to_address": _normalize_address(to_address)
"payment_method": "direct" if intent.payment_mode == "direct" else "wallet", or intent.receiver_address,
"payment_method": "direct"
if intent.payment_mode == "direct"
else "wallet",
"status": status, "status": status,
"raw_receipt": {}, "raw_receipt": {},
"raw_tx": { "raw_tx": {
@@ -1744,7 +1818,7 @@ class PaymentContractCheckoutService:
"valid": False, "valid": False,
"reason": "direct_transfer_not_found", "reason": "direct_transfer_not_found",
"detail": "ERC20 Transfer event not found on token contract. " "detail": "ERC20 Transfer event not found on token contract. "
"Ensure you transferred the correct token to the receiver address.", "Ensure you transferred the correct token to the receiver address.",
"checks": checks, "checks": checks,
} }
event_from = _normalize_address(event_match.get("from")) event_from = _normalize_address(event_match.get("from"))
@@ -1786,7 +1860,7 @@ class PaymentContractCheckoutService:
"valid": False, "valid": False,
"reason": "order_paid_event_not_found", "reason": "order_paid_event_not_found",
"detail": "OrderPaid event not found. " "detail": "OrderPaid event not found. "
"Ensure the tx was sent to the correct receiver contract.", "Ensure the tx was sent to the correct receiver contract.",
"checks": checks, "checks": checks,
} }
event_payer = _normalize_address(event_match.get("payer")) event_payer = _normalize_address(event_match.get("payer"))
@@ -1812,13 +1886,21 @@ class PaymentContractCheckoutService:
if not all([order_match, plan_match, token_match, amount_match]): if not all([order_match, plan_match, token_match, amount_match]):
failures = [] failures = []
if not order_match: if not order_match:
failures.append(f"order_id mismatch: got {event_order_id}, expected {intent.order_id_hex.lower()}") failures.append(
f"order_id mismatch: got {event_order_id}, expected {intent.order_id_hex.lower()}"
)
if not plan_match: if not plan_match:
failures.append(f"plan_id mismatch: got {event_plan_id}, expected {intent.plan_id}") failures.append(
f"plan_id mismatch: got {event_plan_id}, expected {intent.plan_id}"
)
if not token_match: if not token_match:
failures.append(f"token mismatch: got {event_token}, expected {intent.token_address}") failures.append(
f"token mismatch: got {event_token}, expected {intent.token_address}"
)
if not amount_match: if not amount_match:
failures.append(f"amount mismatch: got {event_amount}, expected {intent.amount_units}") failures.append(
f"amount mismatch: got {event_amount}, expected {intent.amount_units}"
)
return { return {
"valid": False, "valid": False,
"reason": "event_mismatch", "reason": "event_mismatch",
@@ -1839,7 +1921,10 @@ class PaymentContractCheckoutService:
intent = self.get_intent(user_id, intent_id) intent = self.get_intent(user_id, intent_id)
tx_hash_text = str(tx_hash or "").strip().lower() tx_hash_text = str(tx_hash or "").strip().lower()
if intent.status == "confirmed": if intent.status == "confirmed":
if tx_hash_text and tx_hash_text != str(intent.tx_hash or "").strip().lower(): if (
tx_hash_text
and tx_hash_text != str(intent.tx_hash or "").strip().lower()
):
self._record_duplicate_transaction( self._record_duplicate_transaction(
intent=intent, intent=intent,
tx_hash=tx_hash_text, tx_hash=tx_hash_text,
@@ -1852,7 +1937,9 @@ class PaymentContractCheckoutService:
"该订单已支付,请勿重复付款;如已重复转账请联系客服处理退款", "该订单已支付,请勿重复付款;如已重复转账请联系客服处理退款",
) )
if intent.status not in {"created", "submitted"}: if intent.status not in {"created", "submitted"}:
raise PaymentCheckoutError(409, f"intent status is {intent.status}, cannot submit") raise PaymentCheckoutError(
409, f"intent status is {intent.status}, cannot submit"
)
from_addr = _normalize_address(from_address) from_addr = _normalize_address(from_address)
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):
@@ -1911,7 +1998,9 @@ class PaymentContractCheckoutService:
"tx_hash": tx_hash_text, "tx_hash": tx_hash_text,
"from_address": from_addr, "from_address": from_addr,
"to_address": intent.receiver_address, "to_address": intent.receiver_address,
"payment_method": "direct" if intent.payment_mode == "direct" else "wallet", "payment_method": "direct"
if intent.payment_mode == "direct"
else "wallet",
"status": "submitted", "status": "submitted",
"updated_at": now_iso, "updated_at": now_iso,
}, },
@@ -1923,7 +2012,9 @@ class PaymentContractCheckoutService:
"status": "submitted", "status": "submitted",
"tx_hash": tx_hash_text, "tx_hash": tx_hash_text,
"from_address": from_addr, "from_address": from_addr,
"transaction": tx_rows[0] if isinstance(tx_rows, list) and tx_rows else None, "transaction": tx_rows[0]
if isinstance(tx_rows, list) and tx_rows
else None,
} }
def _wait_receipt(self, tx_hash: str) -> Any: def _wait_receipt(self, tx_hash: str) -> Any:
@@ -1992,33 +2083,54 @@ class PaymentContractCheckoutService:
def _extract_direct_transfer_event( def _extract_direct_transfer_event(
self, receipt: Any, intent: PaymentIntentRecord self, receipt: Any, intent: PaymentIntentRecord
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
token_contract = self._get_web3().eth.contract(
address=Web3.to_checksum_address(intent.token_address),
abi=[ERC20_TRANSFER_EVENT_ABI],
)
try:
events = token_contract.events.Transfer().process_receipt(receipt)
except Exception:
events = []
if not events:
return None
expected_to = intent.receiver_address expected_to = intent.receiver_address
expected_amount = int(intent.amount_units) expected_amount = int(intent.amount_units)
for ev in events:
args = ev.get("args") if isinstance(ev, dict) else getattr(ev, "args", None) # Collect all token contracts to check: intent's token first,
if not args: # then all other supported tokens (in case user transferred a
# different token than selected in the UI).
token_addresses: List[str] = []
if intent.token_address:
token_addresses.append(_normalize_address(intent.token_address))
for addr in self.supported_tokens:
normalized = _normalize_address(addr)
if normalized and normalized not in token_addresses:
token_addresses.append(normalized)
for token_addr in token_addresses:
try:
token_contract = self._get_web3().eth.contract(
address=Web3.to_checksum_address(token_addr),
abi=[ERC20_TRANSFER_EVENT_ABI],
)
events = token_contract.events.Transfer().process_receipt(receipt)
except Exception:
continue continue
payer = _normalize_address(args.get("from"))
receiver = _normalize_address(args.get("to")) for ev in events:
amount = int(args.get("value") or 0) args = (
if receiver == expected_to and amount >= expected_amount: ev.get("args")
return { if isinstance(ev, dict)
"from": payer, else getattr(ev, "args", None)
"to": receiver, )
"token_address": intent.token_address, if not args:
"amount_units": amount, continue
} payer = _normalize_address(args.get("from"))
receiver = _normalize_address(args.get("to"))
amount = int(args.get("value") or 0)
if receiver == expected_to and amount >= expected_amount:
token_meta = self._token_symbol_for(token_addr)
return {
"from": payer,
"to": receiver,
"token_address": token_addr,
"amount_units": amount,
"token_mismatch": (
token_addr != _normalize_address(intent.token_address)
),
"token_symbol": token_meta,
}
return None return None
def _insert_payment_record( def _insert_payment_record(
@@ -2093,7 +2205,9 @@ class PaymentContractCheckoutService:
if isinstance(current_subscription, dict): if isinstance(current_subscription, dict):
try: try:
latest_exp = datetime.fromisoformat( latest_exp = datetime.fromisoformat(
str(current_subscription.get("expires_at") or "").replace("Z", "+00:00") str(current_subscription.get("expires_at") or "").replace(
"Z", "+00:00"
)
) )
if latest_exp.tzinfo is None: if latest_exp.tzinfo is None:
latest_exp = latest_exp.replace(tzinfo=timezone.utc) latest_exp = latest_exp.replace(tzinfo=timezone.utc)
@@ -2147,9 +2261,9 @@ class PaymentContractCheckoutService:
user_id, user_id,
respect_requirement=False, respect_requirement=False,
) )
if isinstance(latest_subscription, dict) and not self._subscription_row_is_trial( if isinstance(
latest_subscription latest_subscription, dict
): ) and not self._subscription_row_is_trial(latest_subscription):
return latest_subscription return latest_subscription
plan = self._select_plan(intent.plan_code) plan = self._select_plan(intent.plan_code)
@@ -2293,7 +2407,9 @@ class PaymentContractCheckoutService:
}, },
) )
def _notify_telegram(self, user_id: str, plan_code: str, amount_usdc: str, tx_hash: str) -> None: def _notify_telegram(
self, user_id: str, plan_code: str, amount_usdc: str, tx_hash: str
) -> None:
if not self.notify_telegram: if not self.notify_telegram:
return return
token = str(os.getenv("TELEGRAM_BOT_TOKEN") or "").strip() token = str(os.getenv("TELEGRAM_BOT_TOKEN") or "").strip()
@@ -2305,7 +2421,9 @@ class PaymentContractCheckoutService:
telegram_id = int(user.get("telegram_id") or 0) telegram_id = int(user.get("telegram_id") or 0)
if telegram_id <= 0: if telegram_id <= 0:
return return
short_hash = tx_hash[:10] + "..." + tx_hash[-8:] if len(tx_hash) > 20 else tx_hash short_hash = (
tx_hash[:10] + "..." + tx_hash[-8:] if len(tx_hash) > 20 else tx_hash
)
text = ( text = (
"✅ PolyWeather 支付确认\n" "✅ PolyWeather 支付确认\n"
f"用户: {user_id}\n" f"用户: {user_id}\n"
@@ -2348,7 +2466,9 @@ class PaymentContractCheckoutService:
raise PaymentCheckoutError(409, f"intent status is {intent.status}") raise PaymentCheckoutError(409, f"intent status is {intent.status}")
tx_hash_text = str(tx_hash or intent.tx_hash or "").strip().lower() tx_hash_text = str(tx_hash or intent.tx_hash or "").strip().lower()
if intent.status == "failed" and not tx_hash_text: if intent.status == "failed" and not tx_hash_text:
raise PaymentCheckoutError(409, "intent status is failed and tx_hash is missing") raise PaymentCheckoutError(
409, "intent status is failed and tx_hash is missing"
)
if not tx_hash_text: if not tx_hash_text:
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):
@@ -2378,12 +2498,8 @@ class PaymentContractCheckoutService:
tx_get = getattr(tx, "get", None) tx_get = getattr(tx, "get", None)
tx_to_raw = tx_get("to") if callable(tx_get) else None tx_to_raw = tx_get("to") if callable(tx_get) else None
tx_from_raw = tx_get("from") if callable(tx_get) else None tx_from_raw = tx_get("from") if callable(tx_get) else None
tx_to = _normalize_address( tx_to = _normalize_address(tx_to_raw or receipt.get("to"))
tx_to_raw or receipt.get("to") tx_from = _normalize_address(tx_from_raw or receipt.get("from"))
)
tx_from = _normalize_address(
tx_from_raw or receipt.get("from")
)
if not tx_to or not tx_from: if not tx_to or not tx_from:
raise PaymentCheckoutError(409, "tx indexed partially; retry confirm") raise PaymentCheckoutError(409, "tx indexed partially; retry confirm")
block_number = int(receipt.get("blockNumber") or 0) block_number = int(receipt.get("blockNumber") or 0)
@@ -2396,12 +2512,16 @@ class PaymentContractCheckoutService:
is_direct = intent.payment_mode == "direct" is_direct = intent.payment_mode == "direct"
if is_direct: if is_direct:
event_match = self._extract_direct_transfer_event(receipt, intent) event_match = self._extract_direct_transfer_event(receipt, intent)
event_payer = _normalize_address(event_match.get("from")) if event_match else None event_payer = (
_normalize_address(event_match.get("from")) if event_match else None
)
effective_payer = event_payer or tx_from effective_payer = event_payer or tx_from
routed_via_delegate = False routed_via_delegate = False
else: else:
event_match = self._extract_matching_event(receipt, intent) event_match = self._extract_matching_event(receipt, intent)
event_payer = _normalize_address(event_match.get("payer")) if event_match else None event_payer = (
_normalize_address(event_match.get("payer")) if event_match else None
)
effective_payer = event_payer or tx_from effective_payer = event_payer or tx_from
routed_via_delegate = bool( routed_via_delegate = bool(
event_match and tx_to and tx_to != intent.receiver_address event_match and tx_to and tx_to != intent.receiver_address
@@ -2468,7 +2588,9 @@ class PaymentContractCheckoutService:
redemption_meta = confirmed_metadata.get("points_redemption") redemption_meta = confirmed_metadata.get("points_redemption")
if isinstance(redemption_meta, dict): if isinstance(redemption_meta, dict):
redemption_meta["consumed"] = bool(points_result.get("points_redeemed")) redemption_meta["consumed"] = bool(points_result.get("points_redeemed"))
redemption_meta["consumed_points"] = int(points_result.get("points_redeemed") or 0) redemption_meta["consumed_points"] = int(
points_result.get("points_redeemed") or 0
)
redemption_meta["points_after"] = points_result.get("points_after") redemption_meta["points_after"] = points_result.get("points_after")
redemption_meta["consumed_at"] = now_iso redemption_meta["consumed_at"] = now_iso
confirmed_metadata["points_redemption"] = redemption_meta confirmed_metadata["points_redemption"] = redemption_meta
@@ -2522,7 +2644,9 @@ class PaymentContractCheckoutService:
"payment": repaired.get("payment"), "payment": repaired.get("payment"),
"subscription": repaired.get("subscription"), "subscription": repaired.get("subscription"),
} }
raise PaymentCheckoutError(409, f"intent status is {refreshed.status}, cannot confirm") raise PaymentCheckoutError(
409, f"intent status is {refreshed.status}, cannot confirm"
)
tx_rows = self._rest( tx_rows = self._rest(
"POST", "POST",
"payment_transactions", "payment_transactions",
@@ -2589,7 +2713,9 @@ class PaymentContractCheckoutService:
refreshed = self.get_intent(user_id, intent.intent_id) refreshed = self.get_intent(user_id, intent.intent_id)
return { return {
"intent": refreshed.__dict__, "intent": refreshed.__dict__,
"transaction": tx_rows[0] if isinstance(tx_rows, list) and tx_rows else None, "transaction": tx_rows[0]
if isinstance(tx_rows, list) and tx_rows
else None,
"payment": payment_row, "payment": payment_row,
"subscription": subscription_row, "subscription": subscription_row,
"points_redemption": points_result, "points_redemption": points_result,
@@ -2625,7 +2751,9 @@ class PaymentContractCheckoutService:
tx_hash_text = str(intent.tx_hash or "").strip().lower() tx_hash_text = str(intent.tx_hash or "").strip().lower()
try: try:
if status in {"submitted", "failed"} and tx_hash_text: if status in {"submitted", "failed"} and tx_hash_text:
result = self.confirm_intent_tx(user_id, intent.intent_id, tx_hash_text) result = self.confirm_intent_tx(
user_id, intent.intent_id, tx_hash_text
)
return { return {
"ok": True, "ok": True,
"action": "confirmed_submitted_intent" "action": "confirmed_submitted_intent"
@@ -2634,7 +2762,9 @@ class PaymentContractCheckoutService:
**result, **result,
} }
if status == "confirmed": if status == "confirmed":
repaired = self._ensure_confirm_side_effects(user_id, intent, tx_hash_text) repaired = self._ensure_confirm_side_effects(
user_id, intent, tx_hash_text
)
refreshed = self.get_intent(user_id, intent.intent_id) refreshed = self.get_intent(user_id, intent.intent_id)
return { return {
"ok": True, "ok": True,
@@ -2705,5 +2835,6 @@ class PaymentContractCheckoutService:
"processed_users": len(seen_users), "processed_users": len(seen_users),
"repaired_users": repaired_users, "repaired_users": repaired_users,
} }
PAYMENT_CHECKOUT = PaymentContractCheckoutService()
PAYMENT_CHECKOUT = PaymentContractCheckoutService()