diff --git a/.env.example b/.env.example index ee9a0f3a..98f0dbad 100644 --- a/.env.example +++ b/.env.example @@ -57,9 +57,17 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= POLYWEATHER_PAYMENT_ENABLED=false POLYWEATHER_PAYMENT_CHAIN_ID=137 POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com +# Legacy single-token fallback (still supported) POLYWEATHER_PAYMENT_RECEIVER_CONTRACT= POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6 +# Recommended multi-token config (supports USDC.e + Native USDC at the same time) +# Example: +# [ +# {"code":"usdc_e","symbol":"USDC.e","name":"USDC.e (PoS)","address":"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174","decimals":6,"receiver_contract":"0xYourCheckoutContract","is_default":true}, +# {"code":"usdc","symbol":"USDC","name":"Native USDC","address":"0x3c499c542cef5e3811e1192ce70d8cc03d5c3359","decimals":6,"receiver_contract":"0xYourCheckoutContract"} +# ] +POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON= POLYWEATHER_PAYMENT_CONFIRMATIONS=2 POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800 POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600 diff --git a/README.md b/README.md index 1e3754a2..21ccc585 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ flowchart TD 6. P1 contract checkout (new): - New payment APIs: `/api/payments/config|wallets|intents/*`. - MetaMask wallet binding via nonce challenge + `personal_sign`. + - Supports multi-token checkout on Polygon (USDC.e + Native USDC) via token whitelist config. - Frontend receives contract `tx_payload` and calls `eth_sendTransaction`. - Backend validates `OrderPaid(orderId,payer,planId,token,amount)` onchain event and auto-grants entitlement. - Confirmation writes `payments/subscriptions/entitlement_events` and can notify Telegram. diff --git a/README_ZH.md b/README_ZH.md index cb988854..f4384028 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -97,6 +97,7 @@ flowchart TD 6. P1 合约支付链路(新增): - 新增支付接口:`/api/payments/config|wallets|intents/*`。 - 支持 MetaMask 钱包绑定(nonce + `personal_sign` 验签)。 + - 支持 Polygon 双币种支付(USDC.e + Native USDC),后端按代币白名单路由。 - 支持合约订单支付:前端拿 `tx_payload` 调 `eth_sendTransaction`。 - 后端按 `OrderPaid(orderId,payer,planId,token,amount)` 事件验单并自动开通订阅。 - 交易确认后自动写入 `payments/subscriptions/entitlement_events` 并可推送 Telegram。 diff --git a/contracts/PolyWeatherCheckout.sol b/contracts/PolyWeatherCheckout.sol index 2b708240..ba3bc854 100644 --- a/contracts/PolyWeatherCheckout.sol +++ b/contracts/PolyWeatherCheckout.sol @@ -8,7 +8,7 @@ interface IERC20 { contract PolyWeatherCheckout { address public owner; address public treasury; - address public immutable usdc; + mapping(address => bool) public allowedToken; mapping(bytes32 => bool) public paidOrder; event OrderPaid( @@ -24,11 +24,11 @@ contract PolyWeatherCheckout { _; } - constructor(address _usdc, address _treasury) { - require(_usdc != address(0) && _treasury != address(0), "ZERO_ADDR"); + constructor(address _token, address _treasury) { + require(_token != address(0) && _treasury != address(0), "ZERO_ADDR"); owner = msg.sender; - usdc = _usdc; treasury = _treasury; + allowedToken[_token] = true; } function setTreasury(address _treasury) external onlyOwner { @@ -36,14 +36,19 @@ contract PolyWeatherCheckout { treasury = _treasury; } + function setTokenAllowed(address token, bool allowed) external onlyOwner { + require(token != address(0), "ZERO_ADDR"); + allowedToken[token] = allowed; + } + function pay(bytes32 orderId, uint256 planId, uint256 amount, address token) external { - require(token == usdc, "TOKEN_NOT_ALLOWED"); + require(allowedToken[token], "TOKEN_NOT_ALLOWED"); require(amount > 0, "AMOUNT_ZERO"); require(!paidOrder[orderId], "ORDER_PAID"); paidOrder[orderId] = true; - require(IERC20(usdc).transferFrom(msg.sender, treasury, amount), "TRANSFER_FAILED"); + require(IERC20(token).transferFrom(msg.sender, treasury, amount), "TRANSFER_FAILED"); - emit OrderPaid(orderId, msg.sender, planId, usdc, amount); + emit OrderPaid(orderId, msg.sender, planId, token, amount); } } diff --git a/docs/payments/POLYGONSCAN_VERIFY.md b/docs/payments/POLYGONSCAN_VERIFY.md index f9fecdb4..581f2db3 100644 --- a/docs/payments/POLYGONSCAN_VERIFY.md +++ b/docs/payments/POLYGONSCAN_VERIFY.md @@ -12,9 +12,9 @@ - 合约路径:`contracts/PolyWeatherCheckout.sol` - 合约名:`PolyWeatherCheckout` -构造参数顺序: +构造参数顺序(新版多代币合约): -1. `_usdc` = `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174` +1. `_token` = `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`(初始允许代币) 2. `_treasury` = `0xe581D578EF101c80e3F32263e97E6eA28A0B170e` ## 2. 构造参数编码 @@ -23,7 +23,7 @@ ```bash python scripts/encode_checkout_constructor.py \ - --usdc 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 \ + --token 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 \ --treasury 0xe581D578EF101c80e3F32263e97E6eA28A0B170e ``` @@ -47,8 +47,15 @@ python scripts/encode_checkout_constructor.py \ 验证成功后确认: -- `Read Contract` 有 `owner / treasury / usdc / paidOrder` -- `Write Contract` 有 `pay / setTreasury` +- `Read Contract` 有 `owner / treasury / allowedToken / paidOrder` +- `Write Contract` 有 `pay / setTreasury / setTokenAllowed` - `Contract` 标签显示 `Contract Source Code Verified` +## 5. 同时开启 USDC.e + Native USDC + +验证后在 `Write Contract` 调用: + +- `setTokenAllowed(0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174, true)` // USDC.e +- `setTokenAllowed(0x3c499c542cef5e3811e1192ce70d8cc03d5c3359, true)` // Native USDC + > 说明:钱包风控中的“欺诈/不可信”提示来自钱包安全引擎(如 Blockaid),源码验证能显著降低误报频率,但不保证 100% 立刻消失,通常需一段时间同步信誉缓存。 diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index dd3cdb41..08ae5448 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -64,6 +64,16 @@ type PaymentPlan = { duration_days: number; }; +type PaymentTokenOption = { + code: string; + symbol: string; + name: string; + address: string; + decimals: number; + receiver_contract?: string; + is_default?: boolean; +}; + type PointsRedemptionConfig = { enabled?: boolean; points_per_usdc?: number; @@ -76,6 +86,8 @@ type PaymentConfig = { chain_id?: number; token_address?: string; token_decimals?: number; + default_token_address?: string; + tokens?: PaymentTokenOption[]; receiver_contract?: string; confirmations?: number; points_redemption?: PointsRedemptionConfig; @@ -105,6 +117,8 @@ type CreatedIntent = { value: string; amount_units: string; token_address: string; + token_symbol?: string; + token_decimals?: number; }; }; @@ -344,6 +358,7 @@ export function AccountCenter() { const [boundWallets, setBoundWallets] = useState([]); const [walletAddress, setWalletAddress] = useState(""); const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly"); + const [selectedTokenAddress, setSelectedTokenAddress] = useState(""); const [selectedWallet, setSelectedWallet] = useState(""); const [paymentBusy, setPaymentBusy] = useState(false); const [paymentInfo, setPaymentInfo] = useState(""); @@ -420,6 +435,23 @@ export function AccountCenter() { if (!selectedPlanCode && configJson.plans?.length) { setSelectedPlanCode(configJson.plans[0].plan_code); } + const tokenOptions = Array.isArray(configJson.tokens) + ? configJson.tokens.filter( + (row) => + typeof row?.address === "string" && + String(row.address).startsWith("0x"), + ) + : []; + const defaultTokenAddress = String( + configJson.default_token_address || + tokenOptions.find((row) => row.is_default)?.address || + tokenOptions[0]?.address || + configJson.token_address || + "", + ).toLowerCase(); + if (defaultTokenAddress) { + setSelectedTokenAddress((prev) => prev || defaultTokenAddress); + } } if (walletsRes.ok) { const walletsJson = (await walletsRes.json()) as { @@ -555,6 +587,56 @@ export function AccountCenter() { const selectedPlan = effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) || effectivePlanList[0]; + const availableTokenList: PaymentTokenOption[] = useMemo(() => { + const configured = Array.isArray(paymentConfig?.tokens) + ? paymentConfig?.tokens || [] + : []; + const clean = configured + .filter( + (row) => + row && + typeof row.address === "string" && + row.address.startsWith("0x"), + ) + .map((row) => ({ + ...row, + address: String(row.address).toLowerCase(), + symbol: String(row.symbol || "USDC"), + name: String(row.name || row.symbol || "USDC"), + code: String(row.code || "usdc"), + decimals: Number.isFinite(Number(row.decimals)) + ? Number(row.decimals) + : Number(paymentConfig?.token_decimals ?? 6), + })); + if (clean.length) return clean; + const fallbackAddress = String(paymentConfig?.token_address || "").toLowerCase(); + if (!fallbackAddress.startsWith("0x")) return []; + return [ + { + code: "usdc", + symbol: "USDC", + name: "USDC", + address: fallbackAddress, + decimals: Number(paymentConfig?.token_decimals ?? 6), + receiver_contract: paymentConfig?.receiver_contract, + is_default: true, + }, + ]; + }, [paymentConfig]); + const resolvedSelectedTokenAddress = String( + selectedTokenAddress || + paymentConfig?.default_token_address || + availableTokenList.find((row) => row.is_default)?.address || + availableTokenList[0]?.address || + paymentConfig?.token_address || + "", + ).toLowerCase(); + const selectedPaymentToken = + availableTokenList.find((row) => row.address === resolvedSelectedTokenAddress) || + availableTokenList[0]; + const selectedTokenLabel = + selectedPaymentToken?.symbol || + (resolvedSelectedTokenAddress.startsWith("0x") ? shortAddress(resolvedSelectedTokenAddress) : "USDC"); const paymentFeatureReady = Boolean( paymentConfig?.enabled && paymentConfig?.configured, ); @@ -847,6 +929,7 @@ export function AccountCenter() { plan_code: selectedPlan?.plan_code || "pro_monthly", payment_mode: "strict", allowed_wallet: payingWallet, + token_address: resolvedSelectedTokenAddress || undefined, use_points: billing.canRedeem && usePoints, points_to_consume: billing.canRedeem && usePoints ? billing.pointsUsed : 0, @@ -869,7 +952,18 @@ export function AccountCenter() { const amountUnits = BigInt(String(txPayload.amount_units || "0")); if (!tokenAddress.startsWith("0x") || amountUnits <= 0n) throw new Error("intent token/amount invalid"); - const tokenDecimals = Number(paymentConfig?.token_decimals ?? 6); + const tokenSymbol = String( + txPayload.token_symbol || + selectedPaymentToken?.symbol || + selectedTokenLabel || + "USDC", + ); + const tokenDecimals = Number( + txPayload.token_decimals ?? + selectedPaymentToken?.decimals ?? + paymentConfig?.token_decimals ?? + 6, + ); const balanceHex = (await eth.request({ method: "eth_call", @@ -886,7 +980,7 @@ export function AccountCenter() { const need = formatTokenUnits(amountUnits, tokenDecimals); const have = formatTokenUnits(tokenBalance, tokenDecimals); throw new Error( - `支付代币余额不足:需要 ${need} USDC,当前 ${have} USDC。请确认你持有的是 Polygon 支付币种(当前合约为 USDC.e)。`, + `支付代币余额不足:需要 ${need} ${tokenSymbol},当前 ${have} ${tokenSymbol}。请确认你钱包里持有该支付币种。`, ); } @@ -903,7 +997,7 @@ export function AccountCenter() { const allowance = BigInt(String(allowanceHex || "0x0")); if (allowance < amountUnits) { - setPaymentInfo("检测到授权不足,正在发起 USDC 授权..."); + setPaymentInfo(`检测到授权不足,正在发起 ${tokenSymbol} 授权...`); const approveHash = (await eth.request({ method: "eth_sendTransaction", params: [ @@ -917,7 +1011,7 @@ export function AccountCenter() { })) as string; await waitForReceipt(String(approveHash || "")); approvedInThisRun = true; - setPaymentInfo("USDC 授权成功,正在发起支付..."); + setPaymentInfo(`${tokenSymbol} 授权成功,正在发起支付...`); } else { setPaymentInfo("授权额度充足,正在发起支付..."); } @@ -976,13 +1070,15 @@ export function AccountCenter() { } else if (normalized.userRejected) { setPaymentInfo( approvedInThisRun - ? "USDC 授权已完成,本次支付已取消,可直接再次点击支付。" + ? `${selectedTokenLabel} 授权已完成,本次支付已取消,可直接再次点击支付。` : "", ); setPaymentError(normalized.message); } else { setPaymentInfo( - approvedInThisRun ? "USDC 授权已完成,但支付未完成,请重试。" : "", + approvedInThisRun + ? `${selectedTokenLabel} 授权已完成,但支付未完成,请重试。` + : "", ); setPaymentError(normalized.message); } @@ -1263,6 +1359,7 @@ export function AccountCenter() { infoText={paymentInfo || undefined} txHash={lastTxHash || undefined} chainId={paymentConfig?.chain_id || 137} + paymentTokenLabel={selectedTokenLabel} faqHref="/account" /> @@ -1304,6 +1401,38 @@ export function AccountCenter() {

支付管理

+ {availableTokenList.length > 0 && ( +
+

+ 支付币种 +

+
+ {availableTokenList.map((token) => { + const active = + token.address === + (resolvedSelectedTokenAddress || token.address); + return ( + + ); + })} +
+
+ )} {boundWallets.length ? (
{boundWallets.map((w) => ( diff --git a/frontend/components/subscription/UnlockProOverlay.tsx b/frontend/components/subscription/UnlockProOverlay.tsx index 1305f4e4..8584484e 100644 --- a/frontend/components/subscription/UnlockProOverlay.tsx +++ b/frontend/components/subscription/UnlockProOverlay.tsx @@ -48,6 +48,7 @@ type UnlockProOverlayProps = { telegramGroupUrl?: string; txHash?: string; chainId?: number; + paymentTokenLabel?: string; }; const FEATURES = { @@ -80,6 +81,7 @@ export function UnlockProOverlay({ telegramGroupUrl, txHash, chainId = 137, + paymentTokenLabel, }: UnlockProOverlayProps) { const isEn = locale === "en-US"; const canUsePoints = billing.pointsEnabled && billing.isEligible; @@ -320,6 +322,11 @@ export function UnlockProOverlay({

{isEn ? "Total Due Today" : "今日应付"}

+ {paymentTokenLabel && ( +

+ {isEn ? "Token" : "支付币种"}: {paymentTokenLabel} +

+ )} {billing.discountAmount > 0 && usePoints && (

${planPriceUsd.toFixed(2)} USD diff --git a/scripts/encode_checkout_constructor.py b/scripts/encode_checkout_constructor.py index 2c657584..41217e76 100644 --- a/scripts/encode_checkout_constructor.py +++ b/scripts/encode_checkout_constructor.py @@ -12,12 +12,20 @@ def main() -> int: parser = argparse.ArgumentParser( description="Encode PolyWeatherCheckout constructor args for PolygonScan verification.", ) - parser.add_argument("--usdc", required=True, help="USDC token address") + parser.add_argument( + "--token", + help="Initial allowed token address (e.g. USDC.e or Native USDC)", + ) + parser.add_argument("--usdc", help="Backward-compatible alias of --token") parser.add_argument("--treasury", required=True, help="Treasury address") args = parser.parse_args() - if not Web3.is_address(args.usdc): - print("invalid --usdc address", file=sys.stderr) + token = args.token or args.usdc + if not token: + print("missing --token (or --usdc)", file=sys.stderr) + return 1 + if not Web3.is_address(token): + print("invalid --token address", file=sys.stderr) return 1 if not Web3.is_address(args.treasury): print("invalid --treasury address", file=sys.stderr) @@ -25,7 +33,7 @@ def main() -> int: encoded = encode( ["address", "address"], - [Web3.to_checksum_address(args.usdc), Web3.to_checksum_address(args.treasury)], + [Web3.to_checksum_address(token), Web3.to_checksum_address(args.treasury)], ).hex() print(encoded) return 0 diff --git a/src/payments/contract_checkout.py b/src/payments/contract_checkout.py index 696dd660..32cbf303 100644 --- a/src/payments/contract_checkout.py +++ b/src/payments/contract_checkout.py @@ -18,6 +18,7 @@ from src.database.db_manager import DBManager DEFAULT_POLYGON_CHAIN_ID = 137 DEFAULT_USDC_E_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174" +DEFAULT_NATIVE_USDC_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" PAYMENT_CONTRACT_ABI = [ { @@ -182,6 +183,17 @@ class WalletBindingRecord: verified_at: Optional[str] +@dataclass +class PaymentTokenConfig: + code: str + symbol: str + name: str + address: str + decimals: int + receiver_contract: str + is_default: bool + + @dataclass class PaymentIntentRecord: intent_id: str @@ -192,6 +204,8 @@ class PaymentIntentRecord: amount_units: int amount_usdc: str token_address: str + token_decimals: int + token_symbol: str receiver_address: str status: str payment_mode: str @@ -218,12 +232,34 @@ class PaymentContractCheckoutService: self.chain_id = _env_int("POLYWEATHER_PAYMENT_CHAIN_ID", DEFAULT_POLYGON_CHAIN_ID) self.token_decimals = _env_int("POLYWEATHER_PAYMENT_TOKEN_DECIMALS", 6) self.rpc_url = str(os.getenv("POLYWEATHER_PAYMENT_RPC_URL") or "").strip() - self.receiver_contract = _normalize_address( + legacy_receiver_contract = _normalize_address( os.getenv("POLYWEATHER_PAYMENT_RECEIVER_CONTRACT") or "" ) - self.token_address = _normalize_address( + legacy_token_address = _normalize_address( os.getenv("POLYWEATHER_PAYMENT_TOKEN_ADDRESS") or DEFAULT_USDC_E_ADDRESS ) + self.supported_tokens = self._load_supported_tokens( + os.getenv("POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON") or "", + fallback_receiver_contract=legacy_receiver_contract, + fallback_token_address=legacy_token_address, + fallback_token_decimals=self.token_decimals, + ) + self.default_token_address = next( + ( + address + for address, token in self.supported_tokens.items() + if bool(token.is_default) + ), + "", + ) + if not self.default_token_address and self.supported_tokens: + self.default_token_address = next(iter(self.supported_tokens.keys())) + default_token = self.supported_tokens.get(self.default_token_address) + self.token_address = default_token.address if default_token else "" + self.receiver_contract = default_token.receiver_contract if default_token else "" + 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.challenge_ttl_sec = max( 60, _env_int("POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC", 600) @@ -271,12 +307,18 @@ class PaymentContractCheckoutService: @property def configured(self) -> bool: + has_valid_token_routes = bool( + self.supported_tokens + and all( + token.address and token.receiver_contract + for token in self.supported_tokens.values() + ) + ) return bool( self.supabase_url and self.supabase_service_role_key and self.rpc_url - and self.receiver_contract - and self.token_address + and has_valid_token_routes ) def _ensure_enabled(self) -> None: @@ -285,9 +327,162 @@ class PaymentContractCheckoutService: if not self.configured: raise PaymentCheckoutError( 503, - "payment feature not configured: require SUPABASE + RPC + contract + token", + ( + "payment feature not configured: require SUPABASE + RPC + " + "POLYWEATHER_PAYMENT_ACCEPTED_TOKENS_JSON" + ), ) + def _default_token_meta(self, address: str) -> Dict[str, str]: + normalized = _normalize_address(address) + if normalized == _normalize_address(DEFAULT_NATIVE_USDC_ADDRESS): + return {"code": "usdc", "symbol": "USDC", "name": "Native USDC"} + if normalized == _normalize_address(DEFAULT_USDC_E_ADDRESS): + return {"code": "usdc_e", "symbol": "USDC.e", "name": "USDC.e (PoS)"} + return {"code": "usdc_token", "symbol": "USDC", "name": "USDC"} + + def _to_token_config( + self, + row: Dict[str, Any], + fallback_receiver_contract: str, + fallback_token_decimals: int, + ) -> Optional[PaymentTokenConfig]: + if not isinstance(row, dict): + return None + address = _normalize_address( + row.get("address") or row.get("token_address") or row.get("contract") + ) + if not address: + return None + receiver_contract = _normalize_address( + row.get("receiver_contract") + or row.get("checkout_contract") + or row.get("contract_address") + or fallback_receiver_contract + ) + if not receiver_contract: + return None + default_meta = self._default_token_meta(address) + code = str(row.get("code") or default_meta["code"]).strip().lower() + symbol = str(row.get("symbol") or default_meta["symbol"]).strip() + name = str(row.get("name") or default_meta["name"]).strip() + if not code: + code = default_meta["code"] + if not symbol: + symbol = default_meta["symbol"] + if not name: + name = default_meta["name"] + try: + decimals = int( + row.get("decimals") + or row.get("token_decimals") + or fallback_token_decimals + ) + except Exception: + decimals = int(fallback_token_decimals) + decimals = max(0, decimals) + is_default = bool(row.get("is_default")) + return PaymentTokenConfig( + code=code, + symbol=symbol, + name=name, + address=address, + decimals=decimals, + receiver_contract=receiver_contract, + is_default=is_default, + ) + + def _load_supported_tokens( + self, + raw: str, + *, + fallback_receiver_contract: str, + fallback_token_address: str, + fallback_token_decimals: int, + ) -> Dict[str, PaymentTokenConfig]: + parsed_rows: List[Dict[str, Any]] = [] + text = str(raw or "").strip() + if text: + try: + parsed = json.loads(text) + except Exception: + parsed = None + if isinstance(parsed, list): + parsed_rows = [row for row in parsed if isinstance(row, dict)] + elif isinstance(parsed, dict): + if isinstance(parsed.get("tokens"), list): + parsed_rows = [ + row for row in parsed.get("tokens") or [] if isinstance(row, dict) + ] + else: + for key, value in parsed.items(): + if isinstance(value, dict): + row = dict(value) + row.setdefault("code", str(key)) + parsed_rows.append(row) + + out: Dict[str, PaymentTokenConfig] = {} + for row in parsed_rows: + token = self._to_token_config( + row, + fallback_receiver_contract=fallback_receiver_contract, + fallback_token_decimals=fallback_token_decimals, + ) + if not token: + continue + out[token.address] = token + + if out: + return out + + fallback_address = _normalize_address(fallback_token_address) + if not (fallback_address and fallback_receiver_contract): + return {} + fallback_meta = self._default_token_meta(fallback_address) + fallback_token = PaymentTokenConfig( + code=fallback_meta["code"], + symbol=fallback_meta["symbol"], + name=fallback_meta["name"], + address=fallback_address, + decimals=max(0, int(fallback_token_decimals)), + receiver_contract=fallback_receiver_contract, + is_default=True, + ) + return {fallback_token.address: fallback_token} + + def _resolve_supported_token( + self, + token_address: Optional[str] = None, + ) -> PaymentTokenConfig: + normalized = _normalize_address(token_address or "") + if normalized: + token = self.supported_tokens.get(normalized) + if token: + return token + available = ", ".join( + f"{item.symbol}:{item.address}" for item in self.supported_tokens.values() + ) + raise PaymentCheckoutError( + 400, + f"token_address not supported: {normalized}. available={available}", + ) + default_token = self.supported_tokens.get(self.default_token_address) + if default_token: + return default_token + raise PaymentCheckoutError(503, "no supported payment token configured") + + def _token_decimals_for(self, token_address: str) -> int: + token = self.supported_tokens.get(_normalize_address(token_address)) + if token: + return int(token.decimals) + return int(self.token_decimals) + + def _token_symbol_for(self, token_address: str) -> str: + token = self.supported_tokens.get(_normalize_address(token_address)) + if token and token.symbol: + return str(token.symbol) + return "USDC" + def _service_headers(self, prefer: Optional[str] = None) -> Dict[str, str]: headers = { "apikey": self.supabase_service_role_key, @@ -620,14 +815,29 @@ class PaymentContractCheckoutService: assert self._w3 is not None return self._w3 - def _get_contract(self): + def _get_contract(self, receiver_address: Optional[str] = None): w3 = self._get_web3() + contract_address = _normalize_address(receiver_address or self.receiver_contract) + if not contract_address: + contract_address = self.receiver_contract return w3.eth.contract( - address=Web3.to_checksum_address(self.receiver_contract), + address=Web3.to_checksum_address(contract_address), abi=PAYMENT_CONTRACT_ABI, ) def get_config_payload(self) -> Dict[str, Any]: + tokens_payload = [ + { + "code": token.code, + "symbol": token.symbol, + "name": token.name, + "address": token.address, + "decimals": int(token.decimals), + "receiver_contract": token.receiver_contract, + "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) + ] return { "enabled": self.enabled, "configured": self.configured, @@ -635,6 +845,8 @@ class PaymentContractCheckoutService: "token_address": self.token_address, "token_decimals": self.token_decimals, "receiver_contract": self.receiver_contract, + "default_token_address": self.default_token_address or self.token_address, + "tokens": tokens_payload, "confirmations": self.confirmations, "intent_ttl_sec": self.intent_ttl_sec, "event_name": "OrderPaid", @@ -656,8 +868,10 @@ class PaymentContractCheckoutService: } def _serialize_intent(self, row: Dict[str, Any]) -> PaymentIntentRecord: + token_address = _normalize_address(row.get("token_address") or self.token_address) + token_decimals = self._token_decimals_for(token_address) amount_units = int(_parse_decimal(row.get("amount_units"), Decimal("0"))) - amount_display = _units_to_decimal(amount_units, self.token_decimals) + amount_display = _units_to_decimal(amount_units, token_decimals) return PaymentIntentRecord( intent_id=str(row.get("id")), order_id_hex=str(row.get("order_id_hex")), @@ -666,7 +880,9 @@ class PaymentContractCheckoutService: chain_id=int(row.get("chain_id") or self.chain_id), amount_units=amount_units, amount_usdc=_format_decimal(amount_display), - token_address=_normalize_address(row.get("token_address") or self.token_address), + token_address=token_address, + token_decimals=token_decimals, + token_symbol=self._token_symbol_for(token_address), receiver_address=_normalize_address( row.get("receiver_address") or self.receiver_contract ), @@ -907,7 +1123,7 @@ class PaymentContractCheckoutService: } def _build_tx_payload(self, intent: PaymentIntentRecord) -> Dict[str, Any]: - contract = self._get_contract() + contract = self._get_contract(intent.receiver_address) tx_data = contract.encode_abi( "pay", args=[ @@ -926,6 +1142,8 @@ class PaymentContractCheckoutService: "amount_units": str(intent.amount_units), "amount_usdc": intent.amount_usdc, "token_address": Web3.to_checksum_address(intent.token_address), + "token_symbol": intent.token_symbol, + "token_decimals": int(intent.token_decimals), } def create_intent( @@ -934,12 +1152,14 @@ class PaymentContractCheckoutService: plan_code: str, payment_mode: str = "strict", allowed_wallet: Optional[str] = None, + token_address: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, use_points: bool = False, points_to_consume: Optional[int] = None, ) -> Dict[str, Any]: self._ensure_enabled() plan = self._select_plan(plan_code) + selected_token = self._resolve_supported_token(token_address) mode = str(payment_mode or "strict").strip().lower() if mode not in {"strict", "flex"}: raise PaymentCheckoutError(400, "payment_mode must be strict or flex") @@ -966,10 +1186,12 @@ class PaymentContractCheckoutService: requested_points_to_consume=points_to_consume, ) final_amount_usdc = redemption["pay_amount_usdc"] - amount_units = _decimal_to_units(final_amount_usdc, self.token_decimals) + amount_units = _decimal_to_units(final_amount_usdc, int(selected_token.decimals)) if amount_units <= 0: raise PaymentCheckoutError(400, "invalid final payment amount") combined_metadata = dict(metadata or {}) + combined_metadata["token_code"] = str(selected_token.code) + combined_metadata["token_symbol"] = str(selected_token.symbol) combined_metadata["amount_before_discount_usdc"] = _format_decimal(plan_amount_usdc) combined_metadata["amount_after_discount_usdc"] = _format_decimal(final_amount_usdc) combined_metadata["points_redemption"] = { @@ -993,8 +1215,8 @@ class PaymentContractCheckoutService: "plan_code": plan["plan_code"], "plan_id": plan["plan_id"], "chain_id": self.chain_id, - "token_address": self.token_address, - "receiver_address": self.receiver_contract, + "token_address": selected_token.address, + "receiver_address": selected_token.receiver_contract, "amount_units": str(amount_units), "payment_mode": mode, "allowed_wallet": target_wallet or None, @@ -1021,6 +1243,13 @@ class PaymentContractCheckoutService: "amount_before_discount_usdc": _format_decimal(plan_amount_usdc), "amount_after_discount_usdc": _format_decimal(final_amount_usdc), }, + "token": { + "code": selected_token.code, + "symbol": selected_token.symbol, + "name": selected_token.name, + "address": selected_token.address, + "decimals": int(selected_token.decimals), + }, "points_redemption": { "applied": bool(redemption.get("applied")), "points_source": str(redemption.get("points_source") or "supabase_metadata"), @@ -1148,7 +1377,7 @@ class PaymentContractCheckoutService: def _extract_matching_event( self, receipt: Any, intent: PaymentIntentRecord ) -> Optional[Dict[str, Any]]: - contract = self._get_contract() + contract = self._get_contract(intent.receiver_address) try: events = contract.events.OrderPaid().process_receipt(receipt) except Exception: @@ -1188,9 +1417,12 @@ class PaymentContractCheckoutService: user_id: str, tx_hash: str, amount_units: int, + token_address: str, payload: Dict[str, Any], ) -> Dict[str, Any]: - amount_dec = _units_to_decimal(amount_units, self.token_decimals) + token_decimals = self._token_decimals_for(token_address) + amount_dec = _units_to_decimal(amount_units, token_decimals) + currency = self._token_symbol_for(token_address) rows = self._rest( "POST", "payments", @@ -1198,7 +1430,7 @@ class PaymentContractCheckoutService: payload={ "user_id": user_id, "amount": str(amount_dec), - "currency": "USDC", + "currency": currency, "chain": "polygon", "tx_hash": tx_hash, "status": "confirmed", @@ -1412,6 +1644,7 @@ class PaymentContractCheckoutService: user_id=user_id, tx_hash=tx_hash_text, amount_units=intent.amount_units, + token_address=intent.token_address, payload=payload, ) subscription_row = self._grant_subscription( diff --git a/web/app.py b/web/app.py index 7f8f0e34..4c9269c2 100644 --- a/web/app.py +++ b/web/app.py @@ -265,6 +265,7 @@ class CreatePaymentIntentRequest(BaseModel): plan_code: str = Field(default="pro_monthly", min_length=2) payment_mode: str = Field(default="strict") allowed_wallet: Optional[str] = None + token_address: Optional[str] = None use_points: bool = False points_to_consume: Optional[int] = None metadata: Dict[str, Any] = Field(default_factory=dict) @@ -1251,6 +1252,7 @@ async def payment_create_intent(request: Request, body: CreatePaymentIntentReque plan_code=body.plan_code, payment_mode=body.payment_mode, allowed_wallet=body.allowed_wallet, + token_address=body.token_address, use_points=body.use_points, points_to_consume=body.points_to_consume, metadata=body.metadata,