Fix weekly points display and clarify leaderboard stats

This commit is contained in:
2569718930@qq.com
2026-03-20 23:50:40 +08:00
parent a0b8a3595c
commit 98d36a9174
9 changed files with 706 additions and 28 deletions
+267
View File
@@ -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;
}
}
+18
View File
@@ -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}
+197
View File
@@ -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 交易
适合:
- 月付/季付/年付这类稳定商品
### 路线 BEIP-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` 配置
+46
View File
@@ -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())
+11 -8
View File
@@ -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"🏆 <b>PolyWeather 周活跃度排行榜 ({week_key})</b>\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 += (
"👤 <b>我的状态:</b>\n"
f"┣ 积分: <code>{user_info['points']}</code>\n"
f"┣ 发言: <code>{user_info['message_count']}</code> 次\n"
f"累计积分: <code>{user_info['points']}</code>\n"
f"累计发言: <code>{user_info['message_count']}</code> 次\n"
f"┣ 本周排名: <code>{weekly_rank_text}</code>\n"
f"┣ 本周发言积分: <code>{weekly_points}</code>\n"
f"┣ 今日发言积分: <code>{daily_points}/{MESSAGE_DAILY_CAP}</code>\n"
f"┗ /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>"
+58 -8
View File
@@ -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:
+44 -12
View File
@@ -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,
}
+14
View File
@@ -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"])
+51
View File
@@ -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 "本周发言积分: <code>192</code>" in text
assert "本周排名: <code>1/1</code>" in text