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