feat: add account center with subscription management and EVM wallet payment integration.
This commit is contained in:
@@ -100,6 +100,7 @@ flowchart TD
|
|||||||
- Frontend receives contract `tx_payload` and calls `eth_sendTransaction`.
|
- Frontend receives contract `tx_payload` and calls `eth_sendTransaction`.
|
||||||
- Backend validates `OrderPaid(orderId,payer,planId,token,amount)` onchain event and auto-grants entitlement.
|
- Backend validates `OrderPaid(orderId,payer,planId,token,amount)` onchain event and auto-grants entitlement.
|
||||||
- Confirmation writes `payments/subscriptions/entitlement_events` and can notify Telegram.
|
- Confirmation writes `payments/subscriptions/entitlement_events` and can notify Telegram.
|
||||||
|
- PolygonScan verification guide: `docs/payments/POLYGONSCAN_VERIFY.md`.
|
||||||
|
|
||||||
## Repository Layout
|
## Repository Layout
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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% 立刻消失,通常需一段时间同步信誉缓存。
|
||||||
@@ -217,6 +217,21 @@ function buildApproveCalldata(spender: string, amount: bigint) {
|
|||||||
return `0x095ea7b3${toPaddedAddress(spender)}${toPaddedHex(amount)}`;
|
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 = {
|
type NormalizedPaymentError = {
|
||||||
message: string;
|
message: string;
|
||||||
pending: boolean;
|
pending: boolean;
|
||||||
@@ -225,18 +240,30 @@ type NormalizedPaymentError = {
|
|||||||
|
|
||||||
function normalizePaymentError(error: unknown): NormalizedPaymentError {
|
function normalizePaymentError(error: unknown): NormalizedPaymentError {
|
||||||
const source = error as any;
|
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 = [
|
const messageCandidates = [
|
||||||
source?.shortMessage,
|
source?.shortMessage,
|
||||||
source?.message,
|
source?.message,
|
||||||
source?.reason,
|
source?.reason,
|
||||||
source?.data?.message,
|
source?.data?.message,
|
||||||
|
source?.cause?.message,
|
||||||
source?.error?.message,
|
source?.error?.message,
|
||||||
error instanceof Error ? error.message : "",
|
error instanceof Error ? error.message : "",
|
||||||
typeof error === "string" ? error : "",
|
typeof error === "string" ? error : "",
|
||||||
];
|
];
|
||||||
const rawMessage = messageCandidates
|
const rawMessage = messageCandidates
|
||||||
.find((item) => typeof item === "string" && item.trim())
|
.find(
|
||||||
|
(item) =>
|
||||||
|
typeof item === "string" &&
|
||||||
|
item.trim() &&
|
||||||
|
item.trim().toLowerCase() !== "[object object]",
|
||||||
|
)
|
||||||
?.trim();
|
?.trim();
|
||||||
const lower = String(rawMessage || "").toLowerCase();
|
const lower = String(rawMessage || "").toLowerCase();
|
||||||
|
|
||||||
@@ -250,7 +277,7 @@ function normalizePaymentError(error: unknown): NormalizedPaymentError {
|
|||||||
|
|
||||||
const userRejected =
|
const userRejected =
|
||||||
code === 4001 ||
|
code === 4001 ||
|
||||||
/user rejected|user denied|rejected request|cancelled|canceled|拒绝|取消/.test(
|
/user rejected|user denied|rejected request|cancelled|canceled|拒绝|取消|签名请求已拒绝/.test(
|
||||||
lower,
|
lower,
|
||||||
);
|
);
|
||||||
if (userRejected) {
|
if (userRejected) {
|
||||||
@@ -262,8 +289,10 @@ function normalizePaymentError(error: unknown): NormalizedPaymentError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const insufficientGas =
|
const insufficientGas =
|
||||||
(code === -32000 && /insufficient funds|gas/.test(lower)) ||
|
(code === -32000 && /insufficient funds/.test(lower) && /(gas|fee|native|pol|matic)/.test(lower)) ||
|
||||||
/not enough pol|network fee|网络费|手续费/.test(lower);
|
/not enough pol|insufficient (pol|matic)|insufficient funds for gas|network fee|网络费|手续费/.test(
|
||||||
|
lower,
|
||||||
|
);
|
||||||
if (insufficientGas) {
|
if (insufficientGas) {
|
||||||
return {
|
return {
|
||||||
message: "钱包 POL 不足,无法支付链上手续费,请先充值少量 POL 后重试。",
|
message: "钱包 POL 不足,无法支付链上手续费,请先充值少量 POL 后重试。",
|
||||||
@@ -765,6 +794,7 @@ export function AccountCenter() {
|
|||||||
const createIntentAndPay = async () => {
|
const createIntentAndPay = async () => {
|
||||||
setPaymentError("");
|
setPaymentError("");
|
||||||
setPaymentInfo("");
|
setPaymentInfo("");
|
||||||
|
setLastTxHash("");
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
setPaymentError("请先登录后再支付。");
|
setPaymentError("请先登录后再支付。");
|
||||||
return;
|
return;
|
||||||
@@ -839,6 +869,26 @@ export function AccountCenter() {
|
|||||||
const amountUnits = BigInt(String(txPayload.amount_units || "0"));
|
const amountUnits = BigInt(String(txPayload.amount_units || "0"));
|
||||||
if (!tokenAddress.startsWith("0x") || amountUnits <= 0n)
|
if (!tokenAddress.startsWith("0x") || amountUnits <= 0n)
|
||||||
throw new Error("intent token/amount invalid");
|
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({
|
const allowanceHex = (await eth.request({
|
||||||
method: "eth_call",
|
method: "eth_call",
|
||||||
@@ -1211,6 +1261,8 @@ export function AccountCenter() {
|
|||||||
}
|
}
|
||||||
errorText={paymentError || undefined}
|
errorText={paymentError || undefined}
|
||||||
infoText={paymentInfo || undefined}
|
infoText={paymentInfo || undefined}
|
||||||
|
txHash={lastTxHash || undefined}
|
||||||
|
chainId={paymentConfig?.chain_id || 137}
|
||||||
faqHref="/account"
|
faqHref="/account"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -685,6 +685,21 @@
|
|||||||
color: #6ee7b7;
|
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 {
|
.alertIconBox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Coins,
|
Coins,
|
||||||
Crown,
|
Crown,
|
||||||
|
ExternalLink,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
Lock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -45,6 +46,8 @@ type UnlockProOverlayProps = {
|
|||||||
infoText?: string;
|
infoText?: string;
|
||||||
faqHref?: string;
|
faqHref?: string;
|
||||||
telegramGroupUrl?: string;
|
telegramGroupUrl?: string;
|
||||||
|
txHash?: string;
|
||||||
|
chainId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FEATURES = {
|
const FEATURES = {
|
||||||
@@ -75,6 +78,8 @@ export function UnlockProOverlay({
|
|||||||
infoText,
|
infoText,
|
||||||
faqHref = "/account",
|
faqHref = "/account",
|
||||||
telegramGroupUrl,
|
telegramGroupUrl,
|
||||||
|
txHash,
|
||||||
|
chainId = 137,
|
||||||
}: UnlockProOverlayProps) {
|
}: UnlockProOverlayProps) {
|
||||||
const isEn = locale === "en-US";
|
const isEn = locale === "en-US";
|
||||||
const canUsePoints = billing.pointsEnabled && billing.isEligible;
|
const canUsePoints = billing.pointsEnabled && billing.isEligible;
|
||||||
@@ -93,6 +98,10 @@ export function UnlockProOverlay({
|
|||||||
const progressPct = billing.pointsEnabled
|
const progressPct = billing.pointsEnabled
|
||||||
? Math.min(100, Math.round((points / maxPointsForFullDiscount) * 100))
|
? Math.min(100, Math.round((points / maxPointsForFullDiscount) * 100))
|
||||||
: 0;
|
: 0;
|
||||||
|
const txHref =
|
||||||
|
txHash && txHash.startsWith("0x")
|
||||||
|
? `${chainId === 137 ? "https://polygonscan.com" : "https://etherscan.io"}/tx/${txHash}`
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.modal}>
|
<div className={s.modal}>
|
||||||
@@ -369,7 +378,15 @@ export function UnlockProOverlay({
|
|||||||
<div className={`${s.alertIconBox} ${s.alertIconInfo}`}>
|
<div className={`${s.alertIconBox} ${s.alertIconInfo}`}>
|
||||||
<CheckCircle2 size={10} />
|
<CheckCircle2 size={10} />
|
||||||
</div>
|
</div>
|
||||||
{infoText}
|
<div>
|
||||||
|
<div>{infoText}</div>
|
||||||
|
{txHref && (
|
||||||
|
<Link href={txHref} target="_blank" className={s.txLink}>
|
||||||
|
查看链上交易
|
||||||
|
<ExternalLink size={11} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from eth_abi import encode
|
||||||
|
from web3 import Web3
|
||||||
|
|
||||||
|
|
||||||
|
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("--treasury", required=True, help="Treasury address")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not Web3.is_address(args.usdc):
|
||||||
|
print("invalid --usdc address", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not Web3.is_address(args.treasury):
|
||||||
|
print("invalid --treasury address", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
encoded = encode(
|
||||||
|
["address", "address"],
|
||||||
|
[Web3.to_checksum_address(args.usdc), Web3.to_checksum_address(args.treasury)],
|
||||||
|
).hex()
|
||||||
|
print(encoded)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user