feat: Implement a new contract-based payment system with wallet binding, payment intents, and subscription plan management.

This commit is contained in:
2569718930@qq.com
2026-03-14 10:35:30 +08:00
parent be61c39795
commit 02e35a3a5f
10 changed files with 698 additions and 9 deletions
+4
View File
@@ -141,6 +141,10 @@ POLYGON_WALLET_WATCH_POLYMARKET_CONTRACTS=
# Polymarket Wallet Activity Watcher (all markets, not weather-only)
POLYMARKET_WALLET_ACTIVITY_ENABLED=false
POLYMARKET_WALLET_ACTIVITY_USERS=0x0000000000000000000000000000000000000000
# Optional dedicated chat targets for wallet activity push (recommended)
# If unset, fallback to TELEGRAM_CHAT_IDS / TELEGRAM_CHAT_ID.
POLYMARKET_WALLET_ACTIVITY_CHAT_ID=
POLYMARKET_WALLET_ACTIVITY_CHAT_IDS=
# Optional wallet nicknames:
# - CSV: 0xabc...=Whale_A,0xdef...=Main_Account
# - JSON: {"0xabc...":"Whale A","0xdef...":"Main Account"}
+5
View File
@@ -0,0 +1,5 @@
{
"css.validate": false,
"scss.validate": false,
"less.validate": false
}
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(
req: NextRequest,
context: { params: Promise<{ intentId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
const { intentId } = await context.params;
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}`,
{
method: "GET",
headers: auth.headers,
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: res.status },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch payment intent", detail: String(error) },
{ status: 500 },
);
}
}
+63 -3
View File
@@ -125,6 +125,14 @@ type CreatedIntent = {
};
};
type IntentStatusResponse = {
intent?: {
intent_id?: string;
status?: string;
tx_hash?: string | null;
};
};
declare global {
interface Window {
ethereum?: EvmProvider;
@@ -398,7 +406,7 @@ function normalizePaymentError(error: unknown): NormalizedPaymentError {
?.trim();
const lower = String(rawMessage || "").toLowerCase();
if (lower.includes("confirm pending")) {
if (lower.includes("confirm pending") || lower.includes("payment pending timeout")) {
return {
message: "链上交易已提交,正在确认中,请稍后刷新查看状态。",
pending: true,
@@ -940,6 +948,55 @@ export function AccountCenter() {
throw new Error(`transaction confirmation timeout: ${txHash}`);
};
const pollIntentUntilConfirmed = useCallback(
async (
intentId: string,
authHeaders: Record<string, string>,
txHashHint = "",
timeoutMs = 180000,
pollMs = 5000,
) => {
const startedAt = Date.now();
const shortTx = shortAddress(txHashHint);
while (Date.now() - startedAt < timeoutMs) {
const statusRes = await fetch(`/api/payments/intents/${intentId}`, {
method: "GET",
headers: authHeaders,
cache: "no-store",
});
if (!statusRes.ok) {
if (statusRes.status >= 500 || statusRes.status === 429) {
await new Promise((resolve) => setTimeout(resolve, pollMs));
continue;
}
const raw = (await statusRes.text()).slice(0, 260);
throw new Error(`query intent failed: ${raw}`);
}
const statusJson = (await statusRes.json()) as IntentStatusResponse;
const intent = statusJson.intent || {};
const status = String(intent.status || "").toLowerCase();
const txHash = String(intent.tx_hash || txHashHint || "").toLowerCase();
if (status === "confirmed") {
setPaymentError("");
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHash)}`);
await loadSnapshot();
await loadPaymentSnapshot();
return;
}
if (status === "failed" || status === "cancelled" || status === "expired") {
throw new Error(`payment ${status}`);
}
setPaymentInfo(
`交易已提交: ${shortTx},正在链上确认(状态: ${status || "submitted"}...`,
);
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
throw new Error("payment pending timeout");
},
[loadPaymentSnapshot, loadSnapshot],
);
const signBindMessage = async (
eth: EvmProvider,
address: string,
@@ -1296,8 +1353,11 @@ export function AccountCenter() {
(lowerRaw.includes("confirmations not enough") ||
lowerRaw.includes("tx indexed partially")));
if (maybePending) {
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待确认中。`);
throw new Error(`confirm pending: ${raw}`);
setPaymentInfo(
`交易已提交: ${shortAddress(txHashNorm)},等待链上确认中...`,
);
await pollIntentUntilConfirmed(intentId, authHeaders, txHashNorm);
return;
}
throw new Error(`confirm failed: ${raw}`);
}
+46 -3
View File
@@ -6,7 +6,10 @@ from datetime import datetime
from importlib import import_module
from typing import Any, Callable, Dict, List, Optional
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
from src.utils.telegram_chat_ids import (
get_polymarket_wallet_activity_chat_ids_from_env,
get_telegram_chat_ids_from_env,
)
def _env_bool(name: str, default: bool) -> bool:
@@ -87,6 +90,7 @@ class StartupCoordinator:
self._start_polygon_wallet_loop(),
self._start_polymarket_wallet_activity_loop(),
self._start_weekly_reward_loop(),
self._start_payment_event_loop(),
self._start_payment_confirm_loop(),
]
self._runtime_status = RuntimeStatus(
@@ -210,7 +214,7 @@ class StartupCoordinator:
def _start_polymarket_wallet_activity_loop(self) -> LoopStatus:
enabled = _env_bool("POLYMARKET_WALLET_ACTIVITY_ENABLED", False)
chat_ids = get_telegram_chat_ids_from_env()
chat_ids = get_polymarket_wallet_activity_chat_ids_from_env()
users_count = _parse_csv_count(os.getenv("POLYMARKET_WALLET_ACTIVITY_USERS"))
poll = max(5, _env_int("POLYMARKET_WALLET_ACTIVITY_INTERVAL_SEC", 20))
details = {
@@ -221,7 +225,9 @@ class StartupCoordinator:
}
validation_error = None
if not chat_ids:
validation_error = "missing_TELEGRAM_CHAT_IDS"
validation_error = (
"missing_POLYMARKET_WALLET_ACTIVITY_CHAT_IDS_or_TELEGRAM_CHAT_IDS"
)
elif users_count == 0:
validation_error = "missing_POLYMARKET_WALLET_ACTIVITY_USERS"
return self._start_with_validation(
@@ -300,6 +306,43 @@ class StartupCoordinator:
).start_payment_confirm_loop(),
)
def _start_payment_event_loop(self) -> LoopStatus:
enabled = _env_bool("POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED", True)
details = {
"interval_sec": max(
5, _env_int("POLYWEATHER_PAYMENT_EVENT_LOOP_INTERVAL_SEC", 20)
),
"lookback_blocks": max(
500,
_env_int(
"POLYWEATHER_PAYMENT_EVENT_LOOP_START_LOOKBACK_BLOCKS",
5000,
),
),
"step_blocks": min(
49999,
max(100, _env_int("POLYWEATHER_PAYMENT_EVENT_LOOP_STEP_BLOCKS", 2000)),
),
"max_events": max(
10, _env_int("POLYWEATHER_PAYMENT_EVENT_LOOP_MAX_EVENTS_PER_CYCLE", 200)
),
"payment_enabled": _env_bool("POLYWEATHER_PAYMENT_ENABLED", False),
"chain_id": _env_int("POLYWEATHER_PAYMENT_CHAIN_ID", 137),
}
validation_error = None
if not bool(details["payment_enabled"]):
validation_error = "payment_service_disabled"
return self._start_with_validation(
key="payment_event",
label="支付事件监听",
configured_enabled=enabled,
details=details,
validation_error=validation_error,
starter=lambda: import_module(
"src.payments.event_loop"
).start_payment_event_loop(),
)
def render_runtime_status_html(status: RuntimeStatus) -> str:
lines = [
@@ -9,7 +9,9 @@ from typing import Any, Dict, List, Optional, Tuple
import requests
from loguru import logger
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
from src.utils.telegram_chat_ids import (
get_polymarket_wallet_activity_chat_ids_from_env,
)
def _env_bool(name: str, default: bool) -> bool:
@@ -577,7 +579,7 @@ def _filter_changes_by_position_value(
def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread]:
enabled = _env_bool("POLYMARKET_WALLET_ACTIVITY_ENABLED", False)
chat_ids = get_telegram_chat_ids_from_env()
chat_ids = get_polymarket_wallet_activity_chat_ids_from_env()
users = _parse_addresses(os.getenv("POLYMARKET_WALLET_ACTIVITY_USERS"))
user_aliases = _parse_address_aliases(
os.getenv("POLYMARKET_WALLET_ACTIVITY_USER_ALIASES")
@@ -591,7 +593,10 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread
logger.info("polymarket wallet activity watcher disabled")
return None
if not chat_ids:
logger.warning("polymarket wallet activity watcher skipped: TELEGRAM_CHAT_IDS is not set")
logger.warning(
"polymarket wallet activity watcher skipped: "
"POLYMARKET_WALLET_ACTIVITY_CHAT_IDS/CHAT_ID and TELEGRAM_CHAT_IDS/CHAT_ID are empty"
)
return None
if not users:
logger.warning("polymarket wallet activity watcher skipped: POLYMARKET_WALLET_ACTIVITY_USERS is empty")
+75
View File
@@ -103,6 +103,21 @@ def _normalize_address(address: Any) -> str:
return Web3.to_checksum_address(text).lower()
def _normalize_order_id_hex(order_id_hex: Any) -> str:
text = str(order_id_hex or "").strip().lower()
if not text:
return ""
if not text.startswith("0x"):
text = f"0x{text}"
if len(text) != 66:
return ""
try:
int(text[2:], 16)
except Exception:
return ""
return text
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
@@ -1321,6 +1336,66 @@ class PaymentContractCheckoutService:
)
return out
def list_open_intents_by_order_id(
self,
order_id_hex: str,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""
Find intents by on-chain order id for event-driven reconciliation.
Includes created/submitted intents; confirmed intents are returned too for idempotent skip.
"""
self._ensure_enabled()
normalized_order = _normalize_order_id_hex(order_id_hex)
if not normalized_order:
return []
safe_limit = max(1, min(int(limit or 10), 50))
rows = self._rest(
"GET",
"payment_intents",
params={
"select": (
"id,user_id,status,tx_hash,order_id_hex,plan_id,token_address,"
"amount_units,payment_mode,allowed_wallet,chain_id,created_at,updated_at"
),
"order_id_hex": f"eq.{normalized_order}",
"chain_id": f"eq.{self.chain_id}",
"status": "in.(created,submitted,confirmed)",
"order": "created_at.desc",
"limit": str(safe_limit),
},
allowed_status=[200],
)
if not isinstance(rows, list):
return []
out: List[Dict[str, Any]] = []
for row in rows:
if not isinstance(row, dict):
continue
intent_id = str(row.get("id") or "").strip()
user_id = str(row.get("user_id") or "").strip()
status = str(row.get("status") or "").strip().lower()
if not intent_id or not user_id or not status:
continue
out.append(
{
"intent_id": intent_id,
"user_id": user_id,
"status": status,
"tx_hash": str(row.get("tx_hash") or "").strip().lower(),
"order_id_hex": str(row.get("order_id_hex") or "").strip().lower(),
"plan_id": int(row.get("plan_id") or 0),
"token_address": _normalize_address(row.get("token_address")),
"amount_units": int(row.get("amount_units") or 0),
"payment_mode": str(row.get("payment_mode") or "").strip().lower(),
"allowed_wallet": _normalize_address(row.get("allowed_wallet")),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
)
return out
def submit_intent_tx(
self,
user_id: str,
+416
View File
@@ -0,0 +1,416 @@
from __future__ import annotations
import json
import os
import threading
import time
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from loguru import logger
from web3 import Web3
from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError
def _env_bool(name: str, default: bool) -> bool:
raw = os.getenv(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _env_int(name: str, default: int, min_value: int = 0) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
value = int(raw)
except Exception:
return default
return max(min_value, value)
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _normalize_address(address: Any) -> str:
text = str(address or "").strip()
if not text or not Web3.is_address(text):
return ""
return Web3.to_checksum_address(text).lower()
def _to_hex(value: Any) -> str:
try:
return str(Web3.to_hex(value or b"")).lower()
except Exception:
return ""
def _state_file() -> str:
custom = str(
os.getenv("POLYWEATHER_PAYMENT_EVENT_LOOP_STATE_PATH") or ""
).strip()
if custom:
return custom
runtime_dir = str(os.getenv("POLYWEATHER_RUNTIME_DATA_DIR") or "").strip()
if runtime_dir:
return os.path.join(runtime_dir, "payment_event_loop_state.json")
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
return os.path.join(root, "data", "payment_event_loop_state.json")
def _load_state(path: str) -> Dict[str, Any]:
if not os.path.exists(path):
return {}
try:
with open(path, "r", encoding="utf-8") as fh:
raw = json.load(fh)
return raw if isinstance(raw, dict) else {}
except Exception as exc:
logger.warning(f"payment event loop state load failed: {exc}")
return {}
def _save_state(path: str, state: Dict[str, Any]) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as fh:
json.dump(state, fh, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
def _is_pending_confirm_error(exc: PaymentCheckoutError) -> bool:
detail = str(exc.detail or "").lower()
if exc.status_code in {404, 408, 502, 503}:
return True
if exc.status_code == 409 and (
"confirmations not enough" in detail or "tx indexed partially" in detail
):
return True
return False
def _is_nonfatal_submit_error(exc: PaymentCheckoutError) -> bool:
detail = str(exc.detail or "").lower()
if exc.status_code in {502, 503, 408}:
return True
if exc.status_code == 409 and (
"intent status is submitted" in detail
or "intent status is confirmed" in detail
or "cannot submit" in detail
):
return True
return False
def _decode_order_paid_log(log_item: Any) -> Optional[Dict[str, Any]]:
log_get = getattr(log_item, "get", None)
if not callable(log_get):
return None
address = _normalize_address(log_get("address"))
if not address:
return None
try:
contract = PAYMENT_CHECKOUT._get_contract(address) # noqa: SLF001
event_obj = contract.events.OrderPaid().process_log(log_item)
except Exception:
return None
event_get = getattr(event_obj, "get", None)
args = event_get("args") if callable(event_get) else getattr(event_obj, "args", None)
if not args:
return None
args_get = getattr(args, "get", None)
if not callable(args_get):
return None
order_id_hex = _to_hex(args_get("orderId"))
payer = _normalize_address(args_get("payer"))
token = _normalize_address(args_get("token"))
plan_id = int(args_get("planId") or 0)
amount_units = int(args_get("amount") or 0)
tx_hash = _to_hex(log_get("transactionHash"))
block_number = int(log_get("blockNumber") or 0)
log_index = int(log_get("logIndex") or 0)
if not (order_id_hex and payer and token and tx_hash and plan_id > 0 and amount_units > 0):
return None
return {
"order_id_hex": order_id_hex,
"payer": payer,
"plan_id": plan_id,
"token_address": token,
"amount_units": amount_units,
"receiver_contract": address,
"tx_hash": tx_hash,
"block_number": block_number,
"log_index": log_index,
}
def _event_key(event_row: Dict[str, Any]) -> str:
return f"{event_row.get('tx_hash')}:{int(event_row.get('log_index') or 0)}"
def _select_matching_intents(
intents: List[Dict[str, Any]],
event_row: Dict[str, Any],
) -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for row in intents:
if not isinstance(row, dict):
continue
if int(row.get("plan_id") or 0) != int(event_row.get("plan_id") or 0):
continue
if _normalize_address(row.get("token_address")) != _normalize_address(
event_row.get("token_address")
):
continue
if int(row.get("amount_units") or 0) != int(event_row.get("amount_units") or 0):
continue
out.append(row)
if out:
return out
return intents
def _runner() -> None:
enabled = _env_bool("POLYWEATHER_PAYMENT_EVENT_LOOP_ENABLED", True)
if not enabled:
logger.info("payment event loop disabled")
return
if not PAYMENT_CHECKOUT.enabled:
logger.info("payment event loop skipped: payment service disabled")
return
interval_sec = _env_int("POLYWEATHER_PAYMENT_EVENT_LOOP_INTERVAL_SEC", 20, 5)
lookback_blocks = _env_int(
"POLYWEATHER_PAYMENT_EVENT_LOOP_START_LOOKBACK_BLOCKS", 5000, 500
)
step_blocks = _env_int("POLYWEATHER_PAYMENT_EVENT_LOOP_STEP_BLOCKS", 2000, 100)
step_blocks = min(step_blocks, 49999)
max_events = _env_int("POLYWEATHER_PAYMENT_EVENT_LOOP_MAX_EVENTS_PER_CYCLE", 200, 10)
state_path = _state_file()
receiver_contracts = sorted(
{
_normalize_address(token.receiver_contract)
for token in PAYMENT_CHECKOUT.supported_tokens.values()
if _normalize_address(token.receiver_contract)
}
)
if not receiver_contracts:
logger.warning("payment event loop skipped: no receiver contract configured")
return
topic0 = str(PAYMENT_CHECKOUT._event_topic or "").strip().lower() # noqa: SLF001
if topic0 and not topic0.startswith("0x"):
topic0 = f"0x{topic0}"
if not topic0:
topic0 = (
"0x"
+ Web3.keccak(
text="OrderPaid(bytes32,address,uint256,address,uint256)"
).hex().lower().replace("0x", "")
)
logger.info(
"payment event loop started interval={}s lookback={} step={} max_events={} "
"contracts={} chain_id={}",
interval_sec,
lookback_blocks,
step_blocks,
max_events,
len(receiver_contracts),
PAYMENT_CHECKOUT.chain_id,
)
while True:
cycle_started = time.time()
try:
w3 = PAYMENT_CHECKOUT._get_web3() # noqa: SLF001
if not w3.is_connected():
logger.warning("payment event loop skipped: rpc not connected")
time.sleep(interval_sec)
continue
if int(w3.eth.chain_id) != int(PAYMENT_CHECKOUT.chain_id):
logger.warning(
"payment event loop skipped: chain mismatch rpc={} expected={}",
int(w3.eth.chain_id),
int(PAYMENT_CHECKOUT.chain_id),
)
time.sleep(interval_sec)
continue
latest_block = int(w3.eth.block_number)
safe_latest = latest_block - max(0, int(PAYMENT_CHECKOUT.confirmations) - 1)
if safe_latest <= 0:
time.sleep(interval_sec)
continue
state = _load_state(state_path)
last_scanned = int(state.get("last_scanned_block") or 0)
start_block = (
last_scanned + 1
if last_scanned > 0
else max(0, safe_latest - lookback_blocks + 1)
)
if start_block > safe_latest:
time.sleep(interval_sec)
continue
scanned_blocks = 0
scanned_events = 0
matched_intents = 0
submitted = 0
confirmed = 0
already = 0
pending = 0
failed = 0
ignored = 0
seen_events: set[str] = set()
cursor = start_block
while cursor <= safe_latest and scanned_events < max_events:
to_block = min(cursor + step_blocks - 1, safe_latest)
params: Dict[str, Any] = {
"fromBlock": cursor,
"toBlock": to_block,
"topics": [topic0],
}
params["address"] = (
receiver_contracts
if len(receiver_contracts) > 1
else receiver_contracts[0]
)
logs = w3.eth.get_logs(params)
scanned_blocks += max(0, to_block - cursor + 1)
state["last_scanned_block"] = to_block
state["updated_at"] = _now_iso()
_save_state(state_path, state)
if logs:
for log_item in logs:
event_row = _decode_order_paid_log(log_item)
if not event_row:
continue
event_key = _event_key(event_row)
if event_key in seen_events:
continue
seen_events.add(event_key)
scanned_events += 1
intents = PAYMENT_CHECKOUT.list_open_intents_by_order_id(
event_row["order_id_hex"],
limit=10,
)
if not intents:
ignored += 1
if scanned_events >= max_events:
break
continue
candidates = _select_matching_intents(intents, event_row)
for row in candidates:
status = str(row.get("status") or "").strip().lower()
if status == "confirmed":
already += 1
continue
user_id = str(row.get("user_id") or "").strip()
intent_id = str(row.get("intent_id") or "").strip()
if not user_id or not intent_id:
continue
matched_intents += 1
if status == "created" or not str(row.get("tx_hash") or "").strip():
try:
PAYMENT_CHECKOUT.submit_intent_tx(
user_id=user_id,
intent_id=intent_id,
tx_hash=event_row["tx_hash"],
from_address=event_row["payer"],
)
submitted += 1
except PaymentCheckoutError as exc:
if not _is_nonfatal_submit_error(exc):
failed += 1
logger.warning(
"payment event submit failed intent={} user={} tx={} status={} detail={}",
intent_id,
user_id,
event_row["tx_hash"],
exc.status_code,
exc.detail,
)
continue
try:
result = PAYMENT_CHECKOUT.confirm_intent_tx(
user_id=user_id,
intent_id=intent_id,
tx_hash=event_row["tx_hash"],
)
if bool(result.get("already_confirmed")):
already += 1
else:
confirmed += 1
logger.info(
"payment event-confirmed intent={} user={} tx={} block={}",
intent_id,
user_id,
event_row["tx_hash"],
int(event_row.get("block_number") or 0),
)
except PaymentCheckoutError as exc:
if _is_pending_confirm_error(exc):
pending += 1
continue
failed += 1
logger.warning(
"payment event confirm failed intent={} user={} tx={} status={} detail={}",
intent_id,
user_id,
event_row["tx_hash"],
exc.status_code,
exc.detail,
)
if scanned_events >= max_events:
break
cursor = to_block + 1
if scanned_blocks > 0:
logger.info(
"payment event cycle blocks={} events={} matched={} submitted={} "
"confirmed={} already={} pending={} failed={} ignored={}",
scanned_blocks,
scanned_events,
matched_intents,
submitted,
confirmed,
already,
pending,
failed,
ignored,
)
except Exception as exc:
logger.warning(f"payment event cycle failed: {exc}")
elapsed = time.time() - cycle_started
time.sleep(max(0.0, interval_sec - elapsed))
def start_payment_event_loop() -> threading.Thread:
thread = threading.Thread(
target=_runner,
daemon=True,
name="payment-event-loop",
)
thread.start()
return thread
+20
View File
@@ -40,6 +40,26 @@ def get_telegram_chat_ids_from_env() -> List[str]:
)
def get_polymarket_wallet_activity_chat_ids_from_env() -> List[str]:
"""
Preferred envs for wallet activity push:
- POLYMARKET_WALLET_ACTIVITY_CHAT_IDS (comma-separated)
- POLYMARKET_WALLET_ACTIVITY_CHAT_ID (single)
Falls back to global TELEGRAM_CHAT_IDS / TELEGRAM_CHAT_ID for compatibility.
"""
dedicated = parse_telegram_chat_ids(
os.getenv("POLYMARKET_WALLET_ACTIVITY_CHAT_IDS"),
os.getenv("POLYMARKET_WALLET_ACTIVITY_CHAT_ID"),
)
if dedicated:
return dedicated
return parse_telegram_chat_ids(
os.getenv("TELEGRAM_CHAT_IDS"),
os.getenv("TELEGRAM_CHAT_ID"),
)
def get_primary_telegram_chat_id_from_env() -> str:
chat_ids = get_telegram_chat_ids_from_env()
return chat_ids[0] if chat_ids else ""
+14
View File
@@ -1275,6 +1275,20 @@ async def payment_create_intent(request: Request, body: CreatePaymentIntentReque
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@app.get("/api/payments/intents/{intent_id}")
async def payment_get_intent(request: Request, intent_id: str):
_assert_entitlement(request)
identity = _require_supabase_identity(request)
try:
intent = PAYMENT_CHECKOUT.get_intent(
user_id=identity["user_id"],
intent_id=intent_id,
)
return {"intent": intent.__dict__}
except PaymentCheckoutError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@app.post("/api/payments/intents/{intent_id}/submit")
async def payment_submit_tx(
request: Request,