From 98d36a91749903310943ee59da91f1ac5db42c7f Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 20 Mar 2026 23:50:40 +0800 Subject: [PATCH] Fix weekly points display and clarify leaderboard stats --- contracts/PolyWeatherCheckoutV2.sol | 267 ++++++++++++++++++++++ data/probability_training_snapshots.jsonl | 18 ++ docs/payments/PAYMENT_UPGRADE_V2_ZH.md | 197 ++++++++++++++++ scripts/encode_checkout_v2_constructor.py | 46 ++++ src/bot/io_layer.py | 19 +- src/database/db_manager.py | 66 +++++- src/payments/contract_audit.py | 56 ++++- tests/test_payment_contract_audit.py | 14 ++ tests/test_weekly_points_display.py | 51 +++++ 9 files changed, 706 insertions(+), 28 deletions(-) create mode 100644 contracts/PolyWeatherCheckoutV2.sol create mode 100644 docs/payments/PAYMENT_UPGRADE_V2_ZH.md create mode 100644 scripts/encode_checkout_v2_constructor.py create mode 100644 tests/test_weekly_points_display.py diff --git a/contracts/PolyWeatherCheckoutV2.sol b/contracts/PolyWeatherCheckoutV2.sol new file mode 100644 index 00000000..cf5b2a8f --- /dev/null +++ b/contracts/PolyWeatherCheckoutV2.sol @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +interface IERC20 { + function transferFrom(address from, address to, uint256 value) external returns (bool); + function transfer(address to, uint256 value) external returns (bool); +} + +library Address { + function functionCall(address target, bytes memory data, string memory errorMessage) internal returns (bytes memory) { + (bool success, bytes memory returndata) = target.call(data); + require(success, errorMessage); + return returndata; + } +} + +library SafeERC20 { + using Address for address; + + function safeTransferFrom(IERC20 token, address from, address to, uint256 value) internal { + bytes memory returndata = address(token).functionCall( + abi.encodeWithSelector(token.transferFrom.selector, from, to, value), + "SAFE_TRANSFER_FROM_FAILED" + ); + if (returndata.length > 0) { + require(abi.decode(returndata, (bool)), "SAFE_TRANSFER_FROM_FALSE"); + } + } + + function safeTransfer(IERC20 token, address to, uint256 value) internal { + bytes memory returndata = address(token).functionCall( + abi.encodeWithSelector(token.transfer.selector, to, value), + "SAFE_TRANSFER_FAILED" + ); + if (returndata.length > 0) { + require(abi.decode(returndata, (bool)), "SAFE_TRANSFER_FALSE"); + } + } +} + +abstract contract Ownable { + address public owner; + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + modifier onlyOwner() { + require(msg.sender == owner, "ONLY_OWNER"); + _; + } + + constructor(address initialOwner) { + require(initialOwner != address(0), "ZERO_OWNER"); + owner = initialOwner; + emit OwnershipTransferred(address(0), initialOwner); + } + + function transferOwnership(address newOwner) external onlyOwner { + require(newOwner != address(0), "ZERO_OWNER"); + emit OwnershipTransferred(owner, newOwner); + owner = newOwner; + } +} + +abstract contract Pausable { + bool public paused; + + event Paused(address indexed account); + event Unpaused(address indexed account); + + modifier whenNotPaused() { + require(!paused, "PAUSED"); + _; + } + + function _pause() internal { + require(!paused, "PAUSED"); + paused = true; + emit Paused(msg.sender); + } + + function _unpause() internal { + require(paused, "NOT_PAUSED"); + paused = false; + emit Unpaused(msg.sender); + } +} + +abstract contract ReentrancyGuard { + uint256 private _status = 1; + + modifier nonReentrant() { + require(_status == 1, "REENTRANT"); + _status = 2; + _; + _status = 1; + } +} + +contract PolyWeatherCheckoutV2 is Ownable, Pausable, ReentrancyGuard { + using SafeERC20 for IERC20; + + struct PlanConfig { + uint256 amount; + bool active; + } + + bytes32 public constant AUTHORIZED_PAYMENT_TYPEHASH = + keccak256( + "AuthorizedPayment(bytes32 orderId,address payer,uint256 planId,address token,uint256 amount,uint256 nonce,uint256 deadline)" + ); + + bytes32 public immutable DOMAIN_SEPARATOR; + + address public treasury; + address public signer; + mapping(address => bool) public allowedToken; + mapping(bytes32 => bool) public paidOrder; + mapping(uint256 => mapping(address => PlanConfig)) public planConfig; + mapping(address => uint256) public payerNonce; + + event OrderPaid( + bytes32 indexed orderId, + address indexed payer, + uint256 indexed planId, + address token, + uint256 amount + ); + event TreasuryUpdated(address indexed treasury); + event SignerUpdated(address indexed signer); + event TokenAllowedUpdated(address indexed token, bool allowed); + event PlanConfigured(uint256 indexed planId, address indexed token, uint256 amount, bool active); + + constructor(address initialOwner, address initialTreasury, address initialSigner) + Ownable(initialOwner) + { + require(initialTreasury != address(0), "ZERO_TREASURY"); + treasury = initialTreasury; + signer = initialSigner; + + uint256 chainId; + assembly { + chainId := chainid() + } + DOMAIN_SEPARATOR = keccak256( + abi.encode( + keccak256( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" + ), + keccak256(bytes("PolyWeatherCheckoutV2")), + keccak256(bytes("1")), + chainId, + address(this) + ) + ); + } + + function setTreasury(address newTreasury) external onlyOwner { + require(newTreasury != address(0), "ZERO_ADDR"); + treasury = newTreasury; + emit TreasuryUpdated(newTreasury); + } + + function setSigner(address newSigner) external onlyOwner { + signer = newSigner; + emit SignerUpdated(newSigner); + } + + function setTokenAllowed(address token, bool allowed) external onlyOwner { + require(token != address(0), "ZERO_ADDR"); + allowedToken[token] = allowed; + emit TokenAllowedUpdated(token, allowed); + } + + function setPlan(uint256 planId, address token, uint256 amount, bool active) external onlyOwner { + require(planId > 0, "PLAN_ZERO"); + require(token != address(0), "ZERO_ADDR"); + require(amount > 0 || !active, "AMOUNT_ZERO"); + planConfig[planId][token] = PlanConfig({amount: amount, active: active}); + emit PlanConfigured(planId, token, amount, active); + } + + function pause() external onlyOwner { + _pause(); + } + + function unpause() external onlyOwner { + _unpause(); + } + + function payPlan(bytes32 orderId, uint256 planId, address token) + external + whenNotPaused + nonReentrant + { + require(allowedToken[token], "TOKEN_NOT_ALLOWED"); + PlanConfig memory config = planConfig[planId][token]; + require(config.active, "PLAN_NOT_ACTIVE"); + require(config.amount > 0, "PLAN_AMOUNT_ZERO"); + _collect(orderId, msg.sender, planId, token, config.amount); + } + + function payAuthorized( + bytes32 orderId, + uint256 planId, + address token, + uint256 amount, + uint256 deadline, + bytes calldata signature + ) external whenNotPaused nonReentrant { + require(allowedToken[token], "TOKEN_NOT_ALLOWED"); + require(amount > 0, "AMOUNT_ZERO"); + require(deadline >= block.timestamp, "AUTH_EXPIRED"); + require(signer != address(0), "SIGNER_NOT_SET"); + + uint256 nonce = payerNonce[msg.sender]; + bytes32 structHash = keccak256( + abi.encode( + AUTHORIZED_PAYMENT_TYPEHASH, + orderId, + msg.sender, + planId, + token, + amount, + nonce, + deadline + ) + ); + bytes32 digest = keccak256( + abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash) + ); + require(_recover(digest, signature) == signer, "BAD_SIGNATURE"); + payerNonce[msg.sender] = nonce + 1; + + _collect(orderId, msg.sender, planId, token, amount); + } + + function rescueToken(address token, address to, uint256 amount) external onlyOwner nonReentrant { + require(token != address(0) && to != address(0), "ZERO_ADDR"); + IERC20(token).safeTransfer(to, amount); + } + + function _collect(bytes32 orderId, address payer, uint256 planId, address token, uint256 amount) internal { + require(!paidOrder[orderId], "ORDER_PAID"); + paidOrder[orderId] = true; + IERC20(token).safeTransferFrom(payer, treasury, amount); + emit OrderPaid(orderId, payer, planId, token, amount); + } + + function _recover(bytes32 digest, bytes calldata signature) internal pure returns (address) { + require(signature.length == 65, "BAD_SIG_LEN"); + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := calldataload(signature.offset) + s := calldataload(add(signature.offset, 32)) + v := byte(0, calldataload(add(signature.offset, 64))) + } + if (v < 27) { + v += 27; + } + require(v == 27 || v == 28, "BAD_SIG_V"); + address recovered = ecrecover(digest, v, r, s); + require(recovered != address(0), "BAD_SIG"); + return recovered; + } +} diff --git a/data/probability_training_snapshots.jsonl b/data/probability_training_snapshots.jsonl index 8fa64ae5..d82e2970 100644 --- a/data/probability_training_snapshots.jsonl +++ b/data/probability_training_snapshots.jsonl @@ -53,3 +53,21 @@ {"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875} {"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375} {"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375} +{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625} +{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875} +{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375} +{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} +{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375} +{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} +{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875} +{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375} +{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375} +{"city": "test_city", "timestamp": "2026-03-04 10:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.5625, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 26.0, "peak_status": "before", "prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "shadow_prob_snapshot": [{"v": 30, "p": 0.254}, {"v": 29, "p": 0.234}, {"v": 31, "p": 0.185}, {"v": 28, "p": 0.146}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.5625} +{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875} +{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 33.3, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 33.0, "peak_status": "in_window", "prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "shadow_prob_snapshot": [{"v": 33, "p": 0.456}, {"v": 34, "p": 0.391}, {"v": 35, "p": 0.153}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 33.3, "calibrated_sigma": 1.09375} +{"city": "test_city", "timestamp": "2026-03-04 17:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} +{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375} +{"city": "test_city", "timestamp": "2026-03-04 22:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": null, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "past", "prob_snapshot": [{"v": 28, "p": 1.0}], "shadow_prob_snapshot": [], "probability_engine": "legacy", "probability_mode": "legacy", "calibration_version": null, "calibration_source": null, "calibrated_mu": null, "calibrated_sigma": null} +{"city": "test_city", "timestamp": "2026-03-04 16:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 23.0, "raw_sigma": 0.46875, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 23.0, "peak_status": "past", "prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "shadow_prob_snapshot": [{"v": 23, "p": 0.834}, {"v": 24, "p": 0.166}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 23.0, "calibrated_sigma": 0.46875} +{"city": "test_city", "timestamp": "2026-03-04 14:00", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.85, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.5, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 29.5, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "shadow_prob_snapshot": [{"v": 30, "p": 0.565}, {"v": 31, "p": 0.341}, {"v": 32, "p": 0.094}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.85, "calibrated_sigma": 1.09375} +{"city": "test_city", "timestamp": "2026-03-04 14:30", "date": "2026-03-04", "temp_symbol": "°C", "raw_mu": 29.7, "raw_sigma": 1.09375, "deb_prediction": null, "ensemble": {"p10": 27.0, "median": 29.0, "p90": 31.0}, "multi_model": {"Open-Meteo": 30.0}, "max_so_far": 28.0, "peak_status": "in_window", "prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "shadow_prob_snapshot": [{"v": 30, "p": 0.35}, {"v": 29, "p": 0.299}, {"v": 31, "p": 0.187}, {"v": 28, "p": 0.117}], "probability_engine": "legacy", "probability_mode": "emos_shadow", "calibration_version": "emos-20260320132525", "calibration_source": "artifacts\\probability_calibration\\default.json", "calibrated_mu": 29.7, "calibrated_sigma": 1.09375} diff --git a/docs/payments/PAYMENT_UPGRADE_V2_ZH.md b/docs/payments/PAYMENT_UPGRADE_V2_ZH.md new file mode 100644 index 00000000..2303351a --- /dev/null +++ b/docs/payments/PAYMENT_UPGRADE_V2_ZH.md @@ -0,0 +1,197 @@ +# PolyWeather 支付合约升级方案(V2) + +最后更新:`2026-03-20` + +## 1. 目标 + +本次 V2 方案对应三个明确目标: + +1. 把 `owner` 迁到多签地址 +2. 升级到 `SafeERC20 + Pausable + ReentrancyGuard` +3. 把“链上 plan 绑定”和“EIP-712 授权支付”都纳入设计,而不是只在链下校验 + +合约草案: +- [PolyWeatherCheckoutV2.sol](/E:/web/PolyWeather/contracts/PolyWeatherCheckoutV2.sol) + +构造参数编码脚本: +- [encode_checkout_v2_constructor.py](/E:/web/PolyWeather/scripts/encode_checkout_v2_constructor.py) + +## 2. V2 新增能力 + +### 多签 owner + +V2 constructor 不再默认 `msg.sender` 作为唯一 owner,而是显式传入: + +- `initialOwner` +- `initialTreasury` +- `initialSigner` + +这意味着: +- 部署后可直接把多签地址设为 `owner` +- 不需要先单签部署再补 transfer + +### SafeERC20 + +V2 内置最小 `SafeERC20` 封装: + +- `safeTransferFrom` +- `safeTransfer` + +相比直接依赖 `IERC20.transferFrom -> bool`: +- 对非标准 ERC20 的兼容性更稳 +- 出错边界更明确 + +### Pausable + +V2 增加: + +- `pause()` +- `unpause()` + +支付入口: + +- `payPlan(...)` +- `payAuthorized(...)` + +都受 `whenNotPaused` 保护。 + +一旦发现: +- treasury 配置错误 +- token allowlist 配置错误 +- 签名器异常 +- 链上风控问题 + +可以直接暂停支付入口。 + +### ReentrancyGuard + +V2 增加 `nonReentrant`,保护: + +- `payPlan` +- `payAuthorized` +- `rescueToken` + +虽然当前订单去重已经能挡住典型重复支付路径,但 `ReentrancyGuard` 仍然是更稳的防线。 + +### 链上套餐绑定 + +V2 新增: + +- `setPlan(planId, token, amount, active)` +- `planConfig[planId][token]` + +正式支付入口 `payPlan` 会: + +1. 校验 token 已 allowed +2. 校验 `planId + token` 的 plan 已 active +3. 从链上读取 amount +4. 按链上配置收款 + +这意味着: +- `planId / amount / token` 绑定不再完全依赖链下 + +### EIP-712 授权支付 + +V2 同时保留第二条入口: + +- `payAuthorized(...)` + +它适合: +- 临时折扣 +- 特殊活动价 +- 不想每次都上链改 `setPlan` + +校验字段包括: + +- `orderId` +- `payer` +- `planId` +- `token` +- `amount` +- `nonce` +- `deadline` + +签名人地址由: + +- `signer` + +统一控制。 + +## 3. 两条支付路径怎么选 + +### 路线 A:链上套餐绑定优先 + +优点: +- 最直观 +- 合约级约束最强 +- 更容易审计 + +缺点: +- 套餐改价需要 owner 交易 + +适合: +- 月付/季付/年付这类稳定商品 + +### 路线 B:EIP-712 授权优先 + +优点: +- 活动价灵活 +- 不必每次改链上 plan + +缺点: +- 需要管理 signer 密钥 +- 风险从 owner 单点,部分转移到 signer 运维 + +适合: +- 促销 +- 临时折扣 +- 白名单价格 + +### 当前建议 + +生产建议不是二选一,而是: + +1. **稳定套餐** 走 `payPlan` +2. **特殊场景** 走 `payAuthorized` + +这样: +- 主流程更稳 +- 特殊价仍保留灵活性 + +## 4. 推荐迁移步骤 + +1. 先部署 V2 到测试环境 +2. `owner` 直接用多签地址 +3. 配置 `treasury` +4. 配置 `allowedToken` +5. 配置 `planId/token/amount` +6. 仅在需要活动价时再配置 `signer` +7. 用事件重放脚本和运行态接口验证 +8. 再切生产前端/后端配置到新 `receiver_contract` + +## 5. 构造参数编码 + +示例: + +```bash +python scripts/encode_checkout_v2_constructor.py \ + --owner 0xYourMultiSig \ + --treasury 0xYourTreasury \ + --signer 0xYourBackendSigner +``` + +## 6. 当前判断 + +V2 已经把这三件事做成了明确方案: + +1. 多签 owner +2. SafeERC20 + Pausable + ReentrancyGuard +3. 链上 plan 绑定 + EIP-712 授权 + +它现在是**升级草案**,不是现网已部署合约。 + +如果要真正上线,下一步就是: + +1. 做一次测试网或本地链验证 +2. 更新 PolygonScan 验证文档 +3. 修改后端 `receiver_contract` 配置 diff --git a/scripts/encode_checkout_v2_constructor.py b/scripts/encode_checkout_v2_constructor.py new file mode 100644 index 00000000..8aac9289 --- /dev/null +++ b/scripts/encode_checkout_v2_constructor.py @@ -0,0 +1,46 @@ +#!/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 PolyWeatherCheckoutV2 constructor args for PolygonScan verification.", + ) + parser.add_argument("--owner", required=True, help="Owner or multisig address") + parser.add_argument("--treasury", required=True, help="Treasury address") + parser.add_argument( + "--signer", + required=True, + help="EIP-712 signer address (backend signer or multisig-controlled signer)", + ) + args = parser.parse_args() + + for name, value in { + "owner": args.owner, + "treasury": args.treasury, + "signer": args.signer, + }.items(): + if not Web3.is_address(value): + print(f"invalid --{name} address", file=sys.stderr) + return 1 + + encoded = encode( + ["address", "address", "address"], + [ + Web3.to_checksum_address(args.owner), + Web3.to_checksum_address(args.treasury), + Web3.to_checksum_address(args.signer), + ], + ).hex() + print(encoded) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/bot/io_layer.py b/src/bot/io_layer.py index 843f766f..74674d1b 100644 --- a/src/bot/io_layer.py +++ b/src/bot/io_layer.py @@ -165,8 +165,8 @@ class BotIOLayer: user_info = self.db.get_user(user.id) now = datetime.now() today_str = now.strftime("%Y-%m-%d") - iso_year, iso_week, _ = now.isocalendar() - week_key = f"{iso_year}-W{iso_week:02d}" + weekly_profile = self.db.get_weekly_profile(user.id) + week_key = str(weekly_profile.get("week_key") or "") leaderboard = self.db.get_weekly_leaderboard(limit=5) rank_text = f"🏆 PolyWeather 周活跃度排行榜 ({week_key})\n" @@ -185,16 +185,19 @@ class BotIOLayer: if daily_points > MESSAGE_DAILY_CAP: daily_points = MESSAGE_DAILY_CAP - weekly_points = int(user_info.get("weekly_points") or 0) - weekly_points_week = str(user_info.get("weekly_points_week") or "") - if weekly_points_week != week_key: - weekly_points = 0 + weekly_points = int(weekly_profile.get("weekly_points") or 0) + weekly_rank = weekly_profile.get("weekly_rank") + ranked_count = int(weekly_profile.get("total_ranked") or 0) + weekly_rank_text = ( + f"{weekly_rank}/{ranked_count}" if weekly_rank and ranked_count > 0 else "未上榜" + ) rank_text += "────────────────────\n" rank_text += ( "👤 我的状态:\n" - f"┣ 积分: {user_info['points']}\n" - f"┣ 发言: {user_info['message_count']} 次\n" + f"┣ 累计积分: {user_info['points']}\n" + f"┣ 累计发言: {user_info['message_count']} 次\n" + f"┣ 本周排名: {weekly_rank_text}\n" f"┣ 本周发言积分: {weekly_points}\n" f"┣ 今日发言积分: {daily_points}/{MESSAGE_DAILY_CAP}\n" f"┗ /city 消耗: {CITY_QUERY_COST} | /deb 消耗: {DEB_QUERY_COST}" diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 0ed062f8..b76828a4 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -762,20 +762,70 @@ class DBManager: """ SELECT username, - points, - message_count, - CASE - WHEN weekly_points_week = ? THEN COALESCE(weekly_points, 0) - ELSE 0 - END AS weekly_points - FROM users + u.points AS points, + u.message_count AS message_count, + u.telegram_id AS telegram_id, + COALESCE(a.points, + CASE + WHEN u.weekly_points_week = ? THEN COALESCE(u.weekly_points, 0) + ELSE 0 + END + ) AS weekly_points + FROM users u + LEFT JOIN weekly_points_archive a + ON a.telegram_id = u.telegram_id + AND a.week_key = ? ORDER BY weekly_points DESC, points DESC, message_count DESC LIMIT ? """, - (week_key, limit), + (week_key, week_key, limit), ) return [dict(row) for row in cursor.fetchall()] + def get_weekly_profile(self, telegram_id: int) -> Dict[str, Any]: + now = datetime.now() + iso_year, iso_week, _ = now.isocalendar() + week_key = f"{iso_year}-W{iso_week:02d}" + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + """ + SELECT + u.telegram_id, + COALESCE(u.points, 0) AS points, + COALESCE(u.message_count, 0) AS message_count, + COALESCE(a.points, + CASE + WHEN u.weekly_points_week = ? THEN COALESCE(u.weekly_points, 0) + ELSE 0 + END + ) AS weekly_points + FROM users u + LEFT JOIN weekly_points_archive a + ON a.telegram_id = u.telegram_id + AND a.week_key = ? + ORDER BY weekly_points DESC, points DESC, message_count DESC, u.telegram_id ASC + """, + (week_key, week_key), + ).fetchall() + + weekly_rank: Optional[int] = None + weekly_points = 0 + total_ranked = 0 + for idx, row in enumerate(rows, start=1): + row_weekly_points = int(row["weekly_points"] or 0) + if row_weekly_points > 0: + total_ranked += 1 + if int(row["telegram_id"] or 0) == int(telegram_id): + weekly_rank = idx if row_weekly_points > 0 else None + weekly_points = row_weekly_points + return { + "week_key": week_key, + "weekly_points": max(0, int(weekly_points or 0)), + "weekly_rank": weekly_rank, + "total_ranked": total_ranked, + } + def get_weekly_profile_by_supabase_user_id(self, supabase_user_id: str) -> Dict[str, Any]: key = str(supabase_user_id or "").strip().lower() if not key: diff --git a/src/payments/contract_audit.py b/src/payments/contract_audit.py index f561b29c..18100ffc 100644 --- a/src/payments/contract_audit.py +++ b/src/payments/contract_audit.py @@ -17,6 +17,7 @@ def analyze_checkout_contract(source_path: str) -> Dict[str, Any]: checks = { "has_only_owner_modifier": _has(r"modifier\s+onlyOwner\s*\(", source), "owner_set_in_constructor": _has(r"owner\s*=\s*msg\.sender\s*;", source), + "owner_injected_in_constructor": _has(r"constructor\s*\(\s*address\s+initialOwner", source), "set_treasury_only_owner": _has( r"function\s+setTreasury\s*\([^)]*\)\s*external\s+onlyOwner", source ), @@ -26,6 +27,10 @@ def analyze_checkout_contract(source_path: str) -> Dict[str, Any]: "zero_address_guard_in_constructor": _has( r"constructor\s*\([^)]*\)\s*\{\s*require\(\s*_token\s*!=\s*address\(0\)\s*&&\s*_treasury\s*!=\s*address\(0\)", source, + ) + or _has( + r"constructor[\s\S]*?require\(\s*initialTreasury\s*!=\s*address\(0\)", + source, ), "zero_address_guard_in_setters": _has( r"function\s+setTreasury[\s\S]*?require\(\s*_treasury\s*!=\s*address\(0\)", @@ -34,6 +39,16 @@ def analyze_checkout_contract(source_path: str) -> Dict[str, Any]: and _has( r"function\s+setTokenAllowed[\s\S]*?require\(\s*token\s*!=\s*address\(0\)", source, + ) + or ( + _has( + r"function\s+setTreasury[\s\S]*?require\(\s*newTreasury\s*!=\s*address\(0\)", + source, + ) + and _has( + r"function\s+setTokenAllowed[\s\S]*?require\(\s*token\s*!=\s*address\(0\)", + source, + ) ), "allowed_token_check": _has(r"require\(\s*allowedToken\[token\]", source), "amount_non_zero_check": _has(r"require\(\s*amount\s*>\s*0", source), @@ -41,15 +56,24 @@ def analyze_checkout_contract(source_path: str) -> Dict[str, Any]: "paid_order_written_before_transfer": _has( r"paidOrder\[orderId\]\s*=\s*true\s*;\s*require\(IERC20\(token\)\.transferFrom", source, + ) + or _has( + r"paidOrder\[orderId\]\s*=\s*true\s*;\s*IERC20\(token\)\.safeTransferFrom", + source, ), "emits_order_paid": _has(r"emit\s+OrderPaid\s*\(", source), "uses_safe_erc20": _has(r"SafeERC20", source), "has_pause_switch": _has(r"\bpaused\b|\bPausable\b|\bwhenNotPaused\b", source), + "has_reentrancy_guard": _has(r"nonReentrant|ReentrancyGuard", source), "has_rescue_function": _has( r"function\s+(rescue|sweep|withdraw|recover)", source ), "binds_plan_amount_onchain": _has( - r"mapping\s*\(\s*uint256\s*=>[\s\S]*plan|planAmount|require\(\s*amount\s*==", + r"mapping\s*\(\s*uint256\s*=>[\s\S]*PlanConfig|planAmount|require\(\s*amount\s*==|function\s+setPlan", + source, + ), + "has_signature_authorization": _has( + r"EIP712Domain|AUTHORIZED_PAYMENT_TYPEHASH|function\s+payAuthorized", source, ), } @@ -65,6 +89,14 @@ def analyze_checkout_contract(source_path: str) -> Dict[str, Any]: strengths.append("订单去重状态在外部 transferFrom 前写入,能拦住同订单重复支付与典型重入重放。") if checks["emits_order_paid"]: strengths.append("链上事件 OrderPaid 明确,可作为链下审计与补单的唯一确认源。") + if checks["has_reentrancy_guard"]: + strengths.append("支付入口包含 ReentrancyGuard,能进一步收紧外部调用期间的重入风险。") + if checks["has_pause_switch"]: + strengths.append("合约具备暂停开关,便于紧急止损。") + if checks["binds_plan_amount_onchain"]: + strengths.append("套餐金额已支持链上绑定,不再完全依赖链下校验。") + if checks["has_signature_authorization"]: + strengths.append("支持 EIP-712 授权支付,可在链上金额绑定之外增加签名边界。") if checks["uses_safe_erc20"]: strengths.append("使用了 SafeERC20 包装,兼容性更稳。") @@ -88,7 +120,7 @@ def analyze_checkout_contract(source_path: str) -> Dict[str, Any]: } ) - if not checks["binds_plan_amount_onchain"]: + if not checks["binds_plan_amount_onchain"] and not checks["has_signature_authorization"]: risks.append( { "id": "offchain_price_enforcement", @@ -108,14 +140,15 @@ def analyze_checkout_contract(source_path: str) -> Dict[str, Any]: } ) - risks.append( - { - "id": "single_owner_admin", - "severity": "medium", - "title": "owner 为单地址管理模型", - "detail": "setTreasury 和 setTokenAllowed 由单一 owner 控制。生产建议用多签地址持有 owner,降低单点密钥失窃风险。", - } - ) + if not checks["owner_injected_in_constructor"]: + risks.append( + { + "id": "single_owner_admin", + "severity": "medium", + "title": "owner 为单地址管理模型", + "detail": "setTreasury 和 setTokenAllowed 由单一 owner 控制。生产建议用多签地址持有 owner,降低单点密钥失窃风险。", + } + ) runtime_controls = [ "后端只认链上 OrderPaid 事件,不认前端自报支付成功。", @@ -132,7 +165,7 @@ def analyze_checkout_contract(source_path: str) -> Dict[str, Any]: return { "contract_path": path, - "contract_name": "PolyWeatherCheckout", + "contract_name": "PolyWeatherCheckoutV2" if "PolyWeatherCheckoutV2" in source else "PolyWeatherCheckout", "summary": { "strength_count": len(strengths), "risk_count": len(risks), @@ -144,4 +177,3 @@ def analyze_checkout_contract(source_path: str) -> Dict[str, Any]: "risks": risks, "recommendations": recommendations, } - diff --git a/tests/test_payment_contract_audit.py b/tests/test_payment_contract_audit.py index 5e3ffa92..65dfa0e1 100644 --- a/tests/test_payment_contract_audit.py +++ b/tests/test_payment_contract_audit.py @@ -16,3 +16,17 @@ def test_payment_contract_audit_detects_current_controls(): assert report["checks"]["binds_plan_amount_onchain"] is False assert any(risk["id"] == "single_owner_admin" for risk in report["risks"]) + +def test_payment_contract_audit_detects_v2_controls(): + report = analyze_checkout_contract( + os.path.join("contracts", "PolyWeatherCheckoutV2.sol") + ) + + assert report["contract_name"] == "PolyWeatherCheckoutV2" + assert report["checks"]["uses_safe_erc20"] is True + assert report["checks"]["has_pause_switch"] is True + assert report["checks"]["has_reentrancy_guard"] is True + assert report["checks"]["binds_plan_amount_onchain"] is True + assert report["checks"]["has_signature_authorization"] is True + assert report["checks"]["owner_injected_in_constructor"] is True + assert not any(risk["id"] == "single_owner_admin" for risk in report["risks"]) diff --git a/tests/test_weekly_points_display.py b/tests/test_weekly_points_display.py new file mode 100644 index 00000000..5162d698 --- /dev/null +++ b/tests/test_weekly_points_display.py @@ -0,0 +1,51 @@ +from datetime import datetime + +from src.bot.io_layer import BotIOLayer +from src.database.db_manager import DBManager + + +class _DummyBot: + def send_message(self, *args, **kwargs): + return None + + +class _User: + def __init__(self, user_id: int, username: str): + self.id = user_id + self.username = username + self.first_name = username + + +def test_weekly_points_display_uses_weekly_profile_archive_fallback(tmp_path): + db = DBManager(str(tmp_path / "test.db")) + io = BotIOLayer(_DummyBot(), db) + user = _User(1001, "yuan") + + db.upsert_user(user.id, user.username) + now = datetime.now() + week_key = f"{now.isocalendar()[0]}-W{now.isocalendar()[1]:02d}" + with db._get_connection() as conn: # noqa: SLF001 + conn.execute( + """ + UPDATE users + SET points = ?, message_count = ?, daily_points = ?, daily_points_date = ?, weekly_points = ?, weekly_points_week = ? + WHERE telegram_id = ? + """, + (206, 15, 0, now.strftime("%Y-%m-%d"), 0, "", user.id), + ) + conn.execute( + """ + INSERT INTO weekly_points_archive (telegram_id, week_key, points, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(telegram_id, week_key) DO UPDATE SET + points = excluded.points, + updated_at = excluded.updated_at + """, + (user.id, week_key, 192, now.isoformat()), + ) + conn.commit() + + text = io.build_points_rank_text(user) + assert "累计发言" in text + assert "本周发言积分: 192" in text + assert "本周排名: 1/1" in text