feat: implement Pro subscription with a frontend unlock overlay and blockchain-based payment integration.
This commit is contained in:
@@ -57,9 +57,17 @@ POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
|||||||
POLYWEATHER_PAYMENT_ENABLED=false
|
POLYWEATHER_PAYMENT_ENABLED=false
|
||||||
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
POLYWEATHER_PAYMENT_CHAIN_ID=137
|
||||||
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
|
||||||
|
# Legacy single-token fallback (still supported)
|
||||||
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
|
||||||
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
|
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
|
||||||
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
|
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_CONFIRMATIONS=2
|
||||||
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
|
||||||
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
|
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ flowchart TD
|
|||||||
6. P1 contract checkout (new):
|
6. P1 contract checkout (new):
|
||||||
- New payment APIs: `/api/payments/config|wallets|intents/*`.
|
- New payment APIs: `/api/payments/config|wallets|intents/*`.
|
||||||
- MetaMask wallet binding via nonce challenge + `personal_sign`.
|
- 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`.
|
- 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.
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ flowchart TD
|
|||||||
6. P1 合约支付链路(新增):
|
6. P1 合约支付链路(新增):
|
||||||
- 新增支付接口:`/api/payments/config|wallets|intents/*`。
|
- 新增支付接口:`/api/payments/config|wallets|intents/*`。
|
||||||
- 支持 MetaMask 钱包绑定(nonce + `personal_sign` 验签)。
|
- 支持 MetaMask 钱包绑定(nonce + `personal_sign` 验签)。
|
||||||
|
- 支持 Polygon 双币种支付(USDC.e + Native USDC),后端按代币白名单路由。
|
||||||
- 支持合约订单支付:前端拿 `tx_payload` 调 `eth_sendTransaction`。
|
- 支持合约订单支付:前端拿 `tx_payload` 调 `eth_sendTransaction`。
|
||||||
- 后端按 `OrderPaid(orderId,payer,planId,token,amount)` 事件验单并自动开通订阅。
|
- 后端按 `OrderPaid(orderId,payer,planId,token,amount)` 事件验单并自动开通订阅。
|
||||||
- 交易确认后自动写入 `payments/subscriptions/entitlement_events` 并可推送 Telegram。
|
- 交易确认后自动写入 `payments/subscriptions/entitlement_events` 并可推送 Telegram。
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface IERC20 {
|
|||||||
contract PolyWeatherCheckout {
|
contract PolyWeatherCheckout {
|
||||||
address public owner;
|
address public owner;
|
||||||
address public treasury;
|
address public treasury;
|
||||||
address public immutable usdc;
|
mapping(address => bool) public allowedToken;
|
||||||
mapping(bytes32 => bool) public paidOrder;
|
mapping(bytes32 => bool) public paidOrder;
|
||||||
|
|
||||||
event OrderPaid(
|
event OrderPaid(
|
||||||
@@ -24,11 +24,11 @@ contract PolyWeatherCheckout {
|
|||||||
_;
|
_;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(address _usdc, address _treasury) {
|
constructor(address _token, address _treasury) {
|
||||||
require(_usdc != address(0) && _treasury != address(0), "ZERO_ADDR");
|
require(_token != address(0) && _treasury != address(0), "ZERO_ADDR");
|
||||||
owner = msg.sender;
|
owner = msg.sender;
|
||||||
usdc = _usdc;
|
|
||||||
treasury = _treasury;
|
treasury = _treasury;
|
||||||
|
allowedToken[_token] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setTreasury(address _treasury) external onlyOwner {
|
function setTreasury(address _treasury) external onlyOwner {
|
||||||
@@ -36,14 +36,19 @@ contract PolyWeatherCheckout {
|
|||||||
treasury = _treasury;
|
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 {
|
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(amount > 0, "AMOUNT_ZERO");
|
||||||
require(!paidOrder[orderId], "ORDER_PAID");
|
require(!paidOrder[orderId], "ORDER_PAID");
|
||||||
|
|
||||||
paidOrder[orderId] = true;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
- 合约路径:`contracts/PolyWeatherCheckout.sol`
|
- 合约路径:`contracts/PolyWeatherCheckout.sol`
|
||||||
- 合约名:`PolyWeatherCheckout`
|
- 合约名:`PolyWeatherCheckout`
|
||||||
|
|
||||||
构造参数顺序:
|
构造参数顺序(新版多代币合约):
|
||||||
|
|
||||||
1. `_usdc` = `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`
|
1. `_token` = `0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`(初始允许代币)
|
||||||
2. `_treasury` = `0xe581D578EF101c80e3F32263e97E6eA28A0B170e`
|
2. `_treasury` = `0xe581D578EF101c80e3F32263e97E6eA28A0B170e`
|
||||||
|
|
||||||
## 2. 构造参数编码
|
## 2. 构造参数编码
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
python scripts/encode_checkout_constructor.py \
|
python scripts/encode_checkout_constructor.py \
|
||||||
--usdc 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 \
|
--token 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 \
|
||||||
--treasury 0xe581D578EF101c80e3F32263e97E6eA28A0B170e
|
--treasury 0xe581D578EF101c80e3F32263e97E6eA28A0B170e
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -47,8 +47,15 @@ python scripts/encode_checkout_constructor.py \
|
|||||||
|
|
||||||
验证成功后确认:
|
验证成功后确认:
|
||||||
|
|
||||||
- `Read Contract` 有 `owner / treasury / usdc / paidOrder`
|
- `Read Contract` 有 `owner / treasury / allowedToken / paidOrder`
|
||||||
- `Write Contract` 有 `pay / setTreasury`
|
- `Write Contract` 有 `pay / setTreasury / setTokenAllowed`
|
||||||
- `Contract` 标签显示 `Contract Source Code Verified`
|
- `Contract` 标签显示 `Contract Source Code Verified`
|
||||||
|
|
||||||
|
## 5. 同时开启 USDC.e + Native USDC
|
||||||
|
|
||||||
|
验证后在 `Write Contract` 调用:
|
||||||
|
|
||||||
|
- `setTokenAllowed(0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174, true)` // USDC.e
|
||||||
|
- `setTokenAllowed(0x3c499c542cef5e3811e1192ce70d8cc03d5c3359, true)` // Native USDC
|
||||||
|
|
||||||
> 说明:钱包风控中的“欺诈/不可信”提示来自钱包安全引擎(如 Blockaid),源码验证能显著降低误报频率,但不保证 100% 立刻消失,通常需一段时间同步信誉缓存。
|
> 说明:钱包风控中的“欺诈/不可信”提示来自钱包安全引擎(如 Blockaid),源码验证能显著降低误报频率,但不保证 100% 立刻消失,通常需一段时间同步信誉缓存。
|
||||||
|
|||||||
@@ -64,6 +64,16 @@ type PaymentPlan = {
|
|||||||
duration_days: number;
|
duration_days: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PaymentTokenOption = {
|
||||||
|
code: string;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
decimals: number;
|
||||||
|
receiver_contract?: string;
|
||||||
|
is_default?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type PointsRedemptionConfig = {
|
type PointsRedemptionConfig = {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
points_per_usdc?: number;
|
points_per_usdc?: number;
|
||||||
@@ -76,6 +86,8 @@ type PaymentConfig = {
|
|||||||
chain_id?: number;
|
chain_id?: number;
|
||||||
token_address?: string;
|
token_address?: string;
|
||||||
token_decimals?: number;
|
token_decimals?: number;
|
||||||
|
default_token_address?: string;
|
||||||
|
tokens?: PaymentTokenOption[];
|
||||||
receiver_contract?: string;
|
receiver_contract?: string;
|
||||||
confirmations?: number;
|
confirmations?: number;
|
||||||
points_redemption?: PointsRedemptionConfig;
|
points_redemption?: PointsRedemptionConfig;
|
||||||
@@ -105,6 +117,8 @@ type CreatedIntent = {
|
|||||||
value: string;
|
value: string;
|
||||||
amount_units: string;
|
amount_units: string;
|
||||||
token_address: string;
|
token_address: string;
|
||||||
|
token_symbol?: string;
|
||||||
|
token_decimals?: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -344,6 +358,7 @@ export function AccountCenter() {
|
|||||||
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
|
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
|
||||||
const [walletAddress, setWalletAddress] = useState("");
|
const [walletAddress, setWalletAddress] = useState("");
|
||||||
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
|
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
|
||||||
|
const [selectedTokenAddress, setSelectedTokenAddress] = useState("");
|
||||||
const [selectedWallet, setSelectedWallet] = useState("");
|
const [selectedWallet, setSelectedWallet] = useState("");
|
||||||
const [paymentBusy, setPaymentBusy] = useState(false);
|
const [paymentBusy, setPaymentBusy] = useState(false);
|
||||||
const [paymentInfo, setPaymentInfo] = useState("");
|
const [paymentInfo, setPaymentInfo] = useState("");
|
||||||
@@ -420,6 +435,23 @@ export function AccountCenter() {
|
|||||||
if (!selectedPlanCode && configJson.plans?.length) {
|
if (!selectedPlanCode && configJson.plans?.length) {
|
||||||
setSelectedPlanCode(configJson.plans[0].plan_code);
|
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) {
|
if (walletsRes.ok) {
|
||||||
const walletsJson = (await walletsRes.json()) as {
|
const walletsJson = (await walletsRes.json()) as {
|
||||||
@@ -555,6 +587,56 @@ export function AccountCenter() {
|
|||||||
const selectedPlan =
|
const selectedPlan =
|
||||||
effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) ||
|
effectivePlanList.find((plan) => plan.plan_code === selectedPlanCode) ||
|
||||||
effectivePlanList[0];
|
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(
|
const paymentFeatureReady = Boolean(
|
||||||
paymentConfig?.enabled && paymentConfig?.configured,
|
paymentConfig?.enabled && paymentConfig?.configured,
|
||||||
);
|
);
|
||||||
@@ -847,6 +929,7 @@ export function AccountCenter() {
|
|||||||
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
plan_code: selectedPlan?.plan_code || "pro_monthly",
|
||||||
payment_mode: "strict",
|
payment_mode: "strict",
|
||||||
allowed_wallet: payingWallet,
|
allowed_wallet: payingWallet,
|
||||||
|
token_address: resolvedSelectedTokenAddress || undefined,
|
||||||
use_points: billing.canRedeem && usePoints,
|
use_points: billing.canRedeem && usePoints,
|
||||||
points_to_consume:
|
points_to_consume:
|
||||||
billing.canRedeem && usePoints ? billing.pointsUsed : 0,
|
billing.canRedeem && usePoints ? billing.pointsUsed : 0,
|
||||||
@@ -869,7 +952,18 @@ 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 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({
|
const balanceHex = (await eth.request({
|
||||||
method: "eth_call",
|
method: "eth_call",
|
||||||
@@ -886,7 +980,7 @@ export function AccountCenter() {
|
|||||||
const need = formatTokenUnits(amountUnits, tokenDecimals);
|
const need = formatTokenUnits(amountUnits, tokenDecimals);
|
||||||
const have = formatTokenUnits(tokenBalance, tokenDecimals);
|
const have = formatTokenUnits(tokenBalance, tokenDecimals);
|
||||||
throw new Error(
|
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"));
|
const allowance = BigInt(String(allowanceHex || "0x0"));
|
||||||
|
|
||||||
if (allowance < amountUnits) {
|
if (allowance < amountUnits) {
|
||||||
setPaymentInfo("检测到授权不足,正在发起 USDC 授权...");
|
setPaymentInfo(`检测到授权不足,正在发起 ${tokenSymbol} 授权...`);
|
||||||
const approveHash = (await eth.request({
|
const approveHash = (await eth.request({
|
||||||
method: "eth_sendTransaction",
|
method: "eth_sendTransaction",
|
||||||
params: [
|
params: [
|
||||||
@@ -917,7 +1011,7 @@ export function AccountCenter() {
|
|||||||
})) as string;
|
})) as string;
|
||||||
await waitForReceipt(String(approveHash || ""));
|
await waitForReceipt(String(approveHash || ""));
|
||||||
approvedInThisRun = true;
|
approvedInThisRun = true;
|
||||||
setPaymentInfo("USDC 授权成功,正在发起支付...");
|
setPaymentInfo(`${tokenSymbol} 授权成功,正在发起支付...`);
|
||||||
} else {
|
} else {
|
||||||
setPaymentInfo("授权额度充足,正在发起支付...");
|
setPaymentInfo("授权额度充足,正在发起支付...");
|
||||||
}
|
}
|
||||||
@@ -976,13 +1070,15 @@ export function AccountCenter() {
|
|||||||
} else if (normalized.userRejected) {
|
} else if (normalized.userRejected) {
|
||||||
setPaymentInfo(
|
setPaymentInfo(
|
||||||
approvedInThisRun
|
approvedInThisRun
|
||||||
? "USDC 授权已完成,本次支付已取消,可直接再次点击支付。"
|
? `${selectedTokenLabel} 授权已完成,本次支付已取消,可直接再次点击支付。`
|
||||||
: "",
|
: "",
|
||||||
);
|
);
|
||||||
setPaymentError(normalized.message);
|
setPaymentError(normalized.message);
|
||||||
} else {
|
} else {
|
||||||
setPaymentInfo(
|
setPaymentInfo(
|
||||||
approvedInThisRun ? "USDC 授权已完成,但支付未完成,请重试。" : "",
|
approvedInThisRun
|
||||||
|
? `${selectedTokenLabel} 授权已完成,但支付未完成,请重试。`
|
||||||
|
: "",
|
||||||
);
|
);
|
||||||
setPaymentError(normalized.message);
|
setPaymentError(normalized.message);
|
||||||
}
|
}
|
||||||
@@ -1263,6 +1359,7 @@ export function AccountCenter() {
|
|||||||
infoText={paymentInfo || undefined}
|
infoText={paymentInfo || undefined}
|
||||||
txHash={lastTxHash || undefined}
|
txHash={lastTxHash || undefined}
|
||||||
chainId={paymentConfig?.chain_id || 137}
|
chainId={paymentConfig?.chain_id || 137}
|
||||||
|
paymentTokenLabel={selectedTokenLabel}
|
||||||
faqHref="/account"
|
faqHref="/account"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1304,6 +1401,38 @@ export function AccountCenter() {
|
|||||||
<h3 className="text-blue-400 text-sm font-bold uppercase tracking-widest mb-6 flex items-center gap-2">
|
<h3 className="text-blue-400 text-sm font-bold uppercase tracking-widest mb-6 flex items-center gap-2">
|
||||||
<Wallet size={18} /> 支付管理
|
<Wallet size={18} /> 支付管理
|
||||||
</h3>
|
</h3>
|
||||||
|
{availableTokenList.length > 0 && (
|
||||||
|
<div className="mb-5">
|
||||||
|
<p className="text-[11px] uppercase tracking-widest text-slate-500 mb-2">
|
||||||
|
支付币种
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{availableTokenList.map((token) => {
|
||||||
|
const active =
|
||||||
|
token.address ===
|
||||||
|
(resolvedSelectedTokenAddress || token.address);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={token.address}
|
||||||
|
onClick={() => setSelectedTokenAddress(token.address)}
|
||||||
|
disabled={paymentBusy}
|
||||||
|
className={`rounded-xl border px-3 py-2 text-left transition-all ${
|
||||||
|
active
|
||||||
|
? "bg-blue-500/15 border-blue-500/40 text-white"
|
||||||
|
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-bold">{token.symbol}</div>
|
||||||
|
<div className="text-[10px] opacity-80 truncate">
|
||||||
|
{token.name}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{boundWallets.length ? (
|
{boundWallets.length ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{boundWallets.map((w) => (
|
{boundWallets.map((w) => (
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ type UnlockProOverlayProps = {
|
|||||||
telegramGroupUrl?: string;
|
telegramGroupUrl?: string;
|
||||||
txHash?: string;
|
txHash?: string;
|
||||||
chainId?: number;
|
chainId?: number;
|
||||||
|
paymentTokenLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const FEATURES = {
|
const FEATURES = {
|
||||||
@@ -80,6 +81,7 @@ export function UnlockProOverlay({
|
|||||||
telegramGroupUrl,
|
telegramGroupUrl,
|
||||||
txHash,
|
txHash,
|
||||||
chainId = 137,
|
chainId = 137,
|
||||||
|
paymentTokenLabel,
|
||||||
}: UnlockProOverlayProps) {
|
}: UnlockProOverlayProps) {
|
||||||
const isEn = locale === "en-US";
|
const isEn = locale === "en-US";
|
||||||
const canUsePoints = billing.pointsEnabled && billing.isEligible;
|
const canUsePoints = billing.pointsEnabled && billing.isEligible;
|
||||||
@@ -320,6 +322,11 @@ export function UnlockProOverlay({
|
|||||||
<p className={s.summaryLabel}>
|
<p className={s.summaryLabel}>
|
||||||
{isEn ? "Total Due Today" : "今日应付"}
|
{isEn ? "Total Due Today" : "今日应付"}
|
||||||
</p>
|
</p>
|
||||||
|
{paymentTokenLabel && (
|
||||||
|
<p className={s.summaryOriginal}>
|
||||||
|
{isEn ? "Token" : "支付币种"}: {paymentTokenLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{billing.discountAmount > 0 && usePoints && (
|
{billing.discountAmount > 0 && usePoints && (
|
||||||
<p className={s.summaryOriginal}>
|
<p className={s.summaryOriginal}>
|
||||||
${planPriceUsd.toFixed(2)} USD
|
${planPriceUsd.toFixed(2)} USD
|
||||||
|
|||||||
@@ -12,12 +12,20 @@ def main() -> int:
|
|||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Encode PolyWeatherCheckout constructor args for PolygonScan verification.",
|
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")
|
parser.add_argument("--treasury", required=True, help="Treasury address")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not Web3.is_address(args.usdc):
|
token = args.token or args.usdc
|
||||||
print("invalid --usdc address", file=sys.stderr)
|
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
|
return 1
|
||||||
if not Web3.is_address(args.treasury):
|
if not Web3.is_address(args.treasury):
|
||||||
print("invalid --treasury address", file=sys.stderr)
|
print("invalid --treasury address", file=sys.stderr)
|
||||||
@@ -25,7 +33,7 @@ def main() -> int:
|
|||||||
|
|
||||||
encoded = encode(
|
encoded = encode(
|
||||||
["address", "address"],
|
["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()
|
).hex()
|
||||||
print(encoded)
|
print(encoded)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from src.database.db_manager import DBManager
|
|||||||
|
|
||||||
DEFAULT_POLYGON_CHAIN_ID = 137
|
DEFAULT_POLYGON_CHAIN_ID = 137
|
||||||
DEFAULT_USDC_E_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
DEFAULT_USDC_E_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
|
||||||
|
DEFAULT_NATIVE_USDC_ADDRESS = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359"
|
||||||
|
|
||||||
PAYMENT_CONTRACT_ABI = [
|
PAYMENT_CONTRACT_ABI = [
|
||||||
{
|
{
|
||||||
@@ -182,6 +183,17 @@ class WalletBindingRecord:
|
|||||||
verified_at: Optional[str]
|
verified_at: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PaymentTokenConfig:
|
||||||
|
code: str
|
||||||
|
symbol: str
|
||||||
|
name: str
|
||||||
|
address: str
|
||||||
|
decimals: int
|
||||||
|
receiver_contract: str
|
||||||
|
is_default: bool
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PaymentIntentRecord:
|
class PaymentIntentRecord:
|
||||||
intent_id: str
|
intent_id: str
|
||||||
@@ -192,6 +204,8 @@ class PaymentIntentRecord:
|
|||||||
amount_units: int
|
amount_units: int
|
||||||
amount_usdc: str
|
amount_usdc: str
|
||||||
token_address: str
|
token_address: str
|
||||||
|
token_decimals: int
|
||||||
|
token_symbol: str
|
||||||
receiver_address: str
|
receiver_address: str
|
||||||
status: str
|
status: str
|
||||||
payment_mode: str
|
payment_mode: str
|
||||||
@@ -218,12 +232,34 @@ class PaymentContractCheckoutService:
|
|||||||
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.receiver_contract = _normalize_address(
|
legacy_receiver_contract = _normalize_address(
|
||||||
os.getenv("POLYWEATHER_PAYMENT_RECEIVER_CONTRACT") or ""
|
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
|
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.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)
|
||||||
@@ -271,12 +307,18 @@ class PaymentContractCheckoutService:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
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(
|
return bool(
|
||||||
self.supabase_url
|
self.supabase_url
|
||||||
and self.supabase_service_role_key
|
and self.supabase_service_role_key
|
||||||
and self.rpc_url
|
and self.rpc_url
|
||||||
and self.receiver_contract
|
and has_valid_token_routes
|
||||||
and self.token_address
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _ensure_enabled(self) -> None:
|
def _ensure_enabled(self) -> None:
|
||||||
@@ -285,9 +327,162 @@ class PaymentContractCheckoutService:
|
|||||||
if not self.configured:
|
if not self.configured:
|
||||||
raise PaymentCheckoutError(
|
raise PaymentCheckoutError(
|
||||||
503,
|
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]:
|
def _service_headers(self, prefer: Optional[str] = None) -> Dict[str, str]:
|
||||||
headers = {
|
headers = {
|
||||||
"apikey": self.supabase_service_role_key,
|
"apikey": self.supabase_service_role_key,
|
||||||
@@ -620,14 +815,29 @@ class PaymentContractCheckoutService:
|
|||||||
assert self._w3 is not None
|
assert self._w3 is not None
|
||||||
return self._w3
|
return self._w3
|
||||||
|
|
||||||
def _get_contract(self):
|
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)
|
||||||
|
if not contract_address:
|
||||||
|
contract_address = self.receiver_contract
|
||||||
return w3.eth.contract(
|
return w3.eth.contract(
|
||||||
address=Web3.to_checksum_address(self.receiver_contract),
|
address=Web3.to_checksum_address(contract_address),
|
||||||
abi=PAYMENT_CONTRACT_ABI,
|
abi=PAYMENT_CONTRACT_ABI,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_config_payload(self) -> Dict[str, Any]:
|
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 {
|
return {
|
||||||
"enabled": self.enabled,
|
"enabled": self.enabled,
|
||||||
"configured": self.configured,
|
"configured": self.configured,
|
||||||
@@ -635,6 +845,8 @@ class PaymentContractCheckoutService:
|
|||||||
"token_address": self.token_address,
|
"token_address": self.token_address,
|
||||||
"token_decimals": self.token_decimals,
|
"token_decimals": self.token_decimals,
|
||||||
"receiver_contract": self.receiver_contract,
|
"receiver_contract": self.receiver_contract,
|
||||||
|
"default_token_address": self.default_token_address or self.token_address,
|
||||||
|
"tokens": tokens_payload,
|
||||||
"confirmations": self.confirmations,
|
"confirmations": self.confirmations,
|
||||||
"intent_ttl_sec": self.intent_ttl_sec,
|
"intent_ttl_sec": self.intent_ttl_sec,
|
||||||
"event_name": "OrderPaid",
|
"event_name": "OrderPaid",
|
||||||
@@ -656,8 +868,10 @@ 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_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, self.token_decimals)
|
amount_display = _units_to_decimal(amount_units, token_decimals)
|
||||||
return PaymentIntentRecord(
|
return PaymentIntentRecord(
|
||||||
intent_id=str(row.get("id")),
|
intent_id=str(row.get("id")),
|
||||||
order_id_hex=str(row.get("order_id_hex")),
|
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),
|
chain_id=int(row.get("chain_id") or self.chain_id),
|
||||||
amount_units=amount_units,
|
amount_units=amount_units,
|
||||||
amount_usdc=_format_decimal(amount_display),
|
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(
|
receiver_address=_normalize_address(
|
||||||
row.get("receiver_address") or self.receiver_contract
|
row.get("receiver_address") or self.receiver_contract
|
||||||
),
|
),
|
||||||
@@ -907,7 +1123,7 @@ class PaymentContractCheckoutService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _build_tx_payload(self, intent: PaymentIntentRecord) -> Dict[str, Any]:
|
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(
|
tx_data = contract.encode_abi(
|
||||||
"pay",
|
"pay",
|
||||||
args=[
|
args=[
|
||||||
@@ -926,6 +1142,8 @@ class PaymentContractCheckoutService:
|
|||||||
"amount_units": str(intent.amount_units),
|
"amount_units": str(intent.amount_units),
|
||||||
"amount_usdc": intent.amount_usdc,
|
"amount_usdc": intent.amount_usdc,
|
||||||
"token_address": Web3.to_checksum_address(intent.token_address),
|
"token_address": Web3.to_checksum_address(intent.token_address),
|
||||||
|
"token_symbol": intent.token_symbol,
|
||||||
|
"token_decimals": int(intent.token_decimals),
|
||||||
}
|
}
|
||||||
|
|
||||||
def create_intent(
|
def create_intent(
|
||||||
@@ -934,12 +1152,14 @@ class PaymentContractCheckoutService:
|
|||||||
plan_code: str,
|
plan_code: str,
|
||||||
payment_mode: str = "strict",
|
payment_mode: str = "strict",
|
||||||
allowed_wallet: Optional[str] = None,
|
allowed_wallet: Optional[str] = None,
|
||||||
|
token_address: Optional[str] = None,
|
||||||
metadata: Optional[Dict[str, Any]] = None,
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
use_points: bool = False,
|
use_points: bool = False,
|
||||||
points_to_consume: Optional[int] = None,
|
points_to_consume: Optional[int] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
self._ensure_enabled()
|
self._ensure_enabled()
|
||||||
plan = self._select_plan(plan_code)
|
plan = self._select_plan(plan_code)
|
||||||
|
selected_token = self._resolve_supported_token(token_address)
|
||||||
mode = str(payment_mode or "strict").strip().lower()
|
mode = str(payment_mode or "strict").strip().lower()
|
||||||
if mode not in {"strict", "flex"}:
|
if mode not in {"strict", "flex"}:
|
||||||
raise PaymentCheckoutError(400, "payment_mode must be strict or flex")
|
raise PaymentCheckoutError(400, "payment_mode must be strict or flex")
|
||||||
@@ -966,10 +1186,12 @@ 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, self.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 {})
|
||||||
|
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_before_discount_usdc"] = _format_decimal(plan_amount_usdc)
|
||||||
combined_metadata["amount_after_discount_usdc"] = _format_decimal(final_amount_usdc)
|
combined_metadata["amount_after_discount_usdc"] = _format_decimal(final_amount_usdc)
|
||||||
combined_metadata["points_redemption"] = {
|
combined_metadata["points_redemption"] = {
|
||||||
@@ -993,8 +1215,8 @@ class PaymentContractCheckoutService:
|
|||||||
"plan_code": plan["plan_code"],
|
"plan_code": plan["plan_code"],
|
||||||
"plan_id": plan["plan_id"],
|
"plan_id": plan["plan_id"],
|
||||||
"chain_id": self.chain_id,
|
"chain_id": self.chain_id,
|
||||||
"token_address": self.token_address,
|
"token_address": selected_token.address,
|
||||||
"receiver_address": self.receiver_contract,
|
"receiver_address": selected_token.receiver_contract,
|
||||||
"amount_units": str(amount_units),
|
"amount_units": str(amount_units),
|
||||||
"payment_mode": mode,
|
"payment_mode": mode,
|
||||||
"allowed_wallet": target_wallet or None,
|
"allowed_wallet": target_wallet or None,
|
||||||
@@ -1021,6 +1243,13 @@ class PaymentContractCheckoutService:
|
|||||||
"amount_before_discount_usdc": _format_decimal(plan_amount_usdc),
|
"amount_before_discount_usdc": _format_decimal(plan_amount_usdc),
|
||||||
"amount_after_discount_usdc": _format_decimal(final_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": {
|
"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"),
|
||||||
@@ -1148,7 +1377,7 @@ class PaymentContractCheckoutService:
|
|||||||
def _extract_matching_event(
|
def _extract_matching_event(
|
||||||
self, receipt: Any, intent: PaymentIntentRecord
|
self, receipt: Any, intent: PaymentIntentRecord
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
contract = self._get_contract()
|
contract = self._get_contract(intent.receiver_address)
|
||||||
try:
|
try:
|
||||||
events = contract.events.OrderPaid().process_receipt(receipt)
|
events = contract.events.OrderPaid().process_receipt(receipt)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1188,9 +1417,12 @@ class PaymentContractCheckoutService:
|
|||||||
user_id: str,
|
user_id: str,
|
||||||
tx_hash: str,
|
tx_hash: str,
|
||||||
amount_units: int,
|
amount_units: int,
|
||||||
|
token_address: str,
|
||||||
payload: Dict[str, Any],
|
payload: Dict[str, Any],
|
||||||
) -> 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(
|
rows = self._rest(
|
||||||
"POST",
|
"POST",
|
||||||
"payments",
|
"payments",
|
||||||
@@ -1198,7 +1430,7 @@ class PaymentContractCheckoutService:
|
|||||||
payload={
|
payload={
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"amount": str(amount_dec),
|
"amount": str(amount_dec),
|
||||||
"currency": "USDC",
|
"currency": currency,
|
||||||
"chain": "polygon",
|
"chain": "polygon",
|
||||||
"tx_hash": tx_hash,
|
"tx_hash": tx_hash,
|
||||||
"status": "confirmed",
|
"status": "confirmed",
|
||||||
@@ -1412,6 +1644,7 @@ class PaymentContractCheckoutService:
|
|||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
tx_hash=tx_hash_text,
|
tx_hash=tx_hash_text,
|
||||||
amount_units=intent.amount_units,
|
amount_units=intent.amount_units,
|
||||||
|
token_address=intent.token_address,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
)
|
)
|
||||||
subscription_row = self._grant_subscription(
|
subscription_row = self._grant_subscription(
|
||||||
|
|||||||
@@ -265,6 +265,7 @@ class CreatePaymentIntentRequest(BaseModel):
|
|||||||
plan_code: str = Field(default="pro_monthly", min_length=2)
|
plan_code: str = Field(default="pro_monthly", min_length=2)
|
||||||
payment_mode: str = Field(default="strict")
|
payment_mode: str = Field(default="strict")
|
||||||
allowed_wallet: Optional[str] = None
|
allowed_wallet: Optional[str] = None
|
||||||
|
token_address: Optional[str] = None
|
||||||
use_points: bool = False
|
use_points: bool = False
|
||||||
points_to_consume: Optional[int] = None
|
points_to_consume: Optional[int] = None
|
||||||
metadata: Dict[str, Any] = Field(default_factory=dict)
|
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,
|
plan_code=body.plan_code,
|
||||||
payment_mode=body.payment_mode,
|
payment_mode=body.payment_mode,
|
||||||
allowed_wallet=body.allowed_wallet,
|
allowed_wallet=body.allowed_wallet,
|
||||||
|
token_address=body.token_address,
|
||||||
use_points=body.use_points,
|
use_points=body.use_points,
|
||||||
points_to_consume=body.points_to_consume,
|
points_to_consume=body.points_to_consume,
|
||||||
metadata=body.metadata,
|
metadata=body.metadata,
|
||||||
|
|||||||
Reference in New Issue
Block a user