Fix weekly points display and clarify leaderboard stats
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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` 配置
|
||||
@@ -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
@@ -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>"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user