From f00360ed5640f95efeb51aa599623040c172606a Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 13 Mar 2026 13:13:51 +0800 Subject: [PATCH] feat: add account center with subscription management and EVM wallet payment integration. --- README.md | 1 + contracts/PolyWeatherCheckout.sol | 49 +++++++++++++++ docs/payments/POLYGONSCAN_VERIFY.md | 54 ++++++++++++++++ frontend/components/account/AccountCenter.tsx | 62 +++++++++++++++++-- .../subscription/UnlockProOverlay.module.css | 15 +++++ .../subscription/UnlockProOverlay.tsx | 19 +++++- scripts/encode_checkout_constructor.py | 35 +++++++++++ 7 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 contracts/PolyWeatherCheckout.sol create mode 100644 docs/payments/POLYGONSCAN_VERIFY.md create mode 100644 scripts/encode_checkout_constructor.py diff --git a/README.md b/README.md index 854d644d..1e3754a2 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ flowchart TD - 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. + - PolygonScan verification guide: `docs/payments/POLYGONSCAN_VERIFY.md`. ## Repository Layout diff --git a/contracts/PolyWeatherCheckout.sol b/contracts/PolyWeatherCheckout.sol new file mode 100644 index 00000000..2b708240 --- /dev/null +++ b/contracts/PolyWeatherCheckout.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +interface IERC20 { + function transferFrom(address from, address to, uint256 value) external returns (bool); +} + +contract PolyWeatherCheckout { + address public owner; + address public treasury; + address public immutable usdc; + mapping(bytes32 => bool) public paidOrder; + + event OrderPaid( + bytes32 indexed orderId, + address indexed payer, + uint256 indexed planId, + address token, + uint256 amount + ); + + modifier onlyOwner() { + require(msg.sender == owner, "ONLY_OWNER"); + _; + } + + constructor(address _usdc, address _treasury) { + require(_usdc != address(0) && _treasury != address(0), "ZERO_ADDR"); + owner = msg.sender; + usdc = _usdc; + treasury = _treasury; + } + + function setTreasury(address _treasury) external onlyOwner { + require(_treasury != address(0), "ZERO_ADDR"); + treasury = _treasury; + } + + function pay(bytes32 orderId, uint256 planId, uint256 amount, address token) external { + require(token == usdc, "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"); + + emit OrderPaid(orderId, msg.sender, planId, usdc, amount); + } +} diff --git a/docs/payments/POLYGONSCAN_VERIFY.md b/docs/payments/POLYGONSCAN_VERIFY.md new file mode 100644 index 00000000..f9fecdb4 --- /dev/null +++ b/docs/payments/POLYGONSCAN_VERIFY.md @@ -0,0 +1,54 @@ +# PolyWeatherCheckout PolygonScan 验证 + +目标合约地址:`0xD8101B3cA351fD7a9c00d2eBF226f6461Af33F10` +链:Polygon Mainnet (`chainId=137`) + +## 1. 准备参数 + +- 编译器版本:`v0.8.24+commit.e11b9ed9` +- 优化器:`Enabled` +- Runs:`200` +- 许可证:`MIT` +- 合约路径:`contracts/PolyWeatherCheckout.sol` +- 合约名:`PolyWeatherCheckout` + +构造参数顺序: + +1. `_usdc` = `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174` +2. `_treasury` = `0xe581D578EF101c80e3F32263e97E6eA28A0B170e` + +## 2. 构造参数编码 + +可直接用本仓库脚本: + +```bash +python scripts/encode_checkout_constructor.py \ + --usdc 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 \ + --treasury 0xe581D578EF101c80e3F32263e97E6eA28A0B170e +``` + +输出应为: + +```text +0000000000000000000000002791bca1f2de4661ed88a30c99a7a9449aa84174000000000000000000000000e581d578ef101c80e3f32263e97e6ea28a0b170e +``` + +把这串填到 PolygonScan 的 `Constructor Arguments ABI-encoded`。 + +## 3. PolygonScan 页面操作 + +1. 打开合约页面 -> `Contract` -> `Verify and Publish`. +2. 选择 `Solidity (Single file)`。 +3. 粘贴 `contracts/PolyWeatherCheckout.sol` 全部源码。 +4. 按上面参数填写编译器和优化器。 +5. 粘贴编码后的构造参数,提交验证。 + +## 4. 验证后检查 + +验证成功后确认: + +- `Read Contract` 有 `owner / treasury / usdc / paidOrder` +- `Write Contract` 有 `pay / setTreasury` +- `Contract` 标签显示 `Contract Source Code Verified` + +> 说明:钱包风控中的“欺诈/不可信”提示来自钱包安全引擎(如 Blockaid),源码验证能显著降低误报频率,但不保证 100% 立刻消失,通常需一段时间同步信誉缓存。 diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index b06e513d..dd3cdb41 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -217,6 +217,21 @@ function buildApproveCalldata(spender: string, amount: bigint) { return `0x095ea7b3${toPaddedAddress(spender)}${toPaddedHex(amount)}`; } +function buildBalanceOfCalldata(owner: string) { + return `0x70a08231${toPaddedAddress(owner)}`; +} + +function formatTokenUnits(amount: bigint, decimals: number) { + const safeDecimals = Number.isFinite(decimals) && decimals >= 0 ? Math.floor(decimals) : 6; + const base = 10n ** BigInt(safeDecimals); + const whole = amount / base; + const fraction = amount % base; + if (fraction === 0n) return whole.toString(); + const rawFraction = fraction.toString().padStart(safeDecimals, "0"); + const trimmed = rawFraction.replace(/0+$/, ""); + return `${whole.toString()}.${trimmed}`; +} + type NormalizedPaymentError = { message: string; pending: boolean; @@ -225,18 +240,30 @@ type NormalizedPaymentError = { function normalizePaymentError(error: unknown): NormalizedPaymentError { const source = error as any; - const code = Number(source?.code ?? source?.error?.code ?? NaN); + const code = Number( + source?.code ?? + source?.error?.code ?? + source?.data?.code ?? + source?.cause?.code ?? + NaN, + ); const messageCandidates = [ source?.shortMessage, source?.message, source?.reason, source?.data?.message, + source?.cause?.message, source?.error?.message, error instanceof Error ? error.message : "", typeof error === "string" ? error : "", ]; const rawMessage = messageCandidates - .find((item) => typeof item === "string" && item.trim()) + .find( + (item) => + typeof item === "string" && + item.trim() && + item.trim().toLowerCase() !== "[object object]", + ) ?.trim(); const lower = String(rawMessage || "").toLowerCase(); @@ -250,7 +277,7 @@ function normalizePaymentError(error: unknown): NormalizedPaymentError { const userRejected = code === 4001 || - /user rejected|user denied|rejected request|cancelled|canceled|拒绝|取消/.test( + /user rejected|user denied|rejected request|cancelled|canceled|拒绝|取消|签名请求已拒绝/.test( lower, ); if (userRejected) { @@ -262,8 +289,10 @@ function normalizePaymentError(error: unknown): NormalizedPaymentError { } const insufficientGas = - (code === -32000 && /insufficient funds|gas/.test(lower)) || - /not enough pol|network fee|网络费|手续费/.test(lower); + (code === -32000 && /insufficient funds/.test(lower) && /(gas|fee|native|pol|matic)/.test(lower)) || + /not enough pol|insufficient (pol|matic)|insufficient funds for gas|network fee|网络费|手续费/.test( + lower, + ); if (insufficientGas) { return { message: "钱包 POL 不足,无法支付链上手续费,请先充值少量 POL 后重试。", @@ -765,6 +794,7 @@ export function AccountCenter() { const createIntentAndPay = async () => { setPaymentError(""); setPaymentInfo(""); + setLastTxHash(""); if (!isAuthenticated) { setPaymentError("请先登录后再支付。"); return; @@ -839,6 +869,26 @@ 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 balanceHex = (await eth.request({ + method: "eth_call", + params: [ + { + to: tokenAddress, + data: buildBalanceOfCalldata(payingWallet), + }, + "latest", + ], + })) as string; + const tokenBalance = BigInt(String(balanceHex || "0x0")); + if (tokenBalance < amountUnits) { + const need = formatTokenUnits(amountUnits, tokenDecimals); + const have = formatTokenUnits(tokenBalance, tokenDecimals); + throw new Error( + `支付代币余额不足:需要 ${need} USDC,当前 ${have} USDC。请确认你持有的是 Polygon 支付币种(当前合约为 USDC.e)。`, + ); + } const allowanceHex = (await eth.request({ method: "eth_call", @@ -1211,6 +1261,8 @@ export function AccountCenter() { } errorText={paymentError || undefined} infoText={paymentInfo || undefined} + txHash={lastTxHash || undefined} + chainId={paymentConfig?.chain_id || 137} faqHref="/account" /> diff --git a/frontend/components/subscription/UnlockProOverlay.module.css b/frontend/components/subscription/UnlockProOverlay.module.css index c21b213e..7f628110 100644 --- a/frontend/components/subscription/UnlockProOverlay.module.css +++ b/frontend/components/subscription/UnlockProOverlay.module.css @@ -685,6 +685,21 @@ color: #6ee7b7; } +.txLink { + margin-top: 6px; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: #93c5fd; + text-decoration: none; +} + +.txLink:hover { + color: #bfdbfe; + text-decoration: underline; +} + .alertIconBox { display: flex; align-items: center; diff --git a/frontend/components/subscription/UnlockProOverlay.tsx b/frontend/components/subscription/UnlockProOverlay.tsx index b239ae68..1305f4e4 100644 --- a/frontend/components/subscription/UnlockProOverlay.tsx +++ b/frontend/components/subscription/UnlockProOverlay.tsx @@ -7,6 +7,7 @@ import { CheckCircle2, Coins, Crown, + ExternalLink, Loader2, Lock, MessageSquare, @@ -45,6 +46,8 @@ type UnlockProOverlayProps = { infoText?: string; faqHref?: string; telegramGroupUrl?: string; + txHash?: string; + chainId?: number; }; const FEATURES = { @@ -75,6 +78,8 @@ export function UnlockProOverlay({ infoText, faqHref = "/account", telegramGroupUrl, + txHash, + chainId = 137, }: UnlockProOverlayProps) { const isEn = locale === "en-US"; const canUsePoints = billing.pointsEnabled && billing.isEligible; @@ -93,6 +98,10 @@ export function UnlockProOverlay({ const progressPct = billing.pointsEnabled ? Math.min(100, Math.round((points / maxPointsForFullDiscount) * 100)) : 0; + const txHref = + txHash && txHash.startsWith("0x") + ? `${chainId === 137 ? "https://polygonscan.com" : "https://etherscan.io"}/tx/${txHash}` + : ""; return (