Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`.
Expand Down
11 changes: 11 additions & 0 deletions packages/contracts/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/<network>/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
40 changes: 40 additions & 0 deletions packages/contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`).
Expand Down
139 changes: 139 additions & 0 deletions packages/contracts/policies/AntseedLegacySellerClaimPolicy.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading