diff --git a/CHANGELOG.md b/CHANGELOG.md index ad406f06a..5b584e5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ This project uses selective package publishing. Each release entry lists the pub - Contracts: added the M001 migration workflow for Base Sepolia and Base mainnet, with state-driven dry-run, broadcast, and pinned Anvil-fork modes; reviewable transaction plans; signer roles resolved from keystores or hardware wallets (`--signer role=account:…|keystore:…|ledger`) so no private key is ever read by the repository; resumable epoch-boundary cutover orchestration that pauses Channels and unpauses only after both registry pointers are verified; atomic append-only deployment records with shared and migration-specific validation; generated chain configuration; reproducible bytecode verification against the deployed code (the cutover phase reads the committed deployment record and requires a matching local build rather than a pinned commit); non-mutating gas snapshot checks; interrupted-record reconciliation; and the consolidated `pnpm contracts:check` command for Forge tests, runner tests, ledger/config validation, bytecode verification, and optional deployment-history enforcement. - Contracts: `AntseedPointsPolicyRegistry` now composes trusted points modifiers using bounded basis-point multipliers, allowing reductions, boosts, and hard vetoes without stacking modifiers from the same category. - Contracts: `AntseedPositionInit` now pins the wash-trading registry at construction and refuses starter positions to proven wash traders; the M001 deploy phase requires `WASH_TRADING_REGISTRY` (with an always-false stub in `--fork-test`). +- Contracts: added the M002 migration (`pnpm contracts:deploy -- M002`) that unfreezes the deployed `AntseedSellerRewardsPool` after M001 activates: whitelists the pool on `ANTSToken` and installs the stateless `AntseedLegacySellerClaimPolicy`, which releases a configurable share (default 1000 bps = 10%) of each seller's locked legacy rewards re-derived from EmissionsV2/V1 state, with immediate release and no owner controls, and pays nothing to sellers the wash-trading registry has proven as wash traders. Includes dry-run plans, a combined M001+M002 Anvil fork rehearsal, and the `002-legacy-seller-claims` deployment record. + - Desktop telemetry's `app_connect` / `app_disconnect` user actions now carry which app was connected, as an `app` property drawn from a fixed local taxonomy (the packaged profile names plus the Telegram bot); user-added custom apps report as `custom`, so raw app names never leave the device. Connect events are also attributed to the specific app being connected rather than firing on every profile-set restart (profile switches and custom-app removals no longer emit spurious `app_connect`). - Desktop now finds T3 Code installed under any release channel — the launch-target lookup previously only checked for "T3 Code (Alpha)", so stable/Beta/Nightly installs got no app icon, no default "Open with" application, and no restart action. All channel variants are now probed (stable first), and the T3 Code rows fall back to the official t3.codes icon instead of the generic mark when the app isn't installed. - Desktop's Home screen keeps the "Use AntSeed on your favorite app" pills visible after connecting a tool — previously connecting anything hid the whole list. The pitch now disappears only once the user has chats, and an already-connected app's pill shows as connected (green dot, green-tinted border) and opens the Apps page instead of reconnecting. @@ -26,6 +28,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Fixed +- Contracts: M002 rejects a `LAST_LOCKED_EPOCH` override that omits legacy deposits; documents the no-mixed-payouts-per-seller requirement and covers late pre-migration claims and repeated pool withdrawals. - Desktop no longer shows routing as on before it actually is. The Home power button, hero status, footer status strip, and floating pill lit up as soon as the buyer process was spawned — on first launch and when turning routing back on — even though the local proxy was not yet accepting connections. They now stay in a "Starting..." state until the proxy port answers a reachability probe (re-checked every second during startup), and only then switch to on/Running. - Desktop's footer status strip no longer reports the network as "Healthy" after routing is stopped — network stats kept their last DHT snapshot, so the strip read "Healthy | Stopped". It now shows "Offline" while the buyer runtime is stopped, and the "Stopped" state is shown in red like other error states. - Desktop telemetry launches now appear as sessions in PostHog. Events carried the launch id only as a custom `session_id` property and as a v4 UUID, so PostHog's Sessions explorer — which keys on `$session_id` and derives the session start from a UUIDv7 timestamp — showed nothing. The launch id is now a UUIDv7 and is sent as both `session_id` and `$session_id`, enabling per-launch analysis (session duration, events per launch, crash rate) without changing anything built on `session_id`. diff --git a/packages/contracts/.env.example b/packages/contracts/.env.example index 1d5e06148..80757d529 100644 --- a/packages/contracts/.env.example +++ b/packages/contracts/.env.example @@ -30,3 +30,14 @@ WASH_TRADING_REGISTRY=0x...deployed_wash_trading_registry # pinned into Position #SELLER_REWARDS_POOL_OWNER=0x... # required with the proxy #USAGE_ACCOUNTING=0x...printed_by_deploy #SELLER_REGISTRY=0x...printed_by_deploy + +# Legacy seller claims (M002LegacySellerClaims Install). The CLI fills every +# address from deployments//current.json; signers are addresses given +# via --signer. Set these only when running the Foundry script by hand. +#DEPLOYER=0x...ants_token_owner +#SELLER_REWARDS_POOL_OWNER=0x... +#LEGACY_EMISSIONS_V2=0x...emissions_v2_that_locked_into_the_pool +#USAGE_ACCOUNTING=0x... +#WASH_TRADING_REGISTRY=0x... # CLI default: washTradingRegistry in the activated M001 ledger +#RELEASE_BPS=1000 # share of cumulative locked rewards released (10%) +#LAST_LOCKED_EPOCH=0 # optional override; default gate.effectiveEpoch() - 1 diff --git a/packages/contracts/README.md b/packages/contracts/README.md index d2ad19223..511157c3d 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -376,6 +376,46 @@ Run `M001RecognizedUsageFork.t.sol` with `BASE_MAINNET_RPC_URL` to validate the live canonical starting state. Set `BASE_MAINNET_FORK_BLOCK` when using an archive-capable RPC to pin the check to a specific block. +### Legacy Seller Claims (M002) + +Sellers whose legacy EmissionsV2 rewards were routed to +`AntseedSellerRewardsPool` cannot claim them yet: the pool has no +`sellerClaimPolicy`, and ANTS transfers are disabled with the pool (the +transfer *sender*) never whitelisted. M001 deliberately leaves both alone. + +`script/migrations/M002LegacySellerClaims/Install.s.sol` fixes both in one +idempotent broadcast, after M001 has activated: + +1. `deployer` (ANTSToken owner) — `setTransferWhitelist(pool, true)`, skipped + when transfers are already enabled or the pool is already whitelisted. +2. `sellerRewardsPoolOwner` — deploys `AntseedLegacySellerClaimPolicy` and + installs it with `pool.setSellerClaimPolicy`, skipped when the pool already + has the policy this ledger recorded. + +`AntseedLegacySellerClaimPolicy` is stateless: the pool calls +`claimableSellerRewards(seller, locked)` as a view, so the policy re-derives +each seller's *cumulative* locked amount from EmissionsV2/V1 state (epochs +`0 … effectiveEpoch − 1`, mirroring `claimSellerEmissions`), treats +`cumulative − locked` as already released, and pays out `RELEASE_BPS` +(default 1000 = 10%) of the cumulative amount immediately. Sellers the immutable +wash-trading registry has proven (`isProvenWashTrader`) can claim nothing; +their ANTS stays in the pool. The policy has no owner or administrative setters. The CLI defaults +`WASH_TRADING_REGISTRY` to `washTradingRegistry` in the activated M001 deployment +ledger, without calling `AntseedPositionInit`. + +```bash +pnpm contracts:deploy -- M002 --network base-mainnet --fork-test # M001 + M002 rehearsal +pnpm contracts:deploy -- M002 --network base-mainnet --dry-run +pnpm contracts:deploy -- M002 --network base-mainnet --broadcast \ + --signer deployer=account:antseed-owner \ + --signer sellerRewardsPoolOwner=account:antseed-ops +``` + +States: `ready` (M001 active, at least one install missing), `active`, +`not-applicable` (no rewards pool on the legacy emissions contract), or +`invalid` (M001 not active, or a claim policy this ledger did not install). Writes `history/002-legacy-seller-claims.json` and updates +`current.json`. Runbook: `script/migrations/M002LegacySellerClaims/README.md`. + ## Configuration All constants are configurable by the contract owner via dedicated setter functions (e.g., `setFirstSignCap()`, `setWithdrawalDelay()`). diff --git a/packages/contracts/policies/AntseedLegacySellerClaimPolicy.sol b/packages/contracts/policies/AntseedLegacySellerClaimPolicy.sol new file mode 100644 index 000000000..35deae627 --- /dev/null +++ b/packages/contracts/policies/AntseedLegacySellerClaimPolicy.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { IAntseedSellerClaimPolicy } from "../interfaces/IAntseedSellerClaimPolicy.sol"; +import { IAntseedWashTradingStatus } from "../interfaces/IAntseedWashTradingStatus.sol"; + +interface IEmissionsV2View { + function legacyEmissions() external view returns (address); + function MIGRATION_EPOCH() external view returns (uint256); + function getEpochEmission(uint256 epoch) external view returns (uint256); + function epochParams(uint256 epoch) + external + view + returns ( + uint256 sellerSharePct, + uint256 buyerSharePct, + uint256 reserveSharePct, + uint256 teamSharePct, + uint256 maxSellerSharePct, + uint256 maxBuyerSharePct, + bool initialized + ); + function epochTotalSellerPoints(uint256 epoch) external view returns (uint256); + function userSellerPoints(address seller, uint256 epoch) external view returns (uint256); + function sellerEpochClaimed(address seller, uint256 epoch) external view returns (bool); +} + +interface IEmissionsV1View { + function epochTotalSellerPoints(uint256 epoch) external view returns (uint256); + function userSellerPoints(address seller, uint256 epoch) external view returns (uint256); +} + +/** + * @title AntseedLegacySellerClaimPolicy + * @notice Stateless claim policy for the deployed AntseedSellerRewardsPool. + * + * The pool calls `claimableSellerRewards(seller, locked)` as a view and + * is the only contract that mutates balances, so this policy cannot + * keep its own "already claimed" counter. Instead it re-derives the + * seller's *cumulative* locked amount from EmissionsV2/V1 state + * (mirroring `claimSellerEmissions` exactly) and treats + * `cumulative - locked` as what has already been released. + * + * Release rule: + * entitled = cumulative * releaseBps / BPS (10% of cumulative locked rewards) + * claimable = entitled - released + * + * Wash trading: a seller flagged by the configured on-chain wash-trading + * source (`isProvenWashTrader`) can claim + * nothing. Their rewards stay locked in the pool. + * + * Limitations (documented, conservative): + * - `sellerEpochClaimed` is set for both locked and unlock-policy + * (direct mint) claims. A seller that was unlock-eligible has the + * direct-minted amount counted as "already released", which only + * ever lowers what this policy returns. The pool clamps to `locked`. + * - Epochs are scanned from 0 through `lastEpoch`; pre-migration + * epochs claimed through V2 also landed in the pool and must count. + */ +contract AntseedLegacySellerClaimPolicy is IAntseedSellerClaimPolicy { + uint256 public constant BPS = 10_000; + + IEmissionsV2View public immutable v2; + IEmissionsV1View public immutable v1; // = v2.legacyEmissions(); only contributes points for epochs <= migrationEpoch + uint256 public immutable migrationEpoch; // V1 points are merged for epochs <= migrationEpoch + uint256 public immutable lastEpoch; // last epoch that could have been locked into the pool + uint256 public immutable releaseBps; // 1000 = 10% of cumulative locked rewards + IAntseedWashTradingStatus public immutable washTradingRegistry; + + error InvalidAddress(); + error InvalidValue(); + + constructor(address v2_, uint256 lastEpoch_, uint256 releaseBps_, address washTradingRegistry_) { + if (v2_ == address(0)) revert InvalidAddress(); + if (washTradingRegistry_ == address(0) || washTradingRegistry_.code.length == 0) revert InvalidAddress(); + if (releaseBps_ == 0 || releaseBps_ > BPS) revert InvalidValue(); + address v1_ = IEmissionsV2View(v2_).legacyEmissions(); + if (v1_ == address(0)) revert InvalidAddress(); + v2 = IEmissionsV2View(v2_); + v1 = IEmissionsV1View(v1_); + migrationEpoch = IEmissionsV2View(v2_).MIGRATION_EPOCH(); + if (lastEpoch_ < migrationEpoch) revert InvalidValue(); + lastEpoch = lastEpoch_; + releaseBps = releaseBps_; + washTradingRegistry = IAntseedWashTradingStatus(washTradingRegistry_); + } + + // ═══════════════════════════════════════════════════════════════════ + // VIEWS + // ═══════════════════════════════════════════════════════════════════ + + /// @notice True when the seller must not receive any locked rewards. + function isWashTrader(address seller) public view returns (bool) { + return washTradingRegistry.isProvenWashTrader(seller); + } + + /// @notice Total ANTS ever routed to the rewards pool for `seller`, + /// reconstructed from V2 claim flags and V2/V1 points. + function cumulativeLocked(address seller) public view returns (uint256 total) { + uint256 migration = migrationEpoch; + for (uint256 epoch = 0; epoch <= lastEpoch; epoch++) { + // Not claimed, or claimed via V1 before migration -> never entered the pool. + if (!v2.sellerEpochClaimed(seller, epoch)) continue; + + uint256 sellerPoints = v2.userSellerPoints(seller, epoch); + uint256 totalSellerPoints = v2.epochTotalSellerPoints(epoch); + if (epoch <= migration) { + sellerPoints += v1.userSellerPoints(seller, epoch); + totalSellerPoints += v1.epochTotalSellerPoints(epoch); + } + if (sellerPoints == 0 || totalSellerPoints == 0) continue; + + (uint256 sellerSharePct,,,, uint256 maxSellerSharePct,,) = v2.epochParams(epoch); + uint256 sellerBudget = (v2.getEpochEmission(epoch) * sellerSharePct) / 100; + uint256 reward = (sellerPoints * sellerBudget) / totalSellerPoints; + uint256 maxReward = (sellerBudget * maxSellerSharePct) / 100; + total += reward > maxReward ? maxReward : reward; + } + } + + /// @notice Share of `cumulative` the seller is entitled to. + function entitledOf(uint256 cumulative) public view returns (uint256 entitled) { + entitled = (cumulative * releaseBps) / BPS; + } + + /// @inheritdoc IAntseedSellerClaimPolicy + function claimableSellerRewards(address seller, uint256 locked) external view returns (uint256) { + if (locked == 0) return 0; + if (isWashTrader(seller)) return 0; + + uint256 cumulative = cumulativeLocked(seller); + if (cumulative < locked) cumulative = locked; // defensive: never under-count + uint256 released = cumulative - locked; + + uint256 entitled = entitledOf(cumulative); + uint256 claimable = entitled > released ? entitled - released : 0; + return claimable > locked ? locked : claimable; + } +} diff --git a/packages/contracts/script/migrations/M002LegacySellerClaims/Install.s.sol b/packages/contracts/script/migrations/M002LegacySellerClaims/Install.s.sol new file mode 100644 index 000000000..d60d14dc5 --- /dev/null +++ b/packages/contracts/script/migrations/M002LegacySellerClaims/Install.s.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Script.sol"; + +import { AntseedLegacySellerClaimPolicy } from "../../../policies/AntseedLegacySellerClaimPolicy.sol"; + +interface IAntseedRegistryView { + function emissions() external view returns (address); + function antsToken() external view returns (address); +} + +interface IANTSTokenWhitelist { + function owner() external view returns (address); + function transfersEnabled() external view returns (bool); + function transferWhitelist(address account) external view returns (bool); + function setTransferWhitelist(address account, bool allowed) external; +} + +interface ILegacyEmissionsV2View { + function legacyEmissions() external view returns (address); + function sellerRewardsPool() external view returns (address); + function MIGRATION_EPOCH() external view returns (uint256); +} + +interface ISellerRewardsPoolAdmin { + function owner() external view returns (address); + function sellerClaimPolicy() external view returns (address); + function totalLockedRewards() external view returns (uint256); + function setSellerClaimPolicy(address policy) external; +} + +interface IUsageAccountingView { + function emissionsGate() external view returns (address); +} + +interface IEmissionsGateView { + function effectiveEpoch() external view returns (uint256); +} + +/** + * @title M002InstallLegacySellerClaims + * @notice Unfreezes the deployed AntseedSellerRewardsPool so legacy sellers + * can claim the released share of their locked ANTS. + * + * Two things block `pool.claim()` today: + * 1. the pool has no `sellerClaimPolicy` (claim reverts + * NoSellerClaimPolicy); + * 2. ANTS transfers are disabled and the pool — the transfer + * *sender* — was never whitelisted (claim reverts + * TransfersNotEnabled). + * + * This script fixes both, each with the key that owns it: + * - TOKEN OWNER (`DEPLOYER`): `ANTSToken.setTransferWhitelist(pool, true)` + * unless transfers are already enabled or the pool is whitelisted. + * - POOL OWNER (`SELLER_REWARDS_POOL_OWNER`): deploys + * AntseedLegacySellerClaimPolicy and installs it on the pool. + * + * Run only after M001 has activated (`registry.emissions()` is + * AntseedUsageAccounting). From that point on the last epoch that + * legacy V2 could lock into the pool is fixed at + * `gate.effectiveEpoch() - 1`, which the policy needs as an immutable + * scan bound. + * + * The policy is stateless: it re-derives every seller's cumulative + * locked amount from EmissionsV2/V1 state, releases `RELEASE_BPS` of + * it immediately and returns zero for sellers the + * wash-trading registry has proven to be wash traders. + * + * Idempotent: every step checks chain state and skips what already + * landed; a rerun with both in place exits with nothing to do. + * + * Signers: this script never reads a private key. Each broadcast names the + * address it acts as; Foundry resolves the wallet from the command line. + * + * Required env: + * DEPLOYER ANTSToken owner address (whitelists the pool). + * SELLER_REWARDS_POOL_OWNER Owner address of the deployed + * AntseedSellerRewardsPool. + * ANTSEED_REGISTRY Legacy AntseedRegistry address. + * EXPECTED_ANTS_TOKEN Deployed ANTSToken. + * LEGACY_EMISSIONS_V2 Deployed legacy AntseedEmissionsV2 (the + * contract that locked rewards into the pool). + * USAGE_ACCOUNTING AntseedUsageAccounting from M001 (reads the + * gate's effective epoch). + * WASH_TRADING_REGISTRY Deployed AntseedWashTradingRegistry. The CLI + * defaults it to the activated M001 ledger's + * washTradingRegistry address. + * + * Optional env: + * LAST_LOCKED_EPOCH If set, must equal `effectiveEpoch - 1`. + * RELEASE_BPS Share of cumulative locked rewards released. + * Default 1000 (10%). + * + * Usage (prefer `pnpm contracts:deploy -- M002 ...`): + * cd packages/contracts + * source .env + * forge script script/migrations/M002LegacySellerClaims/Install.s.sol:M002InstallLegacySellerClaims \ + * --rpc-url $BASE_MAINNET_RPC_URL \ + * --account antseed-owner --account pool-owner \ + * --broadcast \ + * --via-ir + */ +contract M002InstallLegacySellerClaims is Script { + uint256 public constant DEFAULT_RELEASE_BPS = 1000; + + struct Config { + address tokenOwner; + address poolOwner; + address registry; + address antsToken; + address legacyEmissionsV2; + address usageAccounting; + address washTradingRegistry; + uint256 lastEpochOverride; // 0 = derive from gate.effectiveEpoch() - 1 + uint256 releaseBps; + } + + function run() external returns (address) { + return runWith( + Config({ + tokenOwner: vm.envAddress("DEPLOYER"), + poolOwner: vm.envAddress("SELLER_REWARDS_POOL_OWNER"), + registry: vm.envAddress("ANTSEED_REGISTRY"), + antsToken: vm.envAddress("EXPECTED_ANTS_TOKEN"), + legacyEmissionsV2: vm.envAddress("LEGACY_EMISSIONS_V2"), + usageAccounting: vm.envAddress("USAGE_ACCOUNTING"), + washTradingRegistry: vm.envAddress("WASH_TRADING_REGISTRY"), + lastEpochOverride: vm.envOr("LAST_LOCKED_EPOCH", uint256(0)), + releaseBps: vm.envOr("RELEASE_BPS", DEFAULT_RELEASE_BPS) + }) + ); + } + + function runWith(Config memory cfg) public returns (address) { + IAntseedRegistryView registry = IAntseedRegistryView(cfg.registry); + IANTSTokenWhitelist token = IANTSTokenWhitelist(cfg.antsToken); + ILegacyEmissionsV2View v2 = ILegacyEmissionsV2View(cfg.legacyEmissionsV2); + + // ── Starting-state guards (all view; nothing is sent if any fails) ── + require(registry.antsToken() == address(token), "EXPECTED_ANTS_TOKEN is not the registry's ANTS token"); + require( + registry.emissions() == cfg.usageAccounting, + "M001 has not activated: registry.emissions() is not UsageAccounting" + ); + require(registry.emissions() != address(v2), "registry.emissions() still resolves to legacy EmissionsV2"); + + address v1 = v2.legacyEmissions(); + require(v1 != address(0), "LEGACY_EMISSIONS_V2 has no legacyEmissions()"); + ISellerRewardsPoolAdmin pool = ISellerRewardsPoolAdmin(v2.sellerRewardsPool()); + require(address(pool) != address(0), "legacy EmissionsV2 has no seller rewards pool"); + require(pool.owner() == cfg.poolOwner, "SELLER_REWARDS_POOL_OWNER is not the pool owner"); + + uint256 effectiveEpoch = + IEmissionsGateView(IUsageAccountingView(cfg.usageAccounting).emissionsGate()).effectiveEpoch(); + require(effectiveEpoch > 0, "gate effective epoch must be positive"); + uint256 lastEpoch = cfg.lastEpochOverride == 0 ? effectiveEpoch - 1 : cfg.lastEpochOverride; + require(lastEpoch >= v2.MIGRATION_EPOCH(), "last locked epoch precedes the V2 migration epoch"); + require(lastEpoch < effectiveEpoch, "LAST_LOCKED_EPOCH must precede the gate's effective epoch"); + require(lastEpoch == effectiveEpoch - 1, "LAST_LOCKED_EPOCH must equal the gate's effective epoch minus one"); + + require(cfg.washTradingRegistry != address(0), "WASH_TRADING_REGISTRY not set"); + require(cfg.washTradingRegistry.code.length != 0, "WASH_TRADING_REGISTRY has no code"); + + bool needsWhitelist = !token.transfersEnabled() && !token.transferWhitelist(address(pool)); + address existingPolicy = pool.sellerClaimPolicy(); + + console.log("=== AntSeed Legacy Seller Claims (M002) ==="); + console.log("SellerRewardsPool: ", address(pool)); + console.log("Pool total locked: ", pool.totalLockedRewards()); + console.log("Pool whitelisted: ", !needsWhitelist); + console.log("Legacy EmissionsV2: ", address(v2)); + console.log("Legacy EmissionsV1: ", v1); + console.log("Migration epoch: ", v2.MIGRATION_EPOCH()); + console.log("Last locked epoch: ", lastEpoch); + console.log("Release bps: ", cfg.releaseBps); + console.log("Wash-trading registry: ", cfg.washTradingRegistry); + + if (!needsWhitelist && existingPolicy != address(0)) { + console.log(""); + console.log("Nothing to do: pool is whitelisted and already has a claim policy:", existingPolicy); + return existingPolicy; + } + + // ── Step 1 (token owner): let the pool send ANTS ── + if (needsWhitelist) { + require(token.owner() == cfg.tokenOwner, "DEPLOYER is not the ANTSToken owner"); + vm.startBroadcast(cfg.tokenOwner); + token.setTransferWhitelist(address(pool), true); + vm.stopBroadcast(); + console.log(""); + console.log("Whitelisted SellerRewardsPool on ANTSToken."); + } + + // ── Step 2 (pool owner): deploy + install the claim policy ── + address policyAddress = existingPolicy; + if (existingPolicy == address(0)) { + vm.startBroadcast(cfg.poolOwner); + AntseedLegacySellerClaimPolicy policy = + new AntseedLegacySellerClaimPolicy(address(v2), lastEpoch, cfg.releaseBps, cfg.washTradingRegistry); + pool.setSellerClaimPolicy(address(policy)); + vm.stopBroadcast(); + policyAddress = address(policy); + require(address(policy.v1()) == v1, "post-check failed: policy v1 mismatch"); + } else { + console.log(""); + console.log("Pool already has a claim policy; keeping it:", existingPolicy); + } + + require(pool.sellerClaimPolicy() == policyAddress, "post-check failed: claim policy not set"); + require( + token.transfersEnabled() || token.transferWhitelist(address(pool)), + "post-check failed: pool cannot transfer ANTS" + ); + + console.log(""); + console.log("=== M002 complete ==="); + console.log("LegacySellerClaimPolicy:", policyAddress); + return policyAddress; + } +} diff --git a/packages/contracts/script/migrations/M002LegacySellerClaims/README.md b/packages/contracts/script/migrations/M002LegacySellerClaims/README.md new file mode 100644 index 000000000..2b7294fff --- /dev/null +++ b/packages/contracts/script/migrations/M002LegacySellerClaims/README.md @@ -0,0 +1,149 @@ +# M002 — Legacy seller claims + +One broadcast, two signers, run only after M001 is `active`: + +```bash +pnpm contracts:deploy -- M002 --network --dry-run +pnpm contracts:deploy -- M002 --network --broadcast \ + --signer deployer= --signer sellerRewardsPoolOwner= +``` + +The CLI reads live chain state and finishes whatever is missing. Rerunning is +safe: both steps check on-chain state first and an `active` pool is a no-op. + +## Why + +Legacy EmissionsV2 routed rewards of sellers without an unlock policy into +`AntseedSellerRewardsPool`. Claiming from that pool is blocked twice: + +| Blocker | Revert | Fixed by | +| --- | --- | --- | +| `pool.sellerClaimPolicy == 0` | `NoSellerClaimPolicy` | pool owner installs `AntseedLegacySellerClaimPolicy` | +| pool not on `ANTSToken.transferWhitelist` while transfers are disabled | `TransfersNotEnabled` | token owner whitelists the pool | + +M001 pins the pool to a registry facade so *locking* keeps working after the +flip, but leaves both blockers in place on purpose; this migration removes them. + +## What gets deployed + +`AntseedLegacySellerClaimPolicy(v2, lastEpoch, releaseBps, washTradingRegistry)` + +- `v2` — the legacy EmissionsV2 that locked into the pool (read from + `AntseedLegacyEmissionsEscrow.legacyEmissions()`); `v1` is derived from + `v2.legacyEmissions()` because V2 merges V1 points for epochs ≤ its + migration epoch. +- `lastEpoch = gate.effectiveEpoch() − 1` — the last epoch legacy V2 could ever + lock. Immutable; that is why M002 must wait for M001 to activate. +- `releaseBps` — `RELEASE_BPS`, default 1000 (10% of each seller's cumulative locked rewards). +- `washTradingRegistry` — `WASH_TRADING_REGISTRY`; the CLI defaults it to the + `washTradingRegistry` address recorded in the activated M001 deployment ledger. Proven wash traders + (`isProvenWashTrader`) can claim nothing. The registry address is immutable; + the policy has no owner, manual seller flags, or administrative setters. + +The policy is a pure view from the pool's perspective. It reconstructs a +seller's cumulative locked amount from V2 claim flags and V2/V1 points, so +`cumulative − locked` is what the seller already withdrew; nothing is +double-counted across claims and nothing needs storage. + +## Required accounting assumptions + +This rollout assumes the pool has never paid out and **all V2 seller rewards +for each pool participant have gone, and continue to go, into this same pool**. +Mixing direct and locked V2 payouts for one seller is unsupported: their claim +flags are indistinguishable and would reduce the reconstructed withdrawal +allowance. Direct-only addresses with no pool deposits, buyer payouts, and +rewards claimed directly through V1 do not affect another seller's allowance. + +Keep pool participants ineligible for direct V2 payouts, direct-only addresses +out of the pool, and V2's rewards-pool address unchanged, including for late +claims after cutover. M002 does not enforce those future owner actions. +Removing mixed-payout tests does not add mixed-payout support. + +Scan epochs **0 through `effectiveEpoch − 1`**, not just from `MIGRATION_EPOCH`: +old rewards earned under V1 can be claimed later through V2 into the pool. +`LAST_LOCKED_EPOCH`, if supplied, must equal that exact upper bound. + +Before mainnet broadcast: + +- Recheck V2's unlock-policy and pool-address history, distinguishing seller + claims from buyer claims; verify no mixed seller histories or other pools. +- Check pool claim history and reconcile recorded deposits with each seller's + locked balance and the pool total. A missing policy today alone does not + prove there were no withdrawals in the past. +- Run the M001 → M002 fork rehearsal, then a dry run against the actual active + M001 deployment. Check the real wash registry (the default fork uses a stub), + signer ownership, token transfer permission, and release parameters. +- Verify `RELEASE_BPS=1000`: exactly 10% of each seller's cumulative locked + rewards, less prior withdrawals. This is not a 10/65 rescaling of the legacy + emission bucket. For example, 1,000 ANTS cumulatively locked permits 100 ANTS + in total releases; repeating a claim cannot release another 10%. + +These checks establish the deployment preconditions; they are not an audit +guarantee. Recheck them immediately before broadcast because owners can change +configuration after a rehearsal. + +### Mainnet history check (2026-09-03) + +At Base block `50,827,725`, the pool had 212 deposits across 82 sellers, zero +withdrawal events, and no claim policy had ever been installed. Its token +balance and total locked balance both matched deposits: 27,153,751.852988306343576944 ANTS. + +V2's migration epoch is **4**. The [epoch-3 claim](https://basescan.org/tx/0xccbd262b981b9c9674c05414505386757e74fc258c034ea7356db32e64dbf7ef) +locked 14,821.253507455354316010 ANTS through V2. Starting the scan at epoch 4 +would omit that real deposit; keep the scan starting at 0. + +The broader claim that V2 never paid directly is false: transaction traces +show 17 direct seller payouts to `0x1f228613116E2d08014DfdCC198377C8dedf18C9` +(DiemStakingProxy), the only address enabled in the unlock-policy history. +For example, see [this direct proxy payout](https://basescan.org/tx/0x4c928c2e6c54c86e0a7cc07f5746b14c8e80dfcbacc9f131f7ad48625441561d). +That is separate from withdrawals from the seller rewards pool. + +All 82 pool sellers' reconstructed cumulative rewards matched their locked +balances exactly. None had a direct V2 payout; the direct-only proxy had zero +pool balance. This verifies the no-mixed-history requirement at that block, +not the stronger (incorrect) claim that V2 never made any direct payouts. + +## Signers + +| `--signer` role | Must be | Sends | +| --- | --- | --- | +| `deployer` | `ANTSToken.owner()` | `setTransferWhitelist(pool, true)` (skipped if transfers are enabled or already whitelisted) | +| `sellerRewardsPoolOwner` | `AntseedSellerRewardsPool.owner()` | policy deployment + `setSellerClaimPolicy` | + +Both signers are always named; the token owner is only *verified* when the +whitelist call is actually needed. Dry runs simulate each role as its live +owner and need no wallet. + +## States + +| State | Meaning | +| --- | --- | +| `ready` | M001 active, pool found, at least one of {whitelist, policy} missing | +| `active` | pool can send ANTS and has the recorded claim policy | +| `not-applicable` | the legacy emissions contract has no rewards pool (V1-only testnets); nothing to do | +| `invalid` | M001 not active, escrow missing, effective epoch ≤ migration epoch, or the pool carries a claim policy this ledger did not install | + +## Rehearsal + +`--fork-test` on Base mainnet runs the full M001 rehearsal (deploy, advance +past the boundary, cutover) on a disposable Anvil fork and then applies M002 +on top of the records M001 wrote, checking that a second apply is a no-op. +M002 declares M001 as its prerequisite; the shared deployment framework owns +fork startup, prerequisite ordering, temporary records, and cleanup. M002's +rehearsal hook only applies M002 and checks its results. Ordinary dry runs and +broadcasts still require M001 to have been activated separately. + +The 2026-09-03 rehearsal at fork block `50,571,469` passed M001 deploy/cutover, +M002 dry-run/install, and idempotent reruns. A real pool seller's forked claim +paid the configured share once; repeat claims reverted. +The wash-registry stub does not validate production wash-trading data. +That rehearsal predates removal of vesting and owner controls; rerun it with +the current policy before broadcasting. + +## After + +- Sellers call `AntseedSellerRewardsPool.claim(recipient)`; `NothingToClaim` + means no additional rewards are claimable or the seller is a proven wash trader. +- The configured release share is available immediately, less prior withdrawals. + The policy itself cannot be reconfigured. The existing pool owner retains + the pool's `setSellerClaimPolicy` authority to replace the entire policy. diff --git a/packages/contracts/test/AntseedLegacySellerClaimPolicy.t.sol b/packages/contracts/test/AntseedLegacySellerClaimPolicy.t.sol new file mode 100644 index 000000000..1f847ecba --- /dev/null +++ b/packages/contracts/test/AntseedLegacySellerClaimPolicy.t.sol @@ -0,0 +1,494 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { ANTSToken } from "../core/ANTSToken.sol"; +import { AntseedRegistry } from "../core/AntseedRegistry.sol"; +import { AntseedEmissions } from "../legacy/AntseedEmissions.sol"; +import { AntseedEmissionsV2 } from "../legacy/AntseedEmissionsV2.sol"; +import { AntseedSellerRewardsPool } from "../rewards/AntseedSellerRewardsPool.sol"; +import { AntseedSellerUnlockPolicy } from "../policies/AntseedSellerUnlockPolicy.sol"; +import { AntseedLegacySellerClaimPolicy } from "../policies/AntseedLegacySellerClaimPolicy.sol"; +import { IAntseedSellerClaimPolicy } from "../interfaces/IAntseedSellerClaimPolicy.sol"; +import { IAntseedWashTradingStatus } from "../interfaces/IAntseedWashTradingStatus.sol"; + +contract MockWashTradingStatus is IAntseedWashTradingStatus { + mapping(address => bool) public wash; + + function set(address seller, bool value) external { + wash[seller] = value; + } + + function isProvenWashTrader(address seller) external view returns (bool) { + return wash[seller]; + } +} + +contract MockDepositsForClaimPolicy { + function getOperator(address buyer) external pure returns (address) { + return buyer; + } +} + +contract AntseedLegacySellerClaimPolicyTest is Test { + uint256 constant INITIAL_EMISSION = 1000 ether; + uint256 constant EPOCH_DURATION = 1 weeks; + uint256 constant RELEASE_BPS = 1000; // 10% + + ANTSToken token; + AntseedRegistry registry; + AntseedEmissions legacy; + AntseedEmissionsV2 v2; + AntseedSellerRewardsPool pool; + AntseedSellerUnlockPolicy unlockPolicy; + MockWashTradingStatus washRegistry; + + address seller1 = address(0x10); + address seller2 = address(0x20); + + function setUp() public { + vm.warp(1_700_000_000); + + token = new ANTSToken(); + registry = new AntseedRegistry(); + registry.setChannels(address(this)); + registry.setDeposits(address(new MockDepositsForClaimPolicy())); + registry.setAntsToken(address(token)); + registry.setProtocolReserve(address(0x50)); + registry.setTeamWallet(address(0x51)); + + legacy = new AntseedEmissions(address(registry), INITIAL_EMISSION, EPOCH_DURATION); + registry.setEmissions(address(legacy)); + token.setRegistry(address(registry)); + + // V1 points in epoch 0 and in the migration epoch (4). + legacy.accrueSellerPoints(seller1, 100); + _warpToEpoch(4); + legacy.accrueSellerPoints(seller1, 100); + + pool = new AntseedSellerRewardsPool(address(registry)); + unlockPolicy = new AntseedSellerUnlockPolicy(); + v2 = new AntseedEmissionsV2(address(registry), address(legacy), address(pool)); + v2.setSellerUnlockPolicy(address(unlockPolicy)); + registry.setEmissions(address(v2)); + token.setTransferWhitelist(address(pool), true); + + washRegistry = new MockWashTradingStatus(); + + // V2 points in migration epoch and the next one. + v2.accrueSellerPoints(seller1, 100); + v2.accrueSellerPoints(seller2, 200); + _warpToEpoch(5); + v2.accrueSellerPoints(seller1, 300); + v2.accrueSellerPoints(seller2, 100); + _warpToEpoch(6); + } + + function _warpToEpoch(uint256 epoch) internal { + vm.warp(legacy.genesis() + EPOCH_DURATION * epoch + 1); + } + + function _epochs(uint256 epoch) internal pure returns (uint256[] memory epochs) { + epochs = new uint256[](1); + epochs[0] = epoch; + } + + function _epochs(uint256 firstEpoch, uint256 secondEpoch) internal pure returns (uint256[] memory epochs) { + epochs = new uint256[](2); + epochs[0] = firstEpoch; + epochs[1] = secondEpoch; + } + + function _deployPolicy(uint256 lastEpoch) internal returns (AntseedLegacySellerClaimPolicy policy) { + policy = new AntseedLegacySellerClaimPolicy(address(v2), lastEpoch, RELEASE_BPS, address(washRegistry)); + pool.setSellerClaimPolicy(address(policy)); + } + + function _expectedReward(uint256 epoch, uint256 sellerPoints, uint256 totalSellerPoints) + internal + view + returns (uint256) + { + (uint256 sellerShare,,,, uint256 maxSellerShare,,) = v2.epochParams(epoch); + uint256 sellerBudget = (v2.getEpochEmission(epoch) * sellerShare) / 100; + uint256 reward = (sellerPoints * sellerBudget) / totalSellerPoints; + uint256 maxReward = (sellerBudget * maxSellerShare) / 100; + return reward > maxReward ? maxReward : reward; + } + + // ───────────────────────────────────────────────────────────────────── + + function test_cumulativeLockedMirrorsV2Claim() public { + AntseedLegacySellerClaimPolicy policy = _deployPolicy(10); + + vm.prank(seller1); + v2.claimSellerEmissions(_epochs(4, 5)); + + uint256 locked = pool.lockedRewards(seller1); + assertGt(locked, 0); + assertEq(policy.cumulativeLocked(seller1), locked, "policy must reproduce pool locked amount exactly"); + + // migration epoch: V1 100 + V2 100 user, V1 100 + V2 300 total -> capped at 50% + uint256 e4 = _expectedReward(4, 200, 400); + uint256 e5 = _expectedReward(5, 300, 400); + assertEq(locked, e4 + e5); + } + + function test_claimReleasesConfiguredShareOnce() public { + AntseedLegacySellerClaimPolicy policy = _deployPolicy(10); + assertEq(policy.entitledOf(1000 ether), 100 ether); + + vm.prank(seller1); + v2.claimSellerEmissions(_epochs(4, 5)); + uint256 locked = pool.lockedRewards(seller1); + uint256 expected = locked / 10; + + assertEq(policy.claimableSellerRewards(seller1, locked), expected); + + vm.prank(seller1); + pool.claim(seller1); + assertEq(token.balanceOf(seller1), expected); + assertEq(pool.lockedRewards(seller1), locked - expected); + + // Nothing more to claim: released == entitled. + assertEq(policy.claimableSellerRewards(seller1, pool.lockedRewards(seller1)), 0); + vm.prank(seller1); + vm.expectRevert(AntseedSellerRewardsPool.NothingToClaim.selector); + pool.claim(seller1); + } + + function test_claimTracksNewlyLockedEpochs() public { + AntseedLegacySellerClaimPolicy policy = _deployPolicy(10); + + vm.prank(seller1); + v2.claimSellerEmissions(_epochs(4)); + vm.prank(seller1); + pool.claim(seller1); + uint256 firstPaid = token.balanceOf(seller1); + + // Lock another epoch later; only its releaseBps share becomes claimable. + vm.prank(seller1); + v2.claimSellerEmissions(_epochs(5)); + uint256 e5 = _expectedReward(5, 300, 400); + uint256 cumulative = policy.cumulativeLocked(seller1); + uint256 entitled = (cumulative * RELEASE_BPS) / 10_000; + assertEq(policy.claimableSellerRewards(seller1, pool.lockedRewards(seller1)), entitled - firstPaid); + assertApproxEqAbs(entitled - firstPaid, (e5 * RELEASE_BPS) / 10_000, 1); + } + + function test_provenWashTraderCannotClaim() public { + AntseedLegacySellerClaimPolicy policy = _deployPolicy(10); + + vm.prank(seller1); + v2.claimSellerEmissions(_epochs(4, 5)); + uint256 locked = pool.lockedRewards(seller1); + assertGt(policy.claimableSellerRewards(seller1, locked), 0); + + washRegistry.set(seller1, true); + assertTrue(policy.isWashTrader(seller1)); + assertEq(policy.claimableSellerRewards(seller1, locked), 0); + + vm.prank(seller1); + vm.expectRevert(AntseedSellerRewardsPool.NothingToClaim.selector); + pool.claim(seller1); + + // Funds remain locked in the pool. + assertEq(pool.lockedRewards(seller1), locked); + } + + function test_preMigrationEpochClaimedThroughV2Counts() public { + AntseedLegacySellerClaimPolicy policy = _deployPolicy(10); + + vm.prank(seller1); + v2.claimSellerEmissions(_epochs(0)); + uint256 locked = pool.lockedRewards(seller1); + assertGt(locked, 0); + assertEq(policy.cumulativeLocked(seller1), locked); + } + + function test_preMigrationOnlyRewardsCanBeWithdrawnExactlyOnce() public { + AntseedLegacySellerClaimPolicy policy = _deployPolicy(5); + uint256 deposited = _lockEpochAndMeasureDeposit(0); + uint256 entitlement = (deposited * RELEASE_BPS) / 10_000; + + assertTrue(v2.sellerEpochClaimed(seller1, 0)); + assertFalse(legacy.sellerEpochClaimed(seller1, 0)); + assertEq(policy.cumulativeLocked(seller1), deposited); + assertEq(policy.claimableSellerRewards(seller1, deposited), entitlement); + + vm.prank(seller1); + pool.claim(seller1); + _assertPoolPayout(deposited, entitlement); + _assertNothingMoreToWithdraw(policy, seller1); + + _warpToEpoch(7); + _assertNothingMoreToWithdraw(policy, seller2); + assertEq(policy.cumulativeLocked(seller1), deposited); + _assertPoolPayout(deposited, entitlement); + } + + function test_preMigrationAndNewEpochBatchCannotBeWithdrawnTwice() public { + AntseedLegacySellerClaimPolicy policy = _deployPolicy(5); + + vm.prank(seller1); + v2.claimSellerEmissions(_epochs(0, 5)); + uint256 deposited = token.balanceOf(address(pool)); + uint256 entitlement = (deposited * RELEASE_BPS) / 10_000; + + assertGt(deposited, 0); + assertEq(pool.lockedRewards(seller1), deposited); + assertTrue(v2.sellerEpochClaimed(seller1, 0)); + assertTrue(v2.sellerEpochClaimed(seller1, 5)); + assertEq(policy.cumulativeLocked(seller1), deposited); + + vm.prank(seller1); + pool.claim(seller2); + assertEq(token.balanceOf(seller1), 0); + assertEq(token.balanceOf(seller2), entitlement); + assertEq(pool.lockedRewards(seller1), deposited - entitlement); + assertEq(pool.totalLockedRewards(), deposited - entitlement); + assertEq(token.balanceOf(address(pool)), deposited - entitlement); + _assertNothingMoreToWithdraw(policy, seller1); + + vm.prank(seller1); + v2.claimSellerEmissions(_epochs(0, 5)); + assertEq(policy.cumulativeLocked(seller1), deposited); + _warpToEpoch(7); + _assertNothingMoreToWithdraw(policy, seller2); + assertEq(token.balanceOf(seller2), entitlement); + } + + function test_oldEpochClaimedAfterWithdrawalReleasesOnlyItsAdditionalShare() public { + AntseedLegacySellerClaimPolicy policy = _deployPolicy(5); + uint256 firstDeposit = _lockEpochAndMeasureDeposit(5); + uint256 firstEntitlement = (firstDeposit * RELEASE_BPS) / 10_000; + + vm.prank(seller1); + pool.claim(seller1); + _assertPoolPayout(firstDeposit, firstEntitlement); + _assertNothingMoreToWithdraw(policy, seller1); + + uint256 oldEpochDeposit = _lockEpochAndMeasureDeposit(0); + uint256 totalDeposited = firstDeposit + oldEpochDeposit; + uint256 totalEntitlement = (totalDeposited * RELEASE_BPS) / 10_000; + assertEq(policy.cumulativeLocked(seller1), totalDeposited); + assertEq( + policy.claimableSellerRewards(seller1, pool.lockedRewards(seller1)), totalEntitlement - firstEntitlement + ); + + vm.prank(seller1); + pool.claim(seller1); + _assertPoolPayout(totalDeposited, totalEntitlement); + _assertNothingMoreToWithdraw(policy, seller1); + } + + function test_migrationStartSketchOverpaysWithOnlyPreMigrationRewards() public { + _assertMigrationStartSketchOverpays(false); + } + + function test_migrationStartSketchOverpaysWithOldAndNewRewards() public { + _assertMigrationStartSketchOverpays(true); + } + + function _lockEpochAndMeasureDeposit(uint256 epoch) internal returns (uint256 deposited) { + unlockPolicy.setSellerEligibility(seller1, false); + uint256 poolBalanceBefore = token.balanceOf(address(pool)); + uint256 lockedBefore = pool.lockedRewards(seller1); + uint256 totalLockedBefore = pool.totalLockedRewards(); + uint256 walletBefore = token.balanceOf(seller1); + + vm.prank(seller1); + v2.claimSellerEmissions(_epochs(epoch)); + + deposited = token.balanceOf(address(pool)) - poolBalanceBefore; + assertGt(deposited, 0); + assertEq(pool.lockedRewards(seller1) - lockedBefore, deposited); + assertEq(pool.totalLockedRewards() - totalLockedBefore, deposited); + assertEq(token.balanceOf(seller1), walletBefore); + } + + function _assertPoolPayout(uint256 deposited, uint256 paid) internal view { + assertEq(token.balanceOf(seller1), paid); + assertEq(pool.lockedRewards(seller1), deposited - paid); + assertEq(pool.totalLockedRewards(), deposited - paid); + assertEq(token.balanceOf(address(pool)), deposited - paid); + } + + function _assertNothingMoreToWithdraw(AntseedLegacySellerClaimPolicy policy, address recipient) internal { + uint256 lockedBefore = pool.lockedRewards(seller1); + uint256 totalLockedBefore = pool.totalLockedRewards(); + uint256 poolBalanceBefore = token.balanceOf(address(pool)); + uint256 recipientBalanceBefore = token.balanceOf(recipient); + + assertEq(policy.claimableSellerRewards(seller1, lockedBefore), 0); + vm.prank(seller1); + vm.expectRevert(AntseedSellerRewardsPool.NothingToClaim.selector); + pool.claim(recipient); + + assertEq(pool.lockedRewards(seller1), lockedBefore); + assertEq(pool.totalLockedRewards(), totalLockedBefore); + assertEq(token.balanceOf(address(pool)), poolBalanceBefore); + assertEq(token.balanceOf(recipient), recipientBalanceBefore); + } + + function _assertMigrationStartSketchOverpays(bool includeNewEpoch) internal { + MigrationStartClaimPolicyFixture sketch = new MigrationStartClaimPolicyFixture(v2, legacy, 5, RELEASE_BPS); + pool.setSellerClaimPolicy(address(sketch)); + uint256 deposited = _lockEpochAndMeasureDeposit(0); + uint256 newEpochDeposit = includeNewEpoch ? _lockEpochAndMeasureDeposit(5) : 0; + deposited += newEpochDeposit; + + assertEq(sketch.cumulativeLocked(seller1), newEpochDeposit); + uint256 entitlement = (deposited * RELEASE_BPS) / 10_000; + vm.prank(seller1); + pool.claim(seller1); + _assertPoolPayout(deposited, entitlement); + + uint256 secondClaim = sketch.claimableSellerRewards(seller1, pool.lockedRewards(seller1)); + assertGt(secondClaim, 0, "migration-start sketch incorrectly allows another withdrawal"); + vm.prank(seller1); + pool.claim(seller1); + _assertPoolPayout(deposited, entitlement + secondClaim); + assertGt(token.balanceOf(seller1), entitlement, "actual transfers exceed the configured release share"); + } + + function test_unlockedSellerNeverUnderCounts() public { + // A seller eligible for direct mint has sellerEpochClaimed set but nothing in the pool. + unlockPolicy.setSellerEligibility(seller2, true); + AntseedLegacySellerClaimPolicy policy = _deployPolicy(10); + + vm.prank(seller2); + v2.claimSellerEmissions(_epochs(4)); + assertEq(pool.lockedRewards(seller2), 0); + assertGt(token.balanceOf(seller2), 0); + assertEq(policy.claimableSellerRewards(seller2, 0), 0); + + uint256 deposited = _lockEpochAndMeasureDeposit(4); + uint256 entitlement = (deposited * RELEASE_BPS) / 10_000; + assertEq(policy.cumulativeLocked(seller1), deposited); + vm.prank(seller1); + pool.claim(seller1); + _assertPoolPayout(deposited, entitlement); + _assertNothingMoreToWithdraw(policy, seller1); + } + + function test_claimableNeverExceedsLocked() public { + AntseedLegacySellerClaimPolicy policy = + new AntseedLegacySellerClaimPolicy(address(v2), 10, 10_000, address(washRegistry)); + pool.setSellerClaimPolicy(address(policy)); + + vm.prank(seller1); + v2.claimSellerEmissions(_epochs(4, 5)); + uint256 locked = pool.lockedRewards(seller1); + assertEq(policy.claimableSellerRewards(seller1, locked), locked); + assertEq(policy.claimableSellerRewards(seller1, locked / 3), locked / 3); + } + + function test_constructorValidation() public { + vm.expectRevert(AntseedLegacySellerClaimPolicy.InvalidValue.selector); + new AntseedLegacySellerClaimPolicy(address(v2), 10, 0, address(washRegistry)); + vm.expectRevert(AntseedLegacySellerClaimPolicy.InvalidValue.selector); + new AntseedLegacySellerClaimPolicy(address(v2), 10, 10_001, address(washRegistry)); + vm.expectRevert(AntseedLegacySellerClaimPolicy.InvalidValue.selector); + new AntseedLegacySellerClaimPolicy(address(v2), 3, 1000, address(washRegistry)); + vm.expectRevert(AntseedLegacySellerClaimPolicy.InvalidAddress.selector); + new AntseedLegacySellerClaimPolicy(address(0), 10, 1000, address(washRegistry)); + vm.expectRevert(AntseedLegacySellerClaimPolicy.InvalidAddress.selector); + new AntseedLegacySellerClaimPolicy(address(v2), 10, 1000, address(0)); + vm.expectRevert(AntseedLegacySellerClaimPolicy.InvalidAddress.selector); + new AntseedLegacySellerClaimPolicy(address(v2), 10, 1000, address(0xDEAD)); + } + + function test_releaseDoesNotIncreaseWithTime() public { + AntseedLegacySellerClaimPolicy policy = _deployPolicy(5); + uint256 deposited = _lockEpochAndMeasureDeposit(4); + uint256 entitled = deposited / 10; + assertEq(policy.claimableSellerRewards(seller1, deposited), entitled); + + _warpToEpoch(100); + assertEq(policy.claimableSellerRewards(seller1, deposited), entitled); + vm.prank(seller1); + pool.claim(seller1); + _assertPoolPayout(deposited, entitled); + + _warpToEpoch(200); + _assertNothingMoreToWithdraw(policy, seller1); + } + + function test_policyHasNoAdministrativeSetters() public { + AntseedLegacySellerClaimPolicy policy = _deployPolicy(5); + (bool flagSucceeded,) = + address(policy).call(abi.encodeWithSignature("setSellerFlagged(address,bool)", seller1, true)); + (bool registrySucceeded,) = + address(policy).call(abi.encodeWithSignature("setWashTradingRegistry(address)", address(0))); + (bool ownershipSucceeded,) = + address(policy).call(abi.encodeWithSignature("transferOwnership(address)", seller1)); + assertFalse(flagSucceeded); + assertFalse(registrySucceeded); + assertFalse(ownershipSucceeded); + assertEq(address(policy.washTradingRegistry()), address(washRegistry)); + assertFalse(policy.isWashTrader(seller1)); + } + + function test_registryFailureBlocksClaims() public { + AntseedLegacySellerClaimPolicy policy = _deployPolicy(5); + uint256 deposited = _lockEpochAndMeasureDeposit(4); + vm.mockCallRevert( + address(washRegistry), + abi.encodeCall(IAntseedWashTradingStatus.isProvenWashTrader, (seller1)), + abi.encodeWithSignature("Error(string)", "registry unavailable") + ); + vm.prank(seller1); + vm.expectRevert("registry unavailable"); + pool.claim(seller1); + assertEq(address(policy.washTradingRegistry()), address(washRegistry)); + _assertPoolPayout(deposited, 0); + } +} + +contract MigrationStartClaimPolicyFixture is IAntseedSellerClaimPolicy { + uint256 public constant BPS = 10_000; + + AntseedEmissionsV2 public immutable v2; + AntseedEmissions public immutable v1; + uint256 public immutable firstEpoch; + uint256 public immutable lastEpoch; + uint256 public immutable releaseBps; + + constructor(AntseedEmissionsV2 emissionsV2, AntseedEmissions emissionsV1, uint256 lastEpoch_, uint256 releaseBps_) { + v2 = emissionsV2; + v1 = emissionsV1; + firstEpoch = v2.MIGRATION_EPOCH(); + lastEpoch = lastEpoch_; + releaseBps = releaseBps_; + } + + function cumulativeLocked(address seller) public view returns (uint256 total) { + uint256 migration = firstEpoch; + for (uint256 epoch = migration; epoch <= lastEpoch; epoch++) { + if (!v2.sellerEpochClaimed(seller, epoch)) continue; + uint256 userPoints = v2.userSellerPoints(seller, epoch); + uint256 totalPoints = v2.epochTotalSellerPoints(epoch); + if (epoch <= migration) { + userPoints += v1.userSellerPoints(seller, epoch); + totalPoints += v1.epochTotalSellerPoints(epoch); + } + if (userPoints == 0 || totalPoints == 0) continue; + (uint256 sellerShare,,,, uint256 maxSellerShare,,) = v2.epochParams(epoch); + uint256 sellerBudget = (v2.getEpochEmission(epoch) * sellerShare) / 100; + uint256 reward = (userPoints * sellerBudget) / totalPoints; + uint256 maxReward = (sellerBudget * maxSellerShare) / 100; + total += reward > maxReward ? maxReward : reward; + } + } + + function claimableSellerRewards(address seller, uint256 locked) external view returns (uint256) { + uint256 cumulative = cumulativeLocked(seller); + if (cumulative < locked) cumulative = locked; + uint256 released = cumulative - locked; + + uint256 entitled = (cumulative * releaseBps) / BPS; + return entitled > released ? entitled - released : 0; + } +} diff --git a/packages/contracts/test/migrations/M002LegacySellerClaims.t.sol b/packages/contracts/test/migrations/M002LegacySellerClaims.t.sol new file mode 100644 index 000000000..daab1e29f --- /dev/null +++ b/packages/contracts/test/migrations/M002LegacySellerClaims.t.sol @@ -0,0 +1,309 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { ANTSToken } from "../../core/ANTSToken.sol"; +import { AntseedRegistry } from "../../core/AntseedRegistry.sol"; +import { AntseedEmissions } from "../../legacy/AntseedEmissions.sol"; +import { AntseedEmissionsV2 } from "../../legacy/AntseedEmissionsV2.sol"; +import { AntseedSellerRewardsPool } from "../../rewards/AntseedSellerRewardsPool.sol"; +import { AntseedLegacyRewardsPoolRegistry } from "../../rewards/AntseedLegacyRewardsPoolRegistry.sol"; +import { AntseedLegacySellerClaimPolicy } from "../../policies/AntseedLegacySellerClaimPolicy.sol"; +import { M002InstallLegacySellerClaims } from "../../script/migrations/M002LegacySellerClaims/Install.s.sol"; + +contract MockDepositsForM002 { + function getOperator(address buyer) external pure returns (address) { + return buyer; + } +} + +contract MockWashRegistryForM002 { + mapping(address => bool) public wash; + + function set(address seller, bool value) external { + wash[seller] = value; + } + + function isProvenWashTrader(address seller) external view returns (bool) { + return wash[seller]; + } +} + +/// @dev Stand-ins for the M001 stack: only the reads M002 performs. +contract MockEmissionsGateForM002 { + uint256 public effectiveEpoch; + + constructor(uint256 effectiveEpoch_) { + effectiveEpoch = effectiveEpoch_; + } +} + +contract MockUsageAccountingForM002 { + address public emissionsGate; + + constructor(address gate) { + emissionsGate = gate; + } +} + +/** + * Drives Install.s.sol against a local V1 -> V2 -> pool stack in the state + * M001 leaves behind: registry.emissions() flipped to UsageAccounting, the + * pool pinned to its registry facade, and the pool NOT whitelisted on ANTS. + */ +contract M002LegacySellerClaimsTest is Test { + uint256 constant INITIAL_EMISSION = 1000 ether; + uint256 constant EPOCH_DURATION = 1 weeks; + uint256 constant EFFECTIVE_EPOCH = 6; + + ANTSToken token; + AntseedRegistry registry; + AntseedEmissions legacy; + AntseedEmissionsV2 v2; + AntseedSellerRewardsPool pool; + MockWashRegistryForM002 washRegistry; + MockUsageAccountingForM002 usageAccounting; + M002InstallLegacySellerClaims script; + + address tokenOwner = address(0xA0); + address poolOwner = address(0xA1); + address seller = address(0x10); + address washSeller = address(0x11); + + function setUp() public { + vm.warp(1_700_000_000); + + token = new ANTSToken(); + registry = new AntseedRegistry(); + registry.setChannels(address(this)); + registry.setDeposits(address(new MockDepositsForM002())); + registry.setAntsToken(address(token)); + registry.setProtocolReserve(address(0x50)); + registry.setTeamWallet(address(0x51)); + + legacy = new AntseedEmissions(address(registry), INITIAL_EMISSION, EPOCH_DURATION); + registry.setEmissions(address(legacy)); + token.setRegistry(address(registry)); + + // V2 migrates at epoch 4; sellers earn in epoch 4 and claim (locked) in epoch 6. + _warpToEpoch(4); + pool = new AntseedSellerRewardsPool(address(registry)); + v2 = new AntseedEmissionsV2(address(registry), address(legacy), address(pool)); + registry.setEmissions(address(v2)); + + v2.accrueSellerPoints(seller, 100); + v2.accrueSellerPoints(washSeller, 100); + _warpToEpoch(EFFECTIVE_EPOCH); + uint256[] memory epochs = new uint256[](1); + epochs[0] = 4; + vm.prank(seller); + v2.claimSellerEmissions(epochs); + vm.prank(washSeller); + v2.claimSellerEmissions(epochs); + assertGt(pool.lockedRewards(seller), 0, "rewards locked into the pool"); + + // M001 cutover: pin the pool at its facade, then flip registry.emissions(). + pool.setRegistry(address(new AntseedLegacyRewardsPoolRegistry(address(v2), address(token)))); + usageAccounting = new MockUsageAccountingForM002(address(new MockEmissionsGateForM002(EFFECTIVE_EPOCH))); + registry.setEmissions(address(usageAccounting)); + + pool.transferOwnership(poolOwner); + token.transferOwnership(tokenOwner); + + washRegistry = new MockWashRegistryForM002(); + washRegistry.set(washSeller, true); + + script = new M002InstallLegacySellerClaims(); + } + + function _warpToEpoch(uint256 epoch) internal { + vm.warp(legacy.genesis() + EPOCH_DURATION * epoch + 1); + } + + function _cfg() internal view returns (M002InstallLegacySellerClaims.Config memory) { + return M002InstallLegacySellerClaims.Config({ + tokenOwner: tokenOwner, + poolOwner: poolOwner, + registry: address(registry), + antsToken: address(token), + legacyEmissionsV2: address(v2), + usageAccounting: address(usageAccounting), + washTradingRegistry: address(washRegistry), + lastEpochOverride: 0, + releaseBps: 1000 + }); + } + + function _policy() internal view returns (AntseedLegacySellerClaimPolicy) { + return AntseedLegacySellerClaimPolicy(address(pool.sellerClaimPolicy())); + } + + // ───────────────────────────────────────────────────────────────────── + + function test_poolIsFrozenBeforeM002() public { + // No policy: claim reverts before touching the token. + vm.prank(seller); + vm.expectRevert(AntseedSellerRewardsPool.NoSellerClaimPolicy.selector); + pool.claim(seller); + + // Policy alone is not enough: the pool cannot send ANTS while transfers are disabled. + AntseedLegacySellerClaimPolicy policy = + new AntseedLegacySellerClaimPolicy(address(v2), 5, 1000, address(washRegistry)); + vm.prank(poolOwner); + pool.setSellerClaimPolicy(address(policy)); + vm.prank(seller); + vm.expectRevert(ANTSToken.TransfersNotEnabled.selector); + pool.claim(seller); + } + + function test_installsPolicyAndWhitelistsPool() public { + assertEq(script.DEFAULT_RELEASE_BPS(), 1000); + address policyAddress = script.runWith(_cfg()); + + AntseedLegacySellerClaimPolicy policy = _policy(); + assertEq(address(policy), policyAddress); + assertEq(address(policy.v2()), address(v2)); + assertEq(address(policy.v1()), address(legacy), "v1 derived from v2"); + assertEq(policy.migrationEpoch(), 4); + assertEq(policy.lastEpoch(), EFFECTIVE_EPOCH - 1, "last epoch = effective - 1"); + assertEq(policy.releaseBps(), 1000); + assertEq(address(policy.washTradingRegistry()), address(washRegistry)); + assertTrue(token.transferWhitelist(address(pool)), "pool whitelisted on ANTS"); + } + + function test_honestSellerClaimsReleasedShare_washTraderGetsNothing() public { + script.runWith(_cfg()); + + uint256 locked = pool.lockedRewards(seller); + uint256 expected = locked / 10; + vm.prank(seller); + pool.claim(seller); + assertEq(token.balanceOf(seller), expected, "released share paid out"); + assertEq(pool.lockedRewards(seller), locked - expected); + + // Nothing more this epoch: cumulative - locked == released. + vm.prank(seller); + vm.expectRevert(AntseedSellerRewardsPool.NothingToClaim.selector); + pool.claim(seller); + + vm.prank(washSeller); + vm.expectRevert(AntseedSellerRewardsPool.NothingToClaim.selector); + pool.claim(washSeller); + assertEq(pool.lockedRewards(washSeller), locked, "wash trader's rewards stay locked"); + } + + function test_rerunIsNoOp() public { + address first = script.runWith(_cfg()); + address second = script.runWith(_cfg()); + assertEq(second, first, "rerun keeps the installed policy"); + assertEq(address(pool.sellerClaimPolicy()), first); + } + + function test_resumesWhenOnlyWhitelistLanded() public { + vm.prank(tokenOwner); + token.setTransferWhitelist(address(pool), true); + address policyAddress = script.runWith(_cfg()); + assertEq(address(pool.sellerClaimPolicy()), policyAddress); + } + + function test_resumesWhenOnlyPolicyLanded() public { + AntseedLegacySellerClaimPolicy policy = + new AntseedLegacySellerClaimPolicy(address(v2), 5, 1000, address(washRegistry)); + vm.prank(poolOwner); + pool.setSellerClaimPolicy(address(policy)); + + address result = script.runWith(_cfg()); + assertEq(result, address(policy), "existing policy kept"); + assertTrue(token.transferWhitelist(address(pool))); + } + + function test_skipsWhitelistWhenTransfersEnabled() public { + vm.prank(tokenOwner); + token.enableTransfers(); + M002InstallLegacySellerClaims.Config memory cfg = _cfg(); + cfg.tokenOwner = address(0xDEAD); // never used when transfers are enabled + script.runWith(cfg); + assertFalse(token.transferWhitelist(address(pool))); + assertTrue(address(pool.sellerClaimPolicy()) != address(0)); + } + + function test_rejectsBeforeM001Activation() public { + registry.setEmissions(address(v2)); + vm.expectRevert("M001 has not activated: registry.emissions() is not UsageAccounting"); + script.runWith(_cfg()); + } + + function test_rejectsWrongPoolOwner() public { + M002InstallLegacySellerClaims.Config memory cfg = _cfg(); + cfg.poolOwner = address(0xB0B); + vm.expectRevert("SELLER_REWARDS_POOL_OWNER is not the pool owner"); + script.runWith(cfg); + } + + function test_rejectsWrongTokenOwner() public { + M002InstallLegacySellerClaims.Config memory cfg = _cfg(); + cfg.tokenOwner = address(0xB0B); + vm.expectRevert("DEPLOYER is not the ANTSToken owner"); + script.runWith(cfg); + } + + function test_rejectsWrongAntsToken() public { + M002InstallLegacySellerClaims.Config memory cfg = _cfg(); + cfg.antsToken = address(new ANTSToken()); + vm.expectRevert("EXPECTED_ANTS_TOKEN is not the registry's ANTS token"); + script.runWith(cfg); + } + + function test_rejectsLastEpochBeforeMigration() public { + // A gate whose effective epoch equals the V2 migration epoch leaves no lockable epoch. + usageAccounting = new MockUsageAccountingForM002(address(new MockEmissionsGateForM002(4))); + registry.setEmissions(address(usageAccounting)); + vm.expectRevert("last locked epoch precedes the V2 migration epoch"); + script.runWith(_cfg()); + } + + function test_rejectsLastEpochOverrideAtOrPastEffective() public { + M002InstallLegacySellerClaims.Config memory cfg = _cfg(); + cfg.lastEpochOverride = EFFECTIVE_EPOCH; + vm.expectRevert("LAST_LOCKED_EPOCH must precede the gate's effective epoch"); + script.runWith(cfg); + } + + function test_rejectsWashRegistryMissingOrWithoutCode() public { + M002InstallLegacySellerClaims.Config memory cfg = _cfg(); + cfg.washTradingRegistry = address(0); + vm.expectRevert("WASH_TRADING_REGISTRY not set"); + script.runWith(cfg); + + cfg.washTradingRegistry = address(0xDEAD); + vm.expectRevert("WASH_TRADING_REGISTRY has no code"); + script.runWith(cfg); + } + + function test_rejectsLastEpochOverrideThatOmitsLockedEpochs() public { + M002InstallLegacySellerClaims.Config memory cfg = _cfg(); + cfg.lastEpochOverride = EFFECTIVE_EPOCH - 2; + vm.expectRevert("LAST_LOCKED_EPOCH must equal the gate's effective epoch minus one"); + script.runWith(cfg); + assertEq(address(pool.sellerClaimPolicy()), address(0)); + assertFalse(token.transferWhitelist(address(pool))); + } + + function test_acceptsExactLastEpochOverride() public { + M002InstallLegacySellerClaims.Config memory cfg = _cfg(); + cfg.lastEpochOverride = EFFECTIVE_EPOCH - 1; + script.runWith(cfg); + assertEq(_policy().lastEpoch(), EFFECTIVE_EPOCH - 1); + } + + function test_optionalReleaseOverride() public { + M002InstallLegacySellerClaims.Config memory cfg = _cfg(); + cfg.releaseBps = 5000; + cfg.lastEpochOverride = EFFECTIVE_EPOCH - 1; + script.runWith(cfg); + AntseedLegacySellerClaimPolicy policy = _policy(); + assertEq(policy.releaseBps(), 5000); + assertEq(policy.lastEpoch(), EFFECTIVE_EPOCH - 1); + } +} diff --git a/scripts/deploy-contracts.test.mjs b/scripts/deploy-contracts.test.mjs index 34be9ca85..65d2600e1 100644 --- a/scripts/deploy-contracts.test.mjs +++ b/scripts/deploy-contracts.test.mjs @@ -22,6 +22,13 @@ import { validateM001Baseline, validateM001Options, } from './deployments/m001.mjs'; +import { + classifyM002, + DEFAULT_RELEASE_BPS, + migration as m002, + validateM002Baseline, + validateM002Options, +} from './deployments/m002.mjs'; import { writeJsonAtomic, writeJsonOnce } from './deployments/runtime/artifacts.mjs'; import { currentRelease, historyRecordExists, loadContext, readCheckpoint, validateArtifacts } from './deployments/runtime/ledger.mjs'; import { executePhases, runMigration } from './deployments/runtime/runner.mjs'; @@ -376,8 +383,8 @@ test('rejects missing and conflicting modes', () => { /Choose exactly one/, ); assert.throws( - () => parseDeployArgs(['M002', '--network', 'base-mainnet', '--dry-run']), - /Unknown deployment migration M002; available: M001/, + () => parseDeployArgs(['M009', '--network', 'base-mainnet', '--dry-run']), + /Unknown deployment migration M009; available: M001, M002/, ); }); @@ -433,7 +440,8 @@ test('reports incomplete network baselines before deployment', () => { test('registers migrations explicitly', () => { assert.equal(getDeploymentMigration('M001').id, 'M001'); - assert.deepEqual([...deploymentMigrations.keys()], ['M001']); + assert.deepEqual([...deploymentMigrations.keys()], ['M001', 'M002']); + assert.equal(getDeploymentMigration('M002').id, 'M002'); }); test('rejects invalid and duplicate migration registrations', () => { @@ -1216,3 +1224,212 @@ test('a dry run before the epoch boundary simulates instead of waiting', async ( assert.deepEqual(scriptRpcs, ['http://127.0.0.1:9998']); assert.equal(result.endStateVerified, false); }); + +// --------------------------------------------------------------------------- +// M002 — legacy seller claims +// --------------------------------------------------------------------------- + +const M002_ADDRESS = { + pool: '0x0000000000000000000000000000000000000010', + policy: '0x0000000000000000000000000000000000000011', + foreignPolicy: '0x0000000000000000000000000000000000000012', + zero: '0x0000000000000000000000000000000000000000', +}; + +function m002Observation(overrides = {}) { + return { + expected: { + registry: ADDRESS.registry, + antsToken: ADDRESS.ants, + usageAccounting: ADDRESS.usageAccounting, + recordedPolicy: null, + }, + registry: { antsToken: ADDRESS.ants, emissions: ADDRESS.usageAccounting }, + legacyEmissionsV2: ADDRESS.legacyEmissions, + token: { owner: ADDRESS.ants, transfersEnabled: false, poolWhitelisted: false }, + pool: { + address: M002_ADDRESS.pool, + owner: ADDRESS.channels, + sellerClaimPolicy: M002_ADDRESS.zero, + policyMatchesRecord: true, + }, + lastLockedEpochValid: true, + ...overrides, + }; +} + +test('classifies the M002 lifecycle', () => { + assert.equal(classifyM002(m002Observation()), 'ready'); + // Half-applied installs stay `ready` so the idempotent script finishes them. + assert.equal(classifyM002(m002Observation({ + token: { transfersEnabled: false, poolWhitelisted: true }, + })), 'ready'); + assert.equal(classifyM002(m002Observation({ + pool: { ...m002Observation().pool, sellerClaimPolicy: M002_ADDRESS.policy }, + })), 'ready'); + assert.equal(classifyM002(m002Observation({ + token: { transfersEnabled: false, poolWhitelisted: true }, + pool: { ...m002Observation().pool, sellerClaimPolicy: M002_ADDRESS.policy }, + })), 'active'); + // Globally enabled transfers make the whitelist unnecessary. + assert.equal(classifyM002(m002Observation({ + token: { transfersEnabled: true, poolWhitelisted: false }, + pool: { ...m002Observation().pool, sellerClaimPolicy: M002_ADDRESS.policy }, + })), 'active'); +}); + +test('M002 refuses to run before M001 activates or over a foreign policy', () => { + assert.equal(classifyM002(m002Observation({ + registry: { antsToken: ADDRESS.ants, emissions: ADDRESS.legacyEmissions }, + })), 'invalid', 'registry.emissions() still legacy'); + assert.equal(classifyM002(m002Observation({ pool: null })), 'not-applicable', 'V1-only legacy emissions, no pool'); + assert.equal(classifyM002(m002Observation({ legacyEmissionsV2: null })), 'invalid', 'escrow points at nothing'); + assert.equal(classifyM002(m002Observation({ lastLockedEpochValid: false })), 'invalid', 'effective epoch <= migration'); + assert.equal(classifyM002(m002Observation({ + pool: { ...m002Observation().pool, sellerClaimPolicy: M002_ADDRESS.foreignPolicy, policyMatchesRecord: false }, + })), 'invalid', 'a policy this ledger did not install'); +}); + +test('M002 supports the same networks and modes as M001', () => { + assert.doesNotThrow(() => validateM002Options({ network: 'base-sepolia', mode: 'dry-run' })); + assert.doesNotThrow(() => validateM002Options({ network: 'base-mainnet', mode: 'fork-test' })); + assert.throws(() => validateM002Options({ network: 'base-sepolia', mode: 'fork-test' }), /M002 supports/); + assert.throws(() => validateM002Options({ network: 'base-local', mode: 'dry-run' }), /M002 supports/); +}); + +test('M002 defaults to ten percent of cumulative locked rewards', () => { + assert.equal(DEFAULT_RELEASE_BPS, 1000); +}); + +test('M002 requires an activated M001 baseline', () => { + const contract = (address) => ({ address }); + const activated = { + network: 'base-mainnet', + contracts: { + registry: contract(ADDRESS.registry), + antsToken: contract(ADDRESS.ants), + emissions: contract(ADDRESS.usageAccounting), + usageAccounting: contract(ADDRESS.usageAccounting), + washTradingRegistry: contract(ADDRESS.channels), + legacyEmissionsEscrow: contract(ADDRESS.sellerRegistry), + }, + }; + assert.doesNotThrow(() => validateM002Baseline(activated)); + assert.equal(m002.expectedState(activated).washTradingRegistry, ADDRESS.channels); + assert.throws( + () => validateM002Baseline({ ...activated, contracts: { ...activated.contracts, emissions: contract(ADDRESS.legacyEmissions) } }), + /run M001 first/, + ); + const { washTradingRegistry, ...withoutWashTradingRegistry } = activated.contracts; + assert.throws(() => validateM002Baseline({ ...activated, contracts: withoutWashTradingRegistry }), /missing: washTradingRegistry/); +}); + +test('M002 observes the ledger wash registry without a PositionInit getter', async (t) => { + const responses = { + 'legacyEmissions()(address)': ADDRESS.legacyEmissions, + 'sellerRewardsPool()(address)': M002_ADDRESS.pool, + 'owner()(address)': ADDRESS.ants, + 'sellerClaimPolicy()(address)': M002_ADDRESS.zero, + 'totalLockedRewards()(uint256)': '1000', + 'emissionsGate()(address)': ADDRESS.channels, + 'effectiveEpoch()(uint256)': '22', + 'MIGRATION_EPOCH()(uint256)': '4', + 'antsToken()(address)': ADDRESS.ants, + 'emissions()(address)': ADDRESS.usageAccounting, + 'transfersEnabled()(bool)': 'false', + 'transferWhitelist(address)(bool)': 'false', + }; + mockCast(t, (args) => { + if (args[0] === 'chain-id') return '8453'; + if (args[0] === 'code') return '0x01'; + assert.equal(args[0], 'call'); + assert.ok(Object.hasOwn(responses, args[2]), `unexpected getter: ${args[2]}`); + return responses[args[2]]; + }); + const observation = await m002.observe({ + rpcUrl: 'http://127.0.0.1:0', + network: 'base-mainnet', + canonical: { chainId: 8453 }, + expected: { + registry: ADDRESS.registry, + antsToken: ADDRESS.ants, + usageAccounting: ADDRESS.usageAccounting, + legacyEmissionsEscrow: ADDRESS.sellerRegistry, + washTradingRegistry: ADDRESS.channels, + recordedPolicy: null, + }, + }); + assert.equal(observation.state, 'ready'); + assert.equal(observation.washTradingRegistry, ADDRESS.channels); +}); + +test('enforces M002 release invariants', () => { + const validate = (record) => m002.recordErrors(record).length === 0; + const verificationConfiguration = { + sellerRewardsPool: M002_ADDRESS.pool, + sellerClaimPolicy: M002_ADDRESS.policy, + poolCanTransfer: true, + lastLockedEpoch: 41, + releaseBps: 1000, + washTradingRegistry: ADDRESS.channels, + }; + const contracts = { legacySellerClaimPolicy: { address: M002_ADDRESS.policy } }; + + assert.equal(validate({ verificationConfiguration, contracts }), true); + assert.equal(validate({}), false, 'verificationConfiguration is required'); + assert.equal(validate({ verificationConfiguration }), false, 'the deployed policy must be recorded'); + assert.equal( + validate({ verificationConfiguration: { ...verificationConfiguration, poolCanTransfer: false }, contracts }), + false, + 'the pool must be able to send ANTS', + ); + assert.equal( + validate({ verificationConfiguration: { ...verificationConfiguration, releaseBps: 0 }, contracts }), + false, + 'a zero release is a frozen pool', + ); + assert.equal( + validate({ + verificationConfiguration, + contracts: { legacySellerClaimPolicy: { address: M002_ADDRESS.foreignPolicy } }, + }), + false, + 'the recorded contract must be the installed policy', + ); +}); + +test('M002 has a single idempotent install phase owned by two signers', () => { + assert.deepEqual(m002.releases, ['002-legacy-seller-claims']); + assert.deepEqual(m002.phases.map((phase) => phase.id), ['install']); + assert.deepEqual(m002.phases[0].signers(), ['deployer', 'sellerRewardsPoolOwner']); + assert.equal(m002.phases[0].guard({ state: 'ready' }), true); + assert.equal(m002.phases[0].guard({ state: 'active' }), false); + assert.deepEqual(m002.allowedDirtyReleases({ state: 'ready' }), []); + assert.deepEqual(buildReleaseOwners().get('002-legacy-seller-claims'), m002); +}); + +test('M002 rehearses through the framework with M001 as its prerequisite', () => { + assert.deepEqual(m002.rehearsal.prerequisites, ['M001']); + assert.equal(m002.rehearsal.fork, undefined); + const result = resolveRehearsal(m002, { ...REHEARSAL_OPTIONS, migration: 'M002' }, deploymentMigrations); + assert.deepEqual(result.migrations.map((entry) => entry.id), ['M001', 'M002']); + assert.deepEqual(result.fork, migration.rehearsal.fork); +}); + +test('M002 rehearsal checks activation and an idempotent second apply', async () => { + for (const states of [['active', 'active'], ['ready'], ['active', 'ready']]) { + const driven = []; + const run = m002.rehearsal.run({ + network: 'base-mainnet', + async runMigration(overrides) { + driven.push(overrides); + return { state: states[driven.length - 1] }; + }, + }); + if (states.every((state) => state === 'active')) await run; + else await assert.rejects(run, /Expected M002 active|not an active no-op/); + assert.equal(driven.length, states.length); + assert.deepEqual(Object.keys(driven[0].signers), ['deployer', 'sellerRewardsPoolOwner']); + assert.ok(Object.values(driven[0].signers).every((signer) => signer.startsWith('unlocked:'))); + } +}); diff --git a/scripts/deployments/index.mjs b/scripts/deployments/index.mjs index 42222b68b..e7d0f4b72 100644 --- a/scripts/deployments/index.mjs +++ b/scripts/deployments/index.mjs @@ -1,8 +1,9 @@ import { migration as m001 } from './m001.mjs'; +import { migration as m002 } from './m002.mjs'; import { parseSignerSpecs } from './runtime/signers.mjs'; /** Every deployment migration, in order. Add new migrations here explicitly. */ -const MIGRATIONS = [m001]; +const MIGRATIONS = [m001, m002]; export function buildMigrationRegistry(migrations) { const registry = new Map(); diff --git a/scripts/deployments/m002.mjs b/scripts/deployments/m002.mjs new file mode 100644 index 000000000..6c7ea9b27 --- /dev/null +++ b/scripts/deployments/m002.mjs @@ -0,0 +1,446 @@ +import process from 'node:process'; + +import { sourceCommit } from './runtime/exec.mjs'; +import { booleanValue, call, chainId, hasCode, numberValue, sameAddress } from './runtime/chain.mjs'; +import { requireEnvironment } from './runtime/env.mjs'; +import { broadcastIsLive, broadcastPath, parseBroadcast, runForgeScript, simulationPath } from './runtime/foundry.mjs'; +import { currentRelease, historyRecordExists, writeCurrent, writeHistoryRecord } from './runtime/ledger.mjs'; +import { runMigration } from './runtime/runner.mjs'; + +/** + * M002 — Legacy seller claims. + * + * Unfreezes the deployed AntseedSellerRewardsPool after M001 has activated: + * whitelists the pool on ANTSToken (it is the transfer *sender* and was never + * whitelisted) and installs AntseedLegacySellerClaimPolicy so sellers can + * claim the released share of their locked legacy rewards. Proven wash + * traders (per the wash-trading registry) can claim nothing. + * + * Single phase, two signers, fully idempotent on chain. The deployed policy is + * recorded under `002-legacy-seller-claims` and folded into `current.json`. + */ + +const ANVIL_ACCOUNT_0 = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; +const ANVIL_ACCOUNT_1 = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; +const M002_TESTNET = 'base-sepolia'; +const M002_ANVIL_FORK = 'base-mainnet'; + +export const RELEASE = '002-legacy-seller-claims'; +export const DEFAULT_RELEASE_BPS = 1000; +export const SIGNERS = ['deployer', 'sellerRewardsPoolOwner']; + +const SIGNER_ENV = { + deployer: 'DEPLOYER', + sellerRewardsPoolOwner: 'SELLER_REWARDS_POOL_OWNER', +}; + +const CONTRACT_NAMES = { + AntseedLegacySellerClaimPolicy: 'legacySellerClaimPolicy', +}; + +// --------------------------------------------------------------------------- +// Pure state logic +// --------------------------------------------------------------------------- + +/** + * `ready` M001 is active, the pool exists, and at least one of the + * two installs (whitelist, policy) is still missing. + * `active` pool can send ANTS and has the recorded claim policy. + * `not-applicable` the legacy emissions contract has no rewards pool + * (V1-only testnets); nothing to unfreeze. + * `invalid` anything else (M001 not active, foreign policy, ...). + */ +export function classifyM002(observation) { + const { expected, registry } = observation; + if (!sameAddress(registry.antsToken, expected.antsToken)) return 'invalid'; + if (!sameAddress(registry.emissions, expected.usageAccounting)) return 'invalid'; + if (!observation.legacyEmissionsV2) return 'invalid'; + if (!observation.pool) return 'not-applicable'; + if (!observation.lastLockedEpochValid) return 'invalid'; + const policy = observation.pool.sellerClaimPolicy; + const hasPolicy = policy && !/^0x0{40}$/.test(policy); + if (hasPolicy && !observation.pool.policyMatchesRecord) return 'invalid'; + const canTransfer = observation.token.transfersEnabled || observation.token.poolWhitelisted; + if (hasPolicy && canTransfer) return 'active'; + return 'ready'; +} + +export function validateM002Options(options) { + const testnetRun = options.network === M002_TESTNET && options.mode !== 'fork-test'; + const mainnetRun = options.network === M002_ANVIL_FORK; + if (testnetRun || mainnetRun) return; + throw new Error( + 'M002 supports Base Sepolia --dry-run/--broadcast and Base mainnet --dry-run/--broadcast/--fork-test', + ); +} + +/** + * M002 runs on top of an activated M001 ledger: `emissions` must already be + * UsageAccounting. The V2 contract that locked rewards into the pool is read + * on chain from the M001 escrow (`legacyEmissionsEscrow.legacyEmissions()`). + */ +export function validateM002Baseline(canonical) { + const required = ['registry', 'antsToken', 'emissions', 'usageAccounting', 'washTradingRegistry', 'legacyEmissionsEscrow']; + const missing = required.filter((name) => !canonical.contracts?.[name]?.address); + if (missing.length) { + throw new Error(`M002 ${canonical.network} deployment baseline is missing: ${missing.join(', ')}`); + } + if (!sameAddress(canonical.contracts.emissions.address, canonical.contracts.usageAccounting.address)) { + throw new Error(`M002 ${canonical.network} baseline: emissions must already be AntseedUsageAccounting (run M001 first)`); + } +} + +/** M002-specific ledger invariants for the release it owns. */ +export function recordErrors(record) { + const errors = []; + const address = /^0x[0-9a-fA-F]{40}$/; + const configuration = record.verificationConfiguration; + if (!configuration || typeof configuration !== 'object') return ['verificationConfiguration is required']; + const expect = (key, ok, description) => { + if (!ok(configuration[key])) errors.push(`verificationConfiguration.${key} must be ${description}`); + }; + expect('sellerRewardsPool', (value) => address.test(value ?? ''), 'an address'); + expect('sellerClaimPolicy', (value) => address.test(value ?? ''), 'an address'); + expect('poolCanTransfer', (value) => value === true, 'true'); + expect('lastLockedEpoch', (value) => Number.isInteger(value) && value >= 0, 'a non-negative integer'); + expect('releaseBps', (value) => Number.isInteger(value) && value > 0 && value <= 10_000, 'an integer in (0, 10000]'); + expect('washTradingRegistry', (value) => address.test(value ?? ''), 'an address'); + if (!record.contracts?.legacySellerClaimPolicy?.address) { + errors.push('contracts.legacySellerClaimPolicy is required'); + } else if (configuration.sellerClaimPolicy + && !sameAddress(record.contracts.legacySellerClaimPolicy.address, configuration.sellerClaimPolicy)) { + errors.push('contracts.legacySellerClaimPolicy must match verificationConfiguration.sellerClaimPolicy'); + } + return errors; +} + +// --------------------------------------------------------------------------- +// Observation +// --------------------------------------------------------------------------- + +function expectedState(canonical) { + const contracts = canonical.contracts; + return { + registry: contracts.registry.address, + antsToken: contracts.antsToken.address, + usageAccounting: contracts.usageAccounting.address, + legacyEmissionsEscrow: contracts.legacyEmissionsEscrow.address, + washTradingRegistry: contracts.washTradingRegistry.address, + recordedPolicy: contracts.legacySellerClaimPolicy?.address ?? null, + }; +} + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +/** V1-only legacy contracts (testnets) have no MIGRATION_EPOCH and no pool. */ +function readMigrationEpoch(rpcUrl, legacyEmissionsV2) { + try { + return numberValue(call(rpcUrl, legacyEmissionsV2, 'MIGRATION_EPOCH()(uint256)')); + } catch { + return null; + } +} + +function readPool(rpcUrl, legacyEmissionsV2) { + let pool; + try { + pool = call(rpcUrl, legacyEmissionsV2, 'sellerRewardsPool()(address)'); + } catch { + return null; + } + if (!pool || sameAddress(pool, ZERO_ADDRESS) || !hasCode(rpcUrl, pool)) return null; + return { + address: pool, + owner: call(rpcUrl, pool, 'owner()(address)'), + sellerClaimPolicy: call(rpcUrl, pool, 'sellerClaimPolicy()(address)'), + totalLockedRewards: call(rpcUrl, pool, 'totalLockedRewards()(uint256)').split(/\s/)[0], + }; +} + +async function observeM002(context) { + const { rpcUrl, expected } = context; + const liveChainId = chainId(rpcUrl); + if (liveChainId !== context.canonical.chainId) { + throw new Error(`RPC chain ID ${liveChainId} does not match ${context.network} (${context.canonical.chainId})`); + } + // The V2 contract that locked rewards into the pool is pinned in the M001 escrow. + const legacyEmissionsV2 = call(rpcUrl, expected.legacyEmissionsEscrow, 'legacyEmissions()(address)'); + const pool = readPool(rpcUrl, legacyEmissionsV2); + const gate = call(rpcUrl, expected.usageAccounting, 'emissionsGate()(address)'); + const effectiveEpoch = numberValue(call(rpcUrl, gate, 'effectiveEpoch()(uint256)')); + const migrationEpoch = readMigrationEpoch(rpcUrl, legacyEmissionsV2); + const lastLockedEpoch = effectiveEpoch - 1; + const observation = { + chainId: liveChainId, + expected, + registry: { + antsToken: call(rpcUrl, expected.registry, 'antsToken()(address)'), + emissions: call(rpcUrl, expected.registry, 'emissions()(address)'), + }, + legacyEmissionsV2: hasCode(rpcUrl, legacyEmissionsV2) ? legacyEmissionsV2 : null, + token: { + owner: call(rpcUrl, expected.antsToken, 'owner()(address)'), + transfersEnabled: booleanValue(call(rpcUrl, expected.antsToken, 'transfersEnabled()(bool)')), + poolWhitelisted: pool + ? booleanValue(call(rpcUrl, expected.antsToken, 'transferWhitelist(address)(bool)', [pool.address])) + : false, + }, + pool: pool && { + ...pool, + // A policy that is not the one this ledger recorded is somebody else's install. + policyMatchesRecord: expected.recordedPolicy + ? sameAddress(pool.sellerClaimPolicy, expected.recordedPolicy) + : sameAddress(pool.sellerClaimPolicy, ZERO_ADDRESS) || await policyIsOurs(context, pool.sellerClaimPolicy), + }, + effectiveEpoch, + migrationEpoch, + lastLockedEpoch, + lastLockedEpochValid: effectiveEpoch > 0 && migrationEpoch !== null && lastLockedEpoch >= migrationEpoch, + washTradingRegistry: expected.washTradingRegistry, + }; + observation.state = classifyM002(observation); + return observation; +} + +/** + * When the ledger has no record yet but the pool already has a policy, accept + * it only if our own confirmed broadcast produced it (a crash after sending + * but before recording). Anything else is a foreign install. + */ +async function policyIsOurs(context, policy) { + const broadcastFile = broadcastPath('Install.s.sol', context.canonical.chainId); + if (!(await broadcastIsLive(broadcastFile, context.rpcUrl))) return false; + const parsed = await parseBroadcast(broadcastFile, context.rpcUrl, CONTRACT_NAMES); + return sameAddress(parsed.contracts.legacySellerClaimPolicy?.address, policy); +} + +function printStatus(observation) { + console.log(`M002 state: ${observation.state}`); + console.log(`Registry emissions: ${observation.registry.emissions}`); + if (observation.pool) { + console.log(`SellerRewardsPool: ${observation.pool.address} (locked ${observation.pool.totalLockedRewards})`); + console.log(`Pool claim policy: ${observation.pool.sellerClaimPolicy}`); + console.log(`Pool can transfer: ${observation.token.transfersEnabled || observation.token.poolWhitelisted}`); + } else { + console.log('SellerRewardsPool: none on the legacy emissions contract'); + } + console.log(`Last locked epoch: ${observation.lastLockedEpoch} (migration ${observation.migrationEpoch}, effective ${observation.effectiveEpoch})`); + console.log(`Wash registry: ${observation.washTradingRegistry}`); +} + +// --------------------------------------------------------------------------- +// Environment and signer roles +// --------------------------------------------------------------------------- + +function expectedSigner(role, context, observation) { + switch (role) { + case 'deployer': return observation.token.owner; + case 'sellerRewardsPoolOwner': return observation.pool?.owner ?? ZERO_ADDRESS; + default: throw new Error(`Unknown signer role ${role}`); + } +} + +/** + * Environment handed to Install.s.sol. Signer roles arrive as addresses only. + * The wash-trading registry defaults to the activated M001 deployment ledger; + * `WASH_TRADING_REGISTRY` overrides it. + */ +function migrationEnvironment(context, observation, signerAddresses = {}, extra = {}) { + return { + ...process.env, + ...Object.fromEntries( + Object.entries(signerAddresses).filter(([role]) => SIGNER_ENV[role]).map(([role, address]) => [SIGNER_ENV[role], address]), + ), + BASE_MAINNET_RPC_URL: context.rpcUrl, + BASE_SEPOLIA_RPC_URL: context.rpcUrl, + ANTSEED_REGISTRY: context.expected.registry, + EXPECTED_ANTS_TOKEN: context.expected.antsToken, + LEGACY_EMISSIONS_V2: observation.legacyEmissionsV2, + USAGE_ACCOUNTING: context.expected.usageAccounting, + WASH_TRADING_REGISTRY: process.env.WASH_TRADING_REGISTRY ?? observation.washTradingRegistry, + RELEASE_BPS: process.env.RELEASE_BPS ?? String(DEFAULT_RELEASE_BPS), + ...extra, + }; +} + +function verifyRoles(context, observation, env) { + if (!context.forkTest) requireEnvironment(['BASESCAN_API_KEY'], env); + requireEnvironment(['WASH_TRADING_REGISTRY'], env); + if (!hasCode(context.rpcUrl, env.WASH_TRADING_REGISTRY)) { + throw new Error('WASH_TRADING_REGISTRY has no code on this network'); + } + const mustBe = (role, actual, label) => { + requireEnvironment([SIGNER_ENV[role]], env); + if (!sameAddress(actual, env[SIGNER_ENV[role]])) { + throw new Error(`${role} (${env[SIGNER_ENV[role]]}) is not the ${label} owner`); + } + }; + const needsWhitelist = !observation.token.transfersEnabled && !observation.token.poolWhitelisted; + if (needsWhitelist) mustBe('deployer', observation.token.owner, 'ANTSToken'); + mustBe('sellerRewardsPoolOwner', observation.pool.owner, 'seller rewards pool'); +} + +// --------------------------------------------------------------------------- +// Records +// --------------------------------------------------------------------------- + +/** Recorded from chain state only. */ +function verificationConfiguration(context, observation, policy) { + const { rpcUrl } = context; + const pool = observation.pool.address; + return { + sellerRewardsPool: pool, + sellerClaimPolicy: policy, + poolCanTransfer: booleanValue(call(rpcUrl, context.expected.antsToken, 'transfersEnabled()(bool)')) + || booleanValue(call(rpcUrl, context.expected.antsToken, 'transferWhitelist(address)(bool)', [pool])), + lastLockedEpoch: numberValue(call(rpcUrl, policy, 'lastEpoch()(uint256)')), + releaseBps: numberValue(call(rpcUrl, policy, 'releaseBps()(uint256)')), + washTradingRegistry: call(rpcUrl, policy, 'washTradingRegistry()(address)'), + }; +} + +async function recordRelease(context, observation) { + const parsed = await parseBroadcast(broadcastPath('Install.s.sol', context.canonical.chainId), context.rpcUrl, CONTRACT_NAMES); + const policy = parsed.contracts.legacySellerClaimPolicy?.address ?? observation.pool.sellerClaimPolicy; + if (!policy || sameAddress(policy, ZERO_ADDRESS)) throw new Error('No AntseedLegacySellerClaimPolicy found to record'); + if (parsed.transactions.length === 0) throw new Error('Confirmed Foundry receipts are required to record M002'); + const contracts = parsed.contracts.legacySellerClaimPolicy + ? parsed.contracts + : { legacySellerClaimPolicy: { address: policy, external: false, deployedInRelease: false } }; + const verification = verificationConfiguration(context, observation, policy); + const commit = sourceCommit(); + + if (!(await historyRecordExists(context, RELEASE))) { + await writeHistoryRecord(context, RELEASE, { + $schema: '../../schema.json', + network: context.network, + chainId: context.canonical.chainId, + release: RELEASE, + status: 'active', + sourceCommit: commit, + transactions: parsed.transactions, + verificationConfiguration: verification, + contracts, + }); + } + if (await currentRelease(context) === RELEASE) return; + + const current = structuredClone(context.canonical); + current.release = RELEASE; + current.sourceCommit = commit; + current.transactions = parsed.transactions; + current.verificationConfiguration = verification; + for (const contract of Object.values(current.contracts)) contract.deployedInRelease = false; + Object.assign(current.contracts, contracts); + await writeCurrent(context, current); +} + +// --------------------------------------------------------------------------- +// Phase +// --------------------------------------------------------------------------- + +const installPhase = { + id: 'install', + guard: (observation) => observation.state === 'ready', + signers: () => SIGNERS, + plan: (context, observation) => ({ + release: RELEASE, + phaseId: 'install', + simulationFile: simulationPath('Install.s.sol', context.canonical.chainId), + pointerChanges: { + 'ANTSToken.transferWhitelist(SellerRewardsPool)': { + before: String(observation.token.poolWhitelisted), + after: observation.token.transfersEnabled ? String(observation.token.poolWhitelisted) : 'true', + }, + 'SellerRewardsPool.sellerClaimPolicy': { + before: observation.pool.sellerClaimPolicy, + after: sameAddress(observation.pool.sellerClaimPolicy, ZERO_ADDRESS) + ? 'AntseedLegacySellerClaimPolicy (deployed by this phase)' + : observation.pool.sellerClaimPolicy, + }, + }, + }), + async run(context, mode, environment, observation, wallet) { + runForgeScript({ + target: 'script/migrations/M002LegacySellerClaims/Install.s.sol:M002InstallLegacySellerClaims', + rpcUrl: context.rpcUrl, + broadcast: mode === 'broadcast', + verify: !context.forkTest, + etherscanApiKey: environment.BASESCAN_API_KEY, + env: environment, + walletArgs: wallet.forgeArgs, + }); + if (mode !== 'broadcast') return; + const after = await observeM002(context); + if (after.state !== 'active') throw new Error(`M002 broadcast finished but the pool is not active (${after.state})`); + await recordRelease(context, after); + console.log('M002 complete: legacy sellers can claim from the rewards pool.'); + }, +}; + +/** Writes the record when the install landed but recording did not finish. */ +async function finalize(context, observation, mode) { + if (mode !== 'broadcast' || observation.state !== 'active') return false; + const historyWritten = await historyRecordExists(context, RELEASE); + const currentWritten = await currentRelease(context) === RELEASE; + if (historyWritten && currentWritten) return false; + if (!(await broadcastIsLive(broadcastPath('Install.s.sol', context.canonical.chainId), context.rpcUrl))) { + throw new Error('M002 is active on chain but no confirmed Install.s.sol receipts are available to record it'); + } + await recordRelease(context, observation); + console.log(historyWritten + ? 'Recovered M002 current.json from the confirmed history record.' + : 'Recovered M002 records from confirmed Foundry receipts.'); + return true; +} + +function idleMessage(observation) { + if (observation.state === 'active') return 'M002 is already active; no transactions required.'; + if (observation.state === 'not-applicable') { + return 'The legacy emissions contract has no seller rewards pool on this network; M002 has nothing to do.'; + } + return 'No M002 phase applies to the observed state.'; +} + +// --------------------------------------------------------------------------- +// Fork test +// --------------------------------------------------------------------------- + +async function rehearse({ network, runMigration: drive }) { + const overrides = { + environment: { ANTSEED_DEPLOY_CONFIRM: network }, + signers: { + deployer: `unlocked:${ANVIL_ACCOUNT_0}`, + sellerRewardsPoolOwner: `unlocked:${ANVIL_ACCOUNT_1}`, + }, + }; + let observation = await drive(overrides); + if (observation.state !== 'active') throw new Error(`Expected M002 active, got ${observation.state}`); + observation = await drive(overrides); + if (observation.state !== 'active') throw new Error('Repeated M002 broadcast was not an active no-op'); +} + +// --------------------------------------------------------------------------- +// Migration declaration +// --------------------------------------------------------------------------- + +export const migration = { + id: 'M002', + networks: [M002_TESTNET, M002_ANVIL_FORK], + releases: [RELEASE], + phases: [installPhase], + validateOptions: validateM002Options, + validateBaseline: validateM002Baseline, + expectedState, + observe: observeM002, + printStatus, + environment: migrationEnvironment, + verifyRoles, + expectedSigner, + finalize, + idleMessage, + recordErrors, + allowedDirtyReleases: () => [], + rehearsal: { prerequisites: ['M001'], run: rehearse }, + run: (options, overrides) => runMigration(migration, options, overrides), +};