From 8f368e528f43d897cd32f7488368e63fcb58845e Mon Sep 17 00:00:00 2001 From: alexanderludwig Date: Sat, 5 Sep 2026 00:02:23 +0200 Subject: [PATCH 1/3] feat(cli): support recognized usage seller commands --- .gitignore | 1 + CHANGELOG.md | 1 + apps/cli/README.md | 45 ++- apps/cli/src/cli/commands/emissions.test.ts | 9 +- apps/cli/src/cli/commands/emissions.ts | 150 +++++++-- .../commands/network/chain-config-helper.ts | 14 + .../cli/src/cli/commands/network/contracts.ts | 59 ++++ apps/cli/src/cli/commands/network/index.ts | 2 + apps/cli/src/cli/commands/seller/index.ts | 2 + apps/cli/src/cli/commands/seller/pool.test.ts | 16 + apps/cli/src/cli/commands/seller/pool.ts | 225 ++++++++++++++ apps/cli/src/cli/commands/seller/register.ts | 42 ++- apps/cli/src/cli/commands/seller/setup.ts | 2 +- .../cli/src/cli/commands/seller/stake.test.ts | 40 +++ apps/cli/src/cli/commands/seller/stake.ts | 188 ++++++++---- apps/cli/src/cli/commands/seller/status.ts | 61 +++- apps/cli/src/cli/payment-utils.ts | 131 ++++++++ apps/cli/src/config/types.ts | 14 + apps/website/docs/cli/commands.md | 11 +- apps/website/docs/guides/become-a-provider.md | 19 +- apps/website/docs/guides/payments.md | 17 +- e2e/tests/m001-cli.e2e.test.ts | 128 ++++++++ package.json | 1 + packages/buyer-core/src/base-evm-client.ts | 2 +- .../migrations/M001RecognizedUsage/README.md | 32 ++ packages/node/src/index.ts | 18 ++ packages/node/src/payments/chain-config.ts | 34 ++ .../node/src/payments/contract-stack.test.ts | 73 +++++ packages/node/src/payments/contract-stack.ts | 139 +++++++++ .../src/payments/evm/ants-token-client.ts | 6 + .../src/payments/evm/emissions-gate-client.ts | 19 ++ .../src/payments/evm/position-init-client.ts | 19 ++ .../node/src/payments/evm/registry-client.ts | 34 ++ .../src/payments/evm/seller-pools-client.ts | 68 ++++ .../evm/seller-pools-rewards-client.ts | 15 + .../payments/evm/seller-registry-client.ts | 22 ++ .../payments/evm/usage-accounting-client.ts | 45 +++ .../src/payments/evm/usage-rewards-client.ts | 34 ++ .../payments/generated-contract-addresses.ts | 2 + packages/node/src/payments/index.ts | 28 ++ scripts/deploy-contracts.test.mjs | 42 +++ scripts/deployments/m001-cutover.mjs | 1 + scripts/deployments/m001.mjs | 23 +- scripts/deployments/runtime/anvil.mjs | 30 +- scripts/deployments/runtime/foundry.mjs | 3 +- scripts/generate-contract-chain-config.mjs | 15 +- scripts/m001-sandbox.mjs | 290 ++++++++++++++++++ 47 files changed, 2038 insertions(+), 134 deletions(-) create mode 100644 apps/cli/src/cli/commands/network/contracts.ts create mode 100644 apps/cli/src/cli/commands/seller/pool.test.ts create mode 100644 apps/cli/src/cli/commands/seller/pool.ts create mode 100644 apps/cli/src/cli/commands/seller/stake.test.ts create mode 100644 e2e/tests/m001-cli.e2e.test.ts create mode 100644 packages/node/src/payments/contract-stack.test.ts create mode 100644 packages/node/src/payments/contract-stack.ts create mode 100644 packages/node/src/payments/evm/emissions-gate-client.ts create mode 100644 packages/node/src/payments/evm/position-init-client.ts create mode 100644 packages/node/src/payments/evm/registry-client.ts create mode 100644 packages/node/src/payments/evm/seller-pools-client.ts create mode 100644 packages/node/src/payments/evm/seller-pools-rewards-client.ts create mode 100644 packages/node/src/payments/evm/seller-registry-client.ts create mode 100644 packages/node/src/payments/evm/usage-accounting-client.ts create mode 100644 packages/node/src/payments/evm/usage-rewards-client.ts create mode 100644 scripts/m001-sandbox.mjs diff --git a/.gitignore b/.gitignore index 29608a3fb..892018edb 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ cli-dist/ # Deployments .deployments/ prds/ +.m001-sandbox/ # Napkin .napkin/ diff --git a/CHANGELOG.md b/CHANGELOG.md index eea49641a..35749f94c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project uses selective package publishing. Each release entry lists the pub ### Added +- CLI/Node: added registry-verified support for the M001 recognized-usage contract stack, including dual-stack seller and buyer emissions claims, a stack-aware `antseed seller stake` (ANTS pool staking with `--epochs` after cutover, legacy USDC before it) with explicit `antseed seller legacy stake` / `antseed seller legacy unstake` commands, `antseed seller pool bootstrap` for the legacy-seller starter ANTS position, seller pool position/reward/withdrawal commands, post-cutover seller binding, `antseed network contracts`, generated M001 address overrides, a persistent Anvil fork rehearsal via `pnpm m001:sandbox`, and a 10-second EVM request timeout for storage-heavy reward reads. - 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`). diff --git a/apps/cli/README.md b/apps/cli/README.md index f2f484127..0a6a48359 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -13,8 +13,13 @@ Command-line interface and web dashboard for the AntSeed Network — a P2P netwo | **Providing** | | | `antseed seller start` | Start providing AI services on the P2P network | | `antseed seller register` | Register peer identity on-chain (ERC-8004) | -| `antseed seller stake ` | Stake USDC as a provider (min $10) | -| `antseed seller unstake` | Withdraw staked USDC | +| `antseed seller stake --epochs ` | Stake ANTS into your seller pool (recognized-usage stack) | +| `antseed seller legacy stake ` | Stake USDC as a provider before cutover (min $10) | +| `antseed seller legacy unstake` | Withdraw legacy USDC stake | +| `antseed seller pool bootstrap` | Claim the legacy-seller starter ANTS position after the recognized-usage cutover | +| `antseed seller pool positions` | List seller-pool positions and lifecycle state | +| `antseed seller pool rewards [claim]` | View or claim indexed pool rewards | +| `antseed seller pool withdraw [--force]` | Withdraw matured positions or explicitly accept early-exit slashing | | `antseed seller emissions claim` | Claim accumulated seller payouts | | **Buying** | | | `antseed buyer start` | Start the buyer proxy and connect to sellers | @@ -41,6 +46,7 @@ Command-line interface and web dashboard for the AntSeed Network — a P2P netwo | `antseed metrics serve` | Serve Prometheus metrics for buyers and sellers | | `antseed buyer channels` | List payment channels | | `antseed seller emissions info` | View ANTS emissions and epoch info | +| `antseed network contracts [--json]` | Verify configured stack addresses against the on-chain registry | | `antseed dev` | Run seller + buyer locally for testing | | `antseed network bootstrap` | Run a dedicated DHT bootstrap node | @@ -341,12 +347,45 @@ export ANTSEED_IDENTITY_HEX= antseed seller register # 4. Stake USDC (minimum $10) -antseed seller stake 10 +antseed seller legacy stake 10 # 6. Start providing antseed seller start ``` +After the M001 recognized-usage cutover, new seller stake moves from legacy USDC staking to ANTS seller pools: + +```bash +antseed seller register +antseed seller pool bootstrap +antseed seller stake 100 --epochs 4 +antseed seller pool positions +antseed seller pool rewards +antseed seller pool rewards claim +``` + +`antseed seller stake` targets the active stack: ANTS seller pools after cutover (`--epochs` required) and legacy USDC before it. `antseed seller legacy stake` is explicit legacy USDC staking and refuses to run after cutover. `antseed seller legacy unstake` (also available as `antseed seller unstake`) withdraws legacy stake and warns that doing so can remove temporary eligibility before an ANTS pool becomes active. Emissions commands verify the registry before every read or claim and, after cutover, process finalized legacy epochs and recognized-usage epochs in the same run. Use `--legacy-only` or `--new-only` to restrict a claim. + +### M001 Anvil rehearsal + +The repository includes a persistent Base-mainnet fork sandbox for exercising the exact pre-cutover and post-cutover CLI paths. It requires an archive-capable `BASE_MAINNET_RPC_URL` and an `ANTS_HOLDER` address with ANTS at the pinned fork block. Seller USDC is sourced from the forked legacy staking contract. + +```bash +export BASE_MAINNET_RPC_URL=https://your-archive-base-rpc.example +export ANTS_HOLDER=0x... + +pnpm m001:sandbox up +pnpm m001:sandbox status + +# Use .m001-sandbox/cli-config.json with CLI commands before cutover. +pnpm m001:sandbox cutover +pnpm m001:sandbox advance-epoch 2 +pnpm m001:sandbox fund-ants 0xYourCliWallet 100 +pnpm m001:sandbox down +``` + +Use `--port ` and `--out ` on each sandbox command to override the defaults (`8545` and `.m001-sandbox/`). + ### Buyer Setup (Consuming) ```bash diff --git a/apps/cli/src/cli/commands/emissions.test.ts b/apps/cli/src/cli/commands/emissions.test.ts index 74e5fc412..dd7465921 100644 --- a/apps/cli/src/cli/commands/emissions.test.ts +++ b/apps/cli/src/cli/commands/emissions.test.ts @@ -1,12 +1,19 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { claimablePendingForRole, pastEpochs } from './emissions.js'; +import { claimablePendingForRole, pastEpochs, selectedEmissionStacks } from './emissions.js'; test('pastEpochs returns finalized epoch ids before current epoch', () => { assert.deepEqual(pastEpochs(0), []); assert.deepEqual(pastEpochs(4), [0, 1, 2, 3]); }); +test('selectedEmissionStacks enforces mutually exclusive filters', () => { + assert.deepEqual(selectedEmissionStacks({}), { legacy: true, recognized: true }); + assert.deepEqual(selectedEmissionStacks({ legacyOnly: true }), { legacy: true, recognized: false }); + assert.deepEqual(selectedEmissionStacks({ newOnly: true }), { legacy: false, recognized: true }); + assert.throws(() => selectedEmissionStacks({ legacyOnly: true, newOnly: true })); +}); + test('claimablePendingForRole only selects the requested reward bucket', () => { const pending = { seller: 10n, diff --git a/apps/cli/src/cli/commands/emissions.ts b/apps/cli/src/cli/commands/emissions.ts index 4ce3d419b..c7fbb3c1c 100644 --- a/apps/cli/src/cli/commands/emissions.ts +++ b/apps/cli/src/cli/commands/emissions.ts @@ -5,9 +5,15 @@ import { getGlobalOptions } from './types.js'; import { loadConfig } from '../../config/loader.js'; import { createEmissionsClient, + createLegacyEmissionsClient, + createSellerPoolsClient, + createUsageAccountingClient, + createUsageRewardsClient, loadCryptoContext, formatAnts, + resolveCliContractStack, } from '../payment-utils.js'; +import { legacyEpochs, newEpochs } from '@antseed/node/payments'; export type EmissionsRole = 'seller' | 'buyer'; @@ -24,6 +30,14 @@ export function claimablePendingForRole(pending: PendingEmissions, role: Emissio return role === 'seller' ? pending.seller : pending.buyer; } +export function selectedEmissionStacks(options: { legacyOnly?: boolean; newOnly?: boolean }): { legacy: boolean; recognized: boolean } { + if (options.legacyOnly && options.newOnly) throw new Error('--legacy-only and --new-only cannot be used together'); + return { + legacy: !options.newOnly, + recognized: !options.legacyOnly, + }; +} + function roleLabel(role: EmissionsRole): string { return role === 'seller' ? 'Seller' : 'Buyer'; } @@ -41,39 +55,86 @@ export function registerEmissionsCommand(parentCmd: Command, role: EmissionsRole .command('info') .description('Show current epoch info and pending emissions') .option('--json', 'output as JSON', false) + .option('--legacy-only', 'show only legacy-stack rewards', false) + .option('--new-only', 'show only recognized-usage rewards', false) .action(async (options) => { const globalOpts = getGlobalOptions(parentCmd); const config = await loadConfig(globalOpts.config); const { address } = await loadCryptoContext(globalOpts.dataDir); - const emissionsClient = createEmissionsClient(config); - const spinner = ora('Fetching emissions info...').start(); try { - const epochInfo = await emissionsClient.getEpochInfo(); - const pending = await emissionsClient.pendingEmissions(address, pastEpochs(epochInfo.epoch)); - const rolePending = claimablePendingForRole(pending, role); + const selected = selectedEmissionStacks(options); + const stack = await resolveCliContractStack(config); + const legacyIds = stack.mode === 'legacy' + ? pastEpochs(stack.currentEpoch) + : legacyEpochs(stack.currentEpoch, stack.firstRewardedEpoch!); + const recognizedIds = stack.mode === 'recognized-usage' + ? newEpochs(stack.currentEpoch, stack.firstRewardedEpoch!) + : []; + let legacyPending = 0n; + let recognizedPending = 0n; + let emissionRate = 0n; + let epochDuration = 0; + + if (selected.legacy && legacyIds.length >= 0) { + const client = stack.mode === 'legacy' ? createEmissionsClient(config) : createLegacyEmissionsClient(config); + const [epochInfo, pending] = await Promise.all([ + client.getEpochInfo(), + client.pendingEmissions(address, legacyIds), + ]); + emissionRate = epochInfo.emission; + epochDuration = epochInfo.epochDuration; + legacyPending = claimablePendingForRole(pending, role); + } + + let noCurrentPool = false; + if (selected.recognized && stack.mode === 'recognized-usage') { + if (role === 'seller') { + const usage = createUsageAccountingClient(config); + const pending = await usage.pendingEmissions(address, recognizedIds); + recognizedPending = pending.seller; + const pools = createSellerPoolsClient(config); + const agentId = await pools.agentIdForSeller(address); + noCurrentPool = agentId === 0 || !(await pools.hasPoolAtEpoch(agentId, stack.currentEpoch)); + } else { + const rewards = createUsageRewardsClient(config); + const amounts = await Promise.all(recognizedIds.map(async (epoch) => ( + await rewards.buyerEpochClaimed(address, epoch) ? 0n : rewards.pendingBuyerReward(address, epoch) + ))); + recognizedPending = amounts.reduce((total, value) => total + value, 0n); + } + } spinner.stop(); if (options.json) { console.log(JSON.stringify({ address, - epoch: epochInfo.epoch, - emissionRate: formatAnts(epochInfo.emission), - epochDuration: epochInfo.epochDuration, - [pendingJsonKey(role)]: formatAnts(rolePending), + mode: stack.mode, + epoch: stack.currentEpoch, + firstRewardedEpoch: stack.firstRewardedEpoch ?? null, + emissionRate: formatAnts(emissionRate), + epochDuration, + legacy: { epochs: legacyIds, [pendingJsonKey(role)]: formatAnts(legacyPending) }, + recognizedUsage: { epochs: recognizedIds, [pendingJsonKey(role)]: formatAnts(recognizedPending) }, + [pendingJsonKey(role)]: formatAnts(legacyPending + recognizedPending), + ...(role === 'seller' ? { hasCurrentPool: !noCurrentPool } : {}), }, null, 2)); return; } console.log(chalk.bold('Emissions Info:\n')); - console.log(` Epoch: ${chalk.cyan(String(epochInfo.epoch))}`); - console.log(` Emission rate: ${chalk.green(formatAnts(epochInfo.emission) + ' ANTS/epoch')}`); + console.log(` Mode: ${chalk.cyan(stack.mode)}`); + console.log(` Epoch: ${chalk.cyan(String(stack.currentEpoch))}`); + if (emissionRate > 0n) console.log(` Emission rate: ${chalk.green(formatAnts(emissionRate) + ' ANTS/epoch')}`); console.log(''); console.log(chalk.bold(`${roleLabel(role)} Pending Emissions (${address.slice(0, 10)}...):\n`)); - console.log(` ${roleLabel(role)} rewards: ${chalk.green(formatAnts(rolePending) + ' ANTS')}`); + if (selected.legacy) console.log(` Legacy: ${chalk.green(formatAnts(legacyPending) + ' ANTS')}`); + if (selected.recognized && stack.mode === 'recognized-usage') console.log(` Recognized use: ${chalk.green(formatAnts(recognizedPending) + ' ANTS')}`); + console.log(` Total: ${chalk.green(formatAnts(legacyPending + recognizedPending) + ' ANTS')}`); + if (noCurrentPool) console.log(chalk.yellow('\n⚠ No active seller pool exists for the current epoch; new usage will not accrue rewards.')); } catch (err) { spinner.fail(chalk.red(`Failed to fetch emissions: ${(err as Error).message}`)); process.exit(1); @@ -83,35 +144,68 @@ export function registerEmissionsCommand(parentCmd: Command, role: EmissionsRole emissions .command('claim') .description(`Claim pending ${role} ANTS emissions`) - .action(async () => { + .option('--legacy-only', 'claim only legacy-stack rewards', false) + .option('--new-only', 'claim only recognized-usage rewards', false) + .action(async (options) => { const globalOpts = getGlobalOptions(parentCmd); const config = await loadConfig(globalOpts.config); const { wallet, address } = await loadCryptoContext(globalOpts.dataDir); - const emissionsClient = createEmissionsClient(config); - console.log(chalk.dim(`Wallet: ${address}`)); const spinner = ora(`Claiming ${role} emissions...`).start(); try { - const epochInfo = await emissionsClient.getEpochInfo(); - const epochs = pastEpochs(epochInfo.epoch); - const pending = await emissionsClient.pendingEmissions(address, epochs); - const claimablePending = claimablePendingForRole(pending, role); - if (claimablePending === 0n) { - spinner.succeed(chalk.yellow(`No pending ${role} emissions to claim.`)); - return; + const selected = selectedEmissionStacks(options); + const stack = await resolveCliContractStack(config); + const legacyIds = stack.mode === 'legacy' + ? pastEpochs(stack.currentEpoch) + : legacyEpochs(stack.currentEpoch, stack.firstRewardedEpoch!); + const recognizedIds = stack.mode === 'recognized-usage' + ? newEpochs(stack.currentEpoch, stack.firstRewardedEpoch!) + : []; + let claimed = 0n; + const transactions: string[] = []; + + if (selected.legacy) { + const client = stack.mode === 'legacy' ? createEmissionsClient(config) : createLegacyEmissionsClient(config); + const pending = await client.pendingEmissions(address, legacyIds); + const amount = claimablePendingForRole(pending, role); + if (amount > 0n) { + transactions.push(role === 'seller' + ? await client.claimSellerEmissions(wallet, legacyIds) + : await client.claimBuyerEmissions(wallet, address, legacyIds)); + claimed += amount; + } } - console.log(chalk.dim(`Pending ${roleLabel(role).toLowerCase()}: ${formatAnts(claimablePending)} ANTS`)); + if (selected.recognized && stack.mode === 'recognized-usage') { + if (role === 'seller') { + const usage = createUsageAccountingClient(config); + const pending = await usage.pendingEmissions(address, recognizedIds); + if (pending.seller > 0n) { + transactions.push(await usage.claimSellerEmissions(wallet, recognizedIds)); + claimed += pending.seller; + } + } else { + const rewards = createUsageRewardsClient(config); + for (const epoch of recognizedIds.slice(-104)) { + if (await rewards.buyerEpochClaimed(address, epoch)) continue; + const amount = await rewards.pendingBuyerReward(address, epoch); + if (amount === 0n) continue; + transactions.push(await rewards.claimBuyerReward(wallet, address, epoch)); + claimed += amount; + } + } + } - const txHash = role === 'seller' - ? await emissionsClient.claimSellerEmissions(wallet, epochs) - : await emissionsClient.claimBuyerEmissions(wallet, address, epochs); + if (claimed === 0n) { + spinner.succeed(chalk.yellow(`No pending ${role} emissions to claim.`)); + return; + } - spinner.succeed(chalk.green(`Claimed ${formatAnts(claimablePending)} ANTS`)); - console.log(chalk.dim(`Transaction: ${txHash}`)); + spinner.succeed(chalk.green(`Claimed ${formatAnts(claimed)} ANTS`)); + for (const txHash of transactions) console.log(chalk.dim(`Transaction: ${txHash}`)); } catch (err) { spinner.fail(chalk.red(`Claim failed: ${(err as Error).message}`)); process.exit(1); diff --git a/apps/cli/src/cli/commands/network/chain-config-helper.ts b/apps/cli/src/cli/commands/network/chain-config-helper.ts index c798db6c0..ad21181b8 100644 --- a/apps/cli/src/cli/commands/network/chain-config-helper.ts +++ b/apps/cli/src/cli/commands/network/chain-config-helper.ts @@ -14,10 +14,24 @@ export interface ChainCryptoOverrides { fallbackRpcUrls?: string[]; depositsContractAddress?: string; channelsContractAddress?: string; + registryContractAddress?: string; freeUsageContractAddress?: string; usdcContractAddress?: string; stakingContractAddress?: string; identityRegistryAddress?: string; + emissionsContractAddress?: string; + legacyEmissionsContractAddress?: string; + legacyStakingContractAddress?: string; + legacyEmissionsV1ContractAddress?: string; + antsTokenAddress?: string; + emissionsGateAddress?: string; + sellerPoolsAddress?: string; + sellerRegistryAddress?: string; + positionInitAddress?: string; + usageAccountingAddress?: string; + usageRewardsAddress?: string; + sellerPoolsRewardsAddress?: string; + legacyEmissionsEscrowAddress?: string; } /** diff --git a/apps/cli/src/cli/commands/network/contracts.ts b/apps/cli/src/cli/commands/network/contracts.ts new file mode 100644 index 000000000..e317acfe2 --- /dev/null +++ b/apps/cli/src/cli/commands/network/contracts.ts @@ -0,0 +1,59 @@ +import type { Command } from 'commander'; +import chalk from 'chalk'; +import Table from 'cli-table3'; +import { getGlobalOptions } from '../types.js'; +import { loadConfig } from '../../../config/loader.js'; +import { requireCryptoConfig, resolveCliContractStack } from '../../payment-utils.js'; + +function sameAddress(left: string | undefined, right: string | undefined): boolean { + return !!left && !!right && left.toLowerCase() === right.toLowerCase(); +} + +export function registerNetworkContractsCommand(networkCmd: Command): void { + networkCmd.command('contracts') + .description('Verify configured contract addresses against AntseedRegistry') + .option('--json', 'output as JSON', false) + .action(async (options) => { + try { + const global = getGlobalOptions(networkCmd); + const config = await loadConfig(global.config); + const crypto = requireCryptoConfig(config); + const stack = await resolveCliContractStack(config); + const expectedEmissions = stack.mode === 'legacy' ? crypto.emissionsContractAddress : crypto.usageAccountingAddress; + const expectedStaking = stack.mode === 'legacy' ? crypto.stakingContractAddress : crypto.sellerRegistryAddress; + const matches = { + emissions: sameAddress(stack.registryPointers.emissions, expectedEmissions), + staking: sameAddress(stack.registryPointers.staking, expectedStaking), + }; + const addresses = Object.fromEntries(Object.entries(crypto).filter(([key, value]) => key.endsWith('Address') && typeof value === 'string')); + if (options.json) { + console.log(JSON.stringify({ + chainId: crypto.chainId, + mode: stack.mode, + currentEpoch: stack.currentEpoch, + firstRewardedEpoch: stack.firstRewardedEpoch ?? null, + addresses, + registryPointers: stack.registryPointers, + matches, + }, null, 2)); + return; + } + console.log(chalk.bold(`Contract Stack (${crypto.chainId})\n`)); + console.log(`Mode: ${chalk.cyan(stack.mode)}`); + console.log(`Current epoch: ${stack.currentEpoch}`); + if (stack.firstRewardedEpoch !== undefined) console.log(`First rewarded epoch: ${stack.firstRewardedEpoch}`); + console.log(''); + const pointers = new Table({ head: ['Registry pointer', 'On-chain', 'Configured', 'Match'] }); + pointers.push( + ['emissions', stack.registryPointers.emissions, expectedEmissions ?? 'missing', matches.emissions ? chalk.green('✓') : chalk.red('✗')], + ['staking', stack.registryPointers.staking, expectedStaking ?? 'missing', matches.staking ? chalk.green('✓') : chalk.red('✗')], + ); + console.log(pointers.toString()); + console.log(chalk.bold('\nConfigured addresses')); + for (const [key, value] of Object.entries(addresses)) console.log(` ${key}: ${value}`); + } catch (error) { + console.error(chalk.red(`${(error as Error).name}: ${(error as Error).message}`)); + process.exitCode = 1; + } + }); +} diff --git a/apps/cli/src/cli/commands/network/index.ts b/apps/cli/src/cli/commands/network/index.ts index 7c081d7c1..ed5746325 100644 --- a/apps/cli/src/cli/commands/network/index.ts +++ b/apps/cli/src/cli/commands/network/index.ts @@ -2,6 +2,7 @@ import type { Command } from 'commander'; import { registerNetworkBrowseCommand } from './browse.js'; import { registerNetworkPeerCommand } from './peer.js'; import { registerNetworkBootstrapCommand } from './bootstrap.js'; +import { registerNetworkContractsCommand } from './contracts.js'; export function registerNetworkCommands(program: Command): void { const networkCmd = program @@ -11,4 +12,5 @@ export function registerNetworkCommands(program: Command): void { registerNetworkBrowseCommand(networkCmd); registerNetworkPeerCommand(networkCmd); registerNetworkBootstrapCommand(networkCmd); + registerNetworkContractsCommand(networkCmd); } diff --git a/apps/cli/src/cli/commands/seller/index.ts b/apps/cli/src/cli/commands/seller/index.ts index 20e291a2a..5b0edd4a4 100644 --- a/apps/cli/src/cli/commands/seller/index.ts +++ b/apps/cli/src/cli/commands/seller/index.ts @@ -6,6 +6,7 @@ import { registerSellerRegisterCommand } from './register.js'; import { registerSellerStakeCommand } from './stake.js'; import { registerSellerEmissionsCommand } from './emissions.js'; import { registerSellerDoctorCommand } from './doctor.js'; +import { registerSellerPoolCommand } from './pool.js'; export function registerSellerCommands(program: Command): void { const sellerCmd = program @@ -18,5 +19,6 @@ export function registerSellerCommands(program: Command): void { registerSellerRegisterCommand(sellerCmd); registerSellerStakeCommand(sellerCmd); registerSellerEmissionsCommand(sellerCmd); + registerSellerPoolCommand(sellerCmd); registerSellerDoctorCommand(sellerCmd); } diff --git a/apps/cli/src/cli/commands/seller/pool.test.ts b/apps/cli/src/cli/commands/seller/pool.test.ts new file mode 100644 index 000000000..fcabf7c3a --- /dev/null +++ b/apps/cli/src/cli/commands/seller/pool.test.ts @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { positionState, validateStakeEpochs } from './pool.js'; + +const position = { id: 1, owner: '0x1', agentId: 2, amount: 1n, weightAmount: 1n, stakeStartEpoch: 5, stakeEndEpoch: 9, closedAtEpoch: 0, withdrawn: false }; +test('positionState covers pending, active, matured, and withdrawn positions', () => { + assert.equal(positionState(position, 4), 'pending'); + assert.equal(positionState(position, 5), 'active'); + assert.equal(positionState(position, 9), 'matured'); + assert.equal(positionState({ ...position, withdrawn: true }, 6), 'withdrawn'); +}); +test('validateStakeEpochs enforces contract bounds', () => { + assert.doesNotThrow(() => validateStakeEpochs(4, 1, 104)); + assert.throws(() => validateStakeEpochs(0, 1, 104)); + assert.throws(() => validateStakeEpochs(105, 1, 104)); +}); diff --git a/apps/cli/src/cli/commands/seller/pool.ts b/apps/cli/src/cli/commands/seller/pool.ts new file mode 100644 index 000000000..249a12624 --- /dev/null +++ b/apps/cli/src/cli/commands/seller/pool.ts @@ -0,0 +1,225 @@ +import type { Command } from 'commander'; +import chalk from 'chalk'; +import Table from 'cli-table3'; +import ora from 'ora'; +import { getGlobalOptions } from '../types.js'; +import { loadConfig } from '../../../config/loader.js'; +import { + createAntsTokenClient, + createLegacyStakingClient, + createPositionInitClient, + createSellerPoolsClient, + createSellerPoolsRewardsClient, + createSellerRegistryClient, + formatAnts, + loadCryptoContext, + parseAntsToBaseUnits, + resolveCliContractStack, +} from '../../payment-utils.js'; +import type { SellerPoolPosition } from '@antseed/node/payments'; + +export function positionState(position: SellerPoolPosition, currentEpoch: number): string { + if (position.withdrawn) return 'withdrawn'; + if (position.closedAtEpoch !== 0) return 'closed'; + if (currentEpoch < position.stakeStartEpoch) return 'pending'; + if (currentEpoch < position.stakeEndEpoch) return 'active'; + return 'matured'; +} + +export function validateStakeEpochs(epochs: number, min: number, max: number): void { + if (!Number.isInteger(epochs) || epochs < min || epochs > max) { + throw new Error(`--epochs must be an integer between ${min} and ${max}`); + } +} + +async function requirePoolStack(config: Awaited>) { + const stack = await resolveCliContractStack(config); + if (stack.mode !== 'recognized-usage') { + throw new Error('Seller pool commands require the recognized-usage contract stack. Run them after M001 cutover.'); + } + return stack; +} + +export function registerSellerPoolCommand(sellerCmd: Command): void { + const pool = sellerCmd.command('pool').description('Manage recognized-usage ANTS seller-pool positions'); + + pool.command('bootstrap').alias('init').description('Claim the legacy-seller starter ANTS position').action(async () => { + const global = getGlobalOptions(pool); + const config = await loadConfig(global.config); + const spinner = ora('Checking starter position...').start(); + try { + await requirePoolStack(config); + const { wallet, address } = await loadCryptoContext(global.dataDir); + const legacyStaking = createLegacyStakingClient(config); + const agentId = await legacyStaking.getAgentId(address); + if (!agentId) throw new Error('No legacy seller agent ID found for this wallet.'); + const init = createPositionInitClient(config); + if (await init.agentInitialized(agentId)) { + spinner.succeed(chalk.yellow(`Starter position already initialized for agent ${agentId}.`)); + return; + } + const [remaining, amount, endEpoch] = await Promise.all([init.remainingInits(), init.initAmount(), init.initEndEpoch()]); + if (remaining === 0n) throw new Error('Starter position pool is depleted.'); + spinner.text = `Creating ${formatAnts(amount)} ANTS starter position...`; + const txHash = await init.initPosition(wallet); + spinner.succeed(chalk.green(`Starter position created for agent ${agentId} through epoch ${endEpoch}`)); + console.log(chalk.dim(`Transaction: ${txHash}`)); + } catch (error) { + spinner.fail(chalk.red((error as Error).message)); + process.exitCode = 1; + } + }); + + pool.command('stake ') + .description('Stake ANTS into a seller pool (alias of `antseed seller stake`)') + .requiredOption('--epochs ', 'lock duration in epochs', (value) => Number(value)) + .option('--agent-id ', 'seller agent ID', (value) => Number(value)) + .action(async (amount: string, options: PoolStakeOptions) => { + await runPoolStake(getGlobalOptions(pool), amount, options); + }); + + pool.command('positions').description('List your seller-pool positions').option('--json', 'output as JSON', false).action(async (options) => { + const global = getGlobalOptions(pool); + const config = await loadConfig(global.config); + try { + const stack = await requirePoolStack(config); + const { address } = await loadCryptoContext(global.dataDir); + const pools = createSellerPoolsClient(config); + const ids = await pools.stakerPositionIds(address); + const positions = await Promise.all(ids.map(async (id) => { + const position = await pools.position(id); + return { ...position, state: positionState(position, stack.currentEpoch), withdrawableEpoch: await pools.positionWithdrawableEpoch(id) }; + })); + if (options.json) { + console.log(JSON.stringify(positions, (_key, value) => typeof value === 'bigint' ? value.toString() : value, 2)); + return; + } + if (positions.length === 0) { + console.log(chalk.yellow('No seller-pool positions found.')); + return; + } + const table = new Table({ head: ['ID', 'Agent', 'Amount', 'Start', 'End', 'State'] }); + for (const position of positions) table.push([position.id, position.agentId, `${formatAnts(position.amount)} ANTS`, position.stakeStartEpoch, position.stakeEndEpoch, position.state]); + console.log(table.toString()); + } catch (error) { + console.error(chalk.red((error as Error).message)); + process.exitCode = 1; + } + }); + + pool.command('withdraw ').description('Withdraw seller-pool positions').option('--force', 'allow early exit and slashing', false).action(async (rawIds: string[], options) => { + const global = getGlobalOptions(pool); + const config = await loadConfig(global.config); + const spinner = ora('Checking positions...').start(); + try { + const stack = await requirePoolStack(config); + const ids = rawIds.map((value) => Number(value)); + if (ids.some((id) => !Number.isInteger(id) || id <= 0)) throw new Error('Position IDs must be positive integers.'); + const positions = await Promise.all(ids.map((id) => createSellerPoolsClient(config).position(id))); + const early = positions.filter((position) => !position.withdrawn && position.closedAtEpoch === 0 && stack.currentEpoch < position.stakeEndEpoch); + if (early.length > 0 && !options.force) throw new Error(`Position(s) ${early.map((position) => position.id).join(', ')} are still locked; re-run with --force to accept early-exit slashing.`); + const { wallet } = await loadCryptoContext(global.dataDir); + const txHash = await createSellerPoolsClient(config).withdrawStakes(wallet, ids); + spinner.succeed(chalk.green(`Withdrew ${ids.length} position(s)`)); + console.log(chalk.dim(`Transaction: ${txHash}`)); + } catch (error) { + spinner.fail(chalk.red((error as Error).message)); + process.exitCode = 1; + } + }); + + const rewards = pool.command('rewards').description('Show or claim indexed seller-pool rewards').option('--json', 'output as JSON', false); + rewards.action(async (options) => { + const global = getGlobalOptions(rewards); + const config = await loadConfig(global.config); + try { + await requirePoolStack(config); + const { address } = await loadCryptoContext(global.dataDir); + const pools = createSellerPoolsClient(config); + const rewardsClient = createSellerPoolsRewardsClient(config); + const ids = await pools.stakerPositionIds(address); + const pending = await Promise.all(ids.map(async (id) => ({ id, amount: await rewardsClient.pendingIndexedStakerReward(id) }))); + const total = pending.reduce((sum, item) => sum + item.amount, 0n); + if (options.json) console.log(JSON.stringify({ positions: pending, total }, (_key, value) => typeof value === 'bigint' ? value.toString() : value, 2)); + else { + for (const item of pending) console.log(`Position ${item.id}: ${formatAnts(item.amount)} ANTS`); + console.log(chalk.bold(`Total: ${formatAnts(total)} ANTS`)); + } + } catch (error) { + console.error(chalk.red((error as Error).message)); + process.exitCode = 1; + } + }); + + rewards.command('claim').description('Claim indexed rewards').option('--position ', 'claim one position', (value) => Number(value)).option('--recipient
', 'reward recipient').action(async (options) => { + const global = getGlobalOptions(rewards); + const config = await loadConfig(global.config); + const spinner = ora('Checking pending rewards...').start(); + try { + await requirePoolStack(config); + const { wallet, address } = await loadCryptoContext(global.dataDir); + const pools = createSellerPoolsClient(config); + const rewardsClient = createSellerPoolsRewardsClient(config); + const ids = options.position ? [options.position] : await pools.stakerPositionIds(address); + const pendingIds = []; + for (const id of ids) if (await rewardsClient.pendingIndexedStakerReward(id) > 0n) pendingIds.push(id); + if (pendingIds.length === 0) { + spinner.succeed(chalk.yellow('No indexed pool rewards pending.')); + return; + } + const recipient = options.recipient || address; + const txHash = pendingIds.length === 1 + ? await rewardsClient.claimStakerRewards(wallet, pendingIds[0]!, recipient) + : await rewardsClient.claimStakerRewardsBatch(wallet, pendingIds, recipient); + spinner.succeed(chalk.green(`Claimed rewards for ${pendingIds.length} position(s)`)); + console.log(chalk.dim(`Transaction: ${txHash}`)); + } catch (error) { + spinner.fail(chalk.red((error as Error).message)); + process.exitCode = 1; + } + }); +} + +export interface PoolStakeOptions { + epochs: number; + agentId?: number; +} + +/** Stake ANTS into a seller pool. Shared by `seller stake` and `seller pool stake`. */ +export async function runPoolStake( + global: { config: string; dataDir: string }, + amount: string, + options: PoolStakeOptions, +): Promise { + const config = await loadConfig(global.config); + const spinner = ora('Checking pool stake...').start(); + try { + await requirePoolStack(config); + const amountBaseUnits = parseAntsToBaseUnits(amount); + const { wallet, address } = await loadCryptoContext(global.dataDir); + const pools = createSellerPoolsClient(config); + const registry = createSellerRegistryClient(config); + const [minEpochs, maxEpochs] = await Promise.all([pools.minStakeEpochs(), pools.maxStakeEpochs()]); + validateStakeEpochs(options.epochs, minEpochs, maxEpochs); + let agentId = options.agentId || await registry.getAgentId(address); + if (!agentId) agentId = await createLegacyStakingClient(config).getAgentId(address); + if (!agentId) throw new Error('No seller agent ID found. Pass --agent-id or run antseed seller register.'); + const boundAgentId = await registry.getAgentId(address); + if (boundAgentId === 0) { + spinner.text = 'Binding seller registry...'; + await registry.registerSeller(wallet, agentId); + } else if (boundAgentId !== agentId) { + throw new Error(`Seller is bound to agent ${boundAgentId}, not ${agentId}.`); + } + const token = createAntsTokenClient(config); + const balance = await token.balanceOf(address); + if (balance < amountBaseUnits) throw new Error(`Insufficient ANTS balance: have ${formatAnts(balance)}, need ${formatAnts(amountBaseUnits)}.`); + spinner.text = `Staking ${formatAnts(amountBaseUnits)} ANTS for ${options.epochs} epochs...`; + const txHash = await pools.stake(wallet, agentId, amountBaseUnits, options.epochs); + spinner.succeed(chalk.green('ANTS pool position created')); + console.log(chalk.dim(`Transaction: ${txHash}`)); + } catch (error) { + spinner.fail(chalk.red((error as Error).message)); + process.exitCode = 1; + } +} diff --git a/apps/cli/src/cli/commands/seller/register.ts b/apps/cli/src/cli/commands/seller/register.ts index be9293ce7..a3e1da082 100644 --- a/apps/cli/src/cli/commands/seller/register.ts +++ b/apps/cli/src/cli/commands/seller/register.ts @@ -6,6 +6,10 @@ import { loadConfig } from '../../../config/loader.js'; import { createIdentityClient, loadCryptoContext, + resolveCliContractStack, + createSellerRegistryClient, + createLegacyStakingClient, + createStakingClient, } from '../../payment-utils.js'; export function registerSellerRegisterCommand(sellerCmd: Command): void { @@ -13,6 +17,7 @@ export function registerSellerRegisterCommand(sellerCmd: Command): void { .command('register') .description('Register your peer identity on-chain') .option('--metadata ', 'metadata URI (optional)', '') + .option('--agent-id ', 'existing ERC-8004 agent ID', parseInt) .action(async (options) => { const globalOpts = getGlobalOptions(sellerCmd); const config = await loadConfig(globalOpts.config); @@ -21,19 +26,42 @@ export function registerSellerRegisterCommand(sellerCmd: Command): void { try { const { wallet, address } = await loadCryptoContext(globalOpts.dataDir); + const stack = await resolveCliContractStack(config); const identityClient = createIdentityClient(config); console.log(chalk.dim(`Wallet: ${address}`)); const alreadyRegistered = await identityClient.isRegistered(address); - if (alreadyRegistered) { - spinner.succeed(chalk.yellow('Already registered')); - return; + let agentId = options.agentId as number | undefined; + if (!alreadyRegistered) { + spinner.text = 'Registering peer identity...'; + agentId = await identityClient.register(wallet, options.metadata as string || undefined); + spinner.succeed(chalk.green('Peer identity registered')); + } else if (stack.mode === 'legacy') { + agentId = agentId || await createStakingClient(config).getAgentId(address); + } else { + const sellerRegistry = createSellerRegistryClient(config); + agentId = agentId || await sellerRegistry.getAgentId(address); + if (!agentId) agentId = await createLegacyStakingClient(config).getAgentId(address); } - spinner.text = 'Registering peer identity...'; - const agentId = await identityClient.register(wallet, options.metadata as string || undefined); - spinner.succeed(chalk.green('Peer identity registered')); + if (stack.mode === 'recognized-usage') { + if (!agentId) throw new Error('Could not determine agent ID. Pass --agent-id .'); + const sellerRegistry = createSellerRegistryClient(config); + const boundAgentId = await sellerRegistry.getAgentId(address); + if (boundAgentId === 0) { + spinner.start('Binding seller to recognized-usage registry...'); + const txHash = await sellerRegistry.registerSeller(wallet, agentId); + spinner.succeed(chalk.green('Seller bound to recognized-usage registry')); + console.log(chalk.dim(`Transaction: ${txHash}`)); + } else if (boundAgentId !== agentId) { + throw new Error(`Seller is already bound to agent ${boundAgentId}, not ${agentId}.`); + } else if (alreadyRegistered) { + spinner.succeed(chalk.yellow('Already registered and bound')); + } + } else if (alreadyRegistered) { + spinner.succeed(chalk.yellow('Already registered')); + } - console.log(chalk.dim(`Agent ID: ${agentId}`)); + if (agentId) console.log(chalk.dim(`Agent ID: ${agentId}`)); } catch (err) { spinner.fail(chalk.red(`Registration failed: ${(err as Error).message}`)); process.exit(1); diff --git a/apps/cli/src/cli/commands/seller/setup.ts b/apps/cli/src/cli/commands/seller/setup.ts index 57ef74827..f663e442d 100644 --- a/apps/cli/src/cli/commands/seller/setup.ts +++ b/apps/cli/src/cli/commands/seller/setup.ts @@ -259,7 +259,7 @@ export function registerSellerSetupCommand(sellerCmd: Command): void { console.log(chalk.bold('\nNext steps:\n')); console.log(` ${chalk.cyan('1.')} Set credentials: ${chalk.dim(getSellerSetupCredentialHint(pluginName))}`); console.log(` ${chalk.cyan('2.')} Register on-chain: ${chalk.dim('antseed seller register')}`); - console.log(` ${chalk.cyan('3.')} Stake USDC: ${chalk.dim('antseed seller stake 10')}`); + console.log(` ${chalk.cyan('3.')} Stake: ${chalk.dim('antseed seller stake 10')} ${chalk.dim('(add --epochs on recognized-usage networks)')}`); console.log(` ${chalk.cyan('4.')} Start selling: ${chalk.dim('antseed seller start')}`); console.log(''); diff --git a/apps/cli/src/cli/commands/seller/stake.test.ts b/apps/cli/src/cli/commands/seller/stake.test.ts new file mode 100644 index 000000000..c79c7441e --- /dev/null +++ b/apps/cli/src/cli/commands/seller/stake.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Command } from 'commander'; +import { registerSellerCommands } from './index.js'; + +function sellerCommand(): Command { + const program = new Command(); + registerSellerCommands(program); + return program.commands.find((command) => command.name() === 'seller')!; +} + +function findCommand(parent: Command, name: string): Command | undefined { + return parent.commands.find((command) => command.name() === name); +} + +test('seller stake is stack-aware and accepts an ANTS lock duration', () => { + const stake = findCommand(sellerCommand(), 'stake'); + assert.ok(stake); + assert.ok(stake!.options.some((option) => option.long === '--epochs')); + assert.ok(stake!.options.some((option) => option.long === '--agent-id')); +}); + +test('legacy USDC staking lives under seller legacy', () => { + const legacy = findCommand(sellerCommand(), 'legacy'); + assert.ok(legacy); + assert.ok(findCommand(legacy!, 'stake')); + assert.ok(findCommand(legacy!, 'unstake')); +}); + +test('seller unstake remains available as a legacy alias', () => { + assert.ok(findCommand(sellerCommand(), 'unstake')); +}); + +test('pool bootstrap replaces pool init but keeps the old name as an alias', () => { + const pool = findCommand(sellerCommand(), 'pool')!; + const bootstrap = findCommand(pool, 'bootstrap'); + assert.ok(bootstrap); + assert.ok(bootstrap!.aliases().includes('init')); + assert.ok(findCommand(pool, 'stake')); +}); diff --git a/apps/cli/src/cli/commands/seller/stake.ts b/apps/cli/src/cli/commands/seller/stake.ts index 6eeaf92a7..04f62ade1 100644 --- a/apps/cli/src/cli/commands/seller/stake.ts +++ b/apps/cli/src/cli/commands/seller/stake.ts @@ -9,90 +9,150 @@ import { loadCryptoContext, formatUsdc, parseUsdcToBaseUnits, + resolveCliContractStack, + createLegacyStakingClient, } from '../../payment-utils.js'; +import { runPoolStake } from './pool.js'; + +type GlobalOptions = ReturnType; + +async function runLegacyStake( + global: GlobalOptions, + amount: string, + options: { agentId?: number }, +): Promise { + const config = await loadConfig(global.config); + + let amountBaseUnits: bigint; + try { + amountBaseUnits = parseUsdcToBaseUnits(amount); + } catch { + console.error(chalk.red('Error: Amount must be a positive number.')); + process.exit(1); + } + + const spinner = ora('Verifying registration...').start(); + + try { + const stack = await resolveCliContractStack(config); + if (stack.mode === 'recognized-usage') { + spinner.fail(chalk.red('Legacy USDC staking is closed after cutover. Use: antseed seller stake --epochs ')); + process.exit(1); + } + const { wallet, address } = await loadCryptoContext(global.dataDir); + const stakingClient = createStakingClient(config); + const identityClient = createIdentityClient(config); + const isReg = await identityClient.isRegistered(address); + if (!isReg) { + spinner.fail(chalk.red('Not registered. Run: antseed seller register')); + process.exit(1); + } + + // Look up agentId from staking contract, or use --agent-id for first-time staking + let agentId = await stakingClient.getAgentId(address); + if (agentId === 0 && options.agentId) { + agentId = options.agentId; + } + if (agentId === 0) { + spinner.fail(chalk.red('No agentId found. Pass --agent-id from your antseed seller register output.')); + process.exit(1); + } + + const amountFloat = parseFloat(amount); + console.log(chalk.dim(`Wallet: ${address}`)); + console.log(chalk.dim(`Agent ID: ${agentId}`)); + console.log(chalk.dim(`Amount: ${amountFloat} USDC (${amountBaseUnits} base units)`)); + + spinner.text = 'Staking USDC...'; + const txHash = await stakingClient.stake(wallet, agentId, amountBaseUnits); + spinner.succeed(chalk.green(`Staked ${amountFloat} USDC`)); + console.log(chalk.dim(`Transaction: ${txHash}`)); + } catch (err) { + spinner.fail(chalk.red(`Staking failed: ${(err as Error).message}`)); + process.exit(1); + } +} + +async function runLegacyUnstake(global: GlobalOptions): Promise { + const config = await loadConfig(global.config); + const spinner = ora('Fetching stake info...').start(); + + try { + const stack = await resolveCliContractStack(config); + const { wallet, address } = await loadCryptoContext(global.dataDir); + const stakingClient = stack.mode === 'recognized-usage' + ? createLegacyStakingClient(config) + : createStakingClient(config); + console.log(chalk.dim(`Wallet: ${address}`)); + if (stack.mode === 'recognized-usage') { + console.log(chalk.yellow('⚠ Withdrawing legacy USDC stake may remove temporary post-cutover eligibility until your ANTS pool is active.')); + } + const stake = await stakingClient.getStake(address); + if (stake === 0n) { + spinner.fail(chalk.yellow('No active stake to withdraw.')); + return; + } + + console.log(chalk.dim(`Current stake: ${formatUsdc(stake)} USDC`)); + + spinner.text = 'Unstaking...'; + const txHash = await stakingClient.unstake(wallet); + spinner.succeed(chalk.green('Unstaked successfully')); + console.log(chalk.dim(`Transaction: ${txHash}`)); + } catch (err) { + spinner.fail(chalk.red(`Unstake failed: ${(err as Error).message}`)); + process.exit(1); + } +} export function registerSellerStakeCommand(sellerCmd: Command): void { sellerCmd .command('stake ') - .description('Stake USDC as a provider (amount in human-readable USDC, e.g. "10" = 10 USDC)') - .option('--agent-id ', 'ERC-8004 agent ID (from antseed seller register output)', parseInt) - .action(async (amount: string, options: { agentId?: number }) => { + .description('Stake as a provider — ANTS into your seller pool after cutover, USDC before it') + .option('--epochs ', 'ANTS lock duration in epochs (recognized-usage stack)', (value) => Number(value)) + .option('--agent-id ', 'seller agent ID (from antseed seller register output)', parseInt) + .action(async (amount: string, options: { epochs?: number; agentId?: number }) => { const globalOpts = getGlobalOptions(sellerCmd); const config = await loadConfig(globalOpts.config); + const stack = await resolveCliContractStack(config); - let amountBaseUnits: bigint; - try { - amountBaseUnits = parseUsdcToBaseUnits(amount); - } catch { - console.error(chalk.red('Error: Amount must be a positive number.')); - process.exit(1); - } - - const spinner = ora('Verifying registration...').start(); - - try { - const { wallet, address } = await loadCryptoContext(globalOpts.dataDir); - const stakingClient = createStakingClient(config); - const identityClient = createIdentityClient(config); - const isReg = await identityClient.isRegistered(address); - if (!isReg) { - spinner.fail(chalk.red('Not registered. Run: antseed seller register')); - process.exit(1); - } - - // Look up agentId from staking contract, or use --agent-id for first-time staking - let agentId = await stakingClient.getAgentId(address); - if (agentId === 0 && options.agentId) { - agentId = options.agentId; - } - if (agentId === 0) { - spinner.fail(chalk.red('No agentId found. Pass --agent-id from your antseed seller register output.')); + if (stack.mode === 'recognized-usage') { + if (options.epochs === undefined) { + console.error(chalk.red('ANTS pool staking requires a lock duration. Use: antseed seller stake --epochs ')); process.exit(1); } + await runPoolStake(globalOpts, amount, { epochs: options.epochs, agentId: options.agentId }); + return; + } - const amountFloat = parseFloat(amount); - console.log(chalk.dim(`Wallet: ${address}`)); - console.log(chalk.dim(`Agent ID: ${agentId}`)); - console.log(chalk.dim(`Amount: ${amountFloat} USDC (${amountBaseUnits} base units)`)); - - spinner.text = 'Staking USDC...'; - const txHash = await stakingClient.stake(wallet, agentId, amountBaseUnits); - spinner.succeed(chalk.green(`Staked ${amountFloat} USDC`)); - console.log(chalk.dim(`Transaction: ${txHash}`)); - } catch (err) { - spinner.fail(chalk.red(`Staking failed: ${(err as Error).message}`)); + if (options.epochs !== undefined) { + console.error(chalk.red('--epochs applies to ANTS pool staking, which is only available after the recognized-usage cutover.')); process.exit(1); } + await runLegacyStake(globalOpts, amount, { agentId: options.agentId }); }); sellerCmd .command('unstake') - .description('Unstake USDC (subject to slash conditions)') + .description('Withdraw legacy USDC stake (alias of `antseed seller legacy unstake`)') .action(async () => { - const globalOpts = getGlobalOptions(sellerCmd); - const config = await loadConfig(globalOpts.config); + await runLegacyUnstake(getGlobalOptions(sellerCmd)); + }); - const spinner = ora('Fetching stake info...').start(); + const legacy = sellerCmd.command('legacy').description('Legacy USDC staking commands'); - try { - const { wallet, address } = await loadCryptoContext(globalOpts.dataDir); - const stakingClient = createStakingClient(config); - console.log(chalk.dim(`Wallet: ${address}`)); - const stake = await stakingClient.getStake(address); - if (stake === 0n) { - spinner.fail(chalk.yellow('No active stake to withdraw.')); - return; - } - - console.log(chalk.dim(`Current stake: ${formatUsdc(stake)} USDC`)); + legacy + .command('stake ') + .description('Stake USDC as a provider (legacy stack only, e.g. "10" = 10 USDC)') + .option('--agent-id ', 'ERC-8004 agent ID (from antseed seller register output)', parseInt) + .action(async (amount: string, options: { agentId?: number }) => { + await runLegacyStake(getGlobalOptions(legacy), amount, options); + }); - spinner.text = 'Unstaking...'; - const txHash = await stakingClient.unstake(wallet); - spinner.succeed(chalk.green('Unstaked successfully')); - console.log(chalk.dim(`Transaction: ${txHash}`)); - } catch (err) { - spinner.fail(chalk.red(`Unstake failed: ${(err as Error).message}`)); - process.exit(1); - } + legacy + .command('unstake') + .description('Withdraw legacy USDC stake (subject to slash conditions)') + .action(async () => { + await runLegacyUnstake(getGlobalOptions(legacy)); }); } diff --git a/apps/cli/src/cli/commands/seller/status.ts b/apps/cli/src/cli/commands/seller/status.ts index 42e5eada5..d96c98fc9 100644 --- a/apps/cli/src/cli/commands/seller/status.ts +++ b/apps/cli/src/cli/commands/seller/status.ts @@ -5,7 +5,15 @@ import { getGlobalOptions } from '../types.js'; import { loadConfig } from '../../../config/loader.js'; import { resolveEffectiveSellerConfig } from '../../../config/effective.js'; import { getNodeStatus } from '../../../status/node-status.js'; -import { loadCryptoContext } from '../../payment-utils.js'; +import { + createLegacyStakingClient, + createSellerPoolsClient, + createSellerRegistryClient, + createStakingClient, + formatUsdc, + loadCryptoContext, + resolveCliContractStack, +} from '../../payment-utils.js'; import { formatEarnings, formatTokens } from '../../formatters.js'; type SellerNodeState = 'seeding' | 'connected' | 'idle'; @@ -43,6 +51,34 @@ export function registerSellerStatusCommand(sellerCmd: Command): void { }; }); + let onChain: Record | null = null; + let onChainError: string | null = null; + if (walletAddress) { + try { + const stack = await resolveCliContractStack(config); + if (stack.mode === 'recognized-usage') { + const registry = createSellerRegistryClient(config); + const pools = createSellerPoolsClient(config); + const agentId = await registry.getAgentId(walletAddress); + const [legacyStake, activePoolStake, eligible, positionCount] = await Promise.all([ + createLegacyStakingClient(config).getStake(walletAddress), + agentId ? pools.poolActiveStakeAtEpoch(agentId, stack.currentEpoch) : 0n, + registry.isStakedAboveMin(walletAddress), + pools.stakerPositionCount(walletAddress), + ]); + onChain = { mode: stack.mode, agentId, legacyStake, activePoolStake, eligible, positionCount }; + } else { + const staking = createStakingClient(config); + const [agentId, legacyStake, eligible] = await Promise.all([ + staking.getAgentId(walletAddress), staking.getStake(walletAddress), staking.isStakedAboveMin(walletAddress), + ]); + onChain = { mode: stack.mode, agentId, legacyStake, activePoolStake: 0n, eligible, positionCount: 0 }; + } + } catch (error) { + onChainError = `${(error as Error).name}: ${(error as Error).message}`; + } + } + if (options.json) { console.log(JSON.stringify({ state: status.state, @@ -54,7 +90,9 @@ export function registerSellerStatusCommand(sellerCmd: Command): void { walletAddress, notices: status.notices, providers: providerSummary, - }, null, 2)); + onChain, + onChainError, + }, (_key, value) => typeof value === 'bigint' ? value.toString() : value, 2)); return; } @@ -91,6 +129,19 @@ export function registerSellerStatusCommand(sellerCmd: Command): void { : chalk.dim('(none)'), ]); + if (onChain) { + table.push( + ['On-chain mode', String(onChain.mode)], + ['Agent ID', String(onChain.agentId)], + ['Legacy USDC stake', `${formatUsdc(onChain.legacyStake as bigint)} USDC`], + ['Pool active stake', `${formatAntsForStatus(onChain.activePoolStake as bigint)} ANTS`], + ['Seller eligible', String(onChain.eligible)], + ['Pool positions', String(onChain.positionCount)], + ); + } else if (onChainError) { + table.push(['On-chain warning', chalk.yellow(onChainError)]); + } + console.log(table.toString()); } catch (err) { console.error(chalk.red(`Error: ${(err as Error).message}`)); @@ -98,3 +149,9 @@ export function registerSellerStatusCommand(sellerCmd: Command): void { } }); } + +function formatAntsForStatus(amount: bigint): string { + const whole = amount / 10n ** 18n; + const fraction = (amount % 10n ** 18n).toString().padStart(18, '0').slice(0, 4).replace(/0+$/, ''); + return fraction ? `${whole}.${fraction}` : String(whole); +} diff --git a/apps/cli/src/cli/payment-utils.ts b/apps/cli/src/cli/payment-utils.ts index 2a59fec91..4524331fb 100644 --- a/apps/cli/src/cli/payment-utils.ts +++ b/apps/cli/src/cli/payment-utils.ts @@ -4,12 +4,22 @@ import { ChannelsClient, StakingClient, DepositRelayClient, + ANTSTokenClient, loadOrCreateIdentity, resolveChainConfig, + resolveContractStack, + type ContractStackResolution, } from '@antseed/node'; import { IdentityClient, EmissionsClient, + UsageAccountingClient, + UsageRewardsClient, + SellerPoolsClient, + SellerPoolsRewardsClient, + SellerRegistryClient, + PositionInitClient, + EmissionsGateClient, ChannelStore, } from '@antseed/node/payments'; import type { Identity } from '@antseed/node'; @@ -71,6 +81,17 @@ export function formatAnts(baseUnits: bigint): string { return `${whole}.${fracStr}`; } +/** Parse a positive human-readable ANTS amount into 18-decimal base units. */ +export function parseAntsToBaseUnits(amount: string): bigint { + const match = amount.trim().match(/^(\d+)(?:\.(\d{1,18}))?$/); + if (!match) throw new Error('Amount must be a positive number with at most 18 decimals.'); + const whole = BigInt(match[1] ?? '0'); + const fraction = (match[2] ?? '').padEnd(18, '0'); + const baseUnits = whole * 10n ** 18n + BigInt(fraction || '0'); + if (baseUnits <= 0n) throw new Error('Amount must be a positive number.'); + return baseUnits; +} + /** Format USDC base units (6 decimals) to human-readable string. */ export function formatUsdc(baseUnits: bigint): string { const whole = baseUnits / 1_000_000n; @@ -106,6 +127,19 @@ type ResolvedCryptoConfig = NonNullable & { stakingContractAddress?: string; identityRegistryAddress?: string; emissionsContractAddress?: string; + legacyEmissionsContractAddress?: string; + legacyStakingContractAddress?: string; + legacyEmissionsV1ContractAddress?: string; + antsTokenAddress?: string; + registryContractAddress?: string; + emissionsGateAddress?: string; + sellerPoolsAddress?: string; + sellerRegistryAddress?: string; + positionInitAddress?: string; + usageAccountingAddress?: string; + usageRewardsAddress?: string; + sellerPoolsRewardsAddress?: string; + legacyEmissionsEscrowAddress?: string; depositRelayAddress?: string; evmChainId: number; }; @@ -153,12 +187,44 @@ export function requireCryptoConfig( channelsContractAddress: crypto.channelsContractAddress || resolved.channelsContractAddress, stakingContractAddress: crypto.stakingContractAddress || resolved.stakingContractAddress, emissionsContractAddress: crypto.emissionsContractAddress || resolved.emissionsContractAddress, + legacyEmissionsContractAddress: crypto.legacyEmissionsContractAddress || resolved.legacyEmissionsContractAddress, + legacyStakingContractAddress: crypto.legacyStakingContractAddress || resolved.legacyStakingContractAddress, + legacyEmissionsV1ContractAddress: crypto.legacyEmissionsV1ContractAddress || resolved.legacyEmissionsV1ContractAddress, + antsTokenAddress: crypto.antsTokenAddress || resolved.antsTokenAddress, + registryContractAddress: crypto.registryContractAddress || resolved.registryContractAddress, + emissionsGateAddress: crypto.emissionsGateAddress || resolved.emissionsGateAddress, + sellerPoolsAddress: crypto.sellerPoolsAddress || resolved.sellerPoolsAddress, + sellerRegistryAddress: crypto.sellerRegistryAddress || resolved.sellerRegistryAddress, + positionInitAddress: crypto.positionInitAddress || resolved.positionInitAddress, + usageAccountingAddress: crypto.usageAccountingAddress || resolved.usageAccountingAddress, + usageRewardsAddress: crypto.usageRewardsAddress || resolved.usageRewardsAddress, + sellerPoolsRewardsAddress: crypto.sellerPoolsRewardsAddress || resolved.sellerPoolsRewardsAddress, + legacyEmissionsEscrowAddress: crypto.legacyEmissionsEscrowAddress || resolved.legacyEmissionsEscrowAddress, identityRegistryAddress: crypto.identityRegistryAddress || resolved.identityRegistryAddress, depositRelayAddress: crypto.depositRelayAddress || resolved.depositRelayAddress, evmChainId: resolved.evmChainId, }; } +export function requireContractAddress( + crypto: ReturnType, + field: keyof ReturnType, + name: string, +): string { + const address = crypto[field]; + if (typeof address !== 'string' || !address) { + throw new Error(`\`${name}\` address not configured for chain \`${crypto.chainId}\``); + } + return address; +} + +export function resolveCliContractStack( + config: AntseedConfig, + overrides?: CryptoConfigOverrides, +): Promise { + return resolveContractStack(requireCryptoConfig(config, overrides)); +} + function fallbackClientOpts(crypto: ReturnType) { return crypto.fallbackRpcUrls && crypto.fallbackRpcUrls.length > 0 ? { fallbackRpcUrls: crypto.fallbackRpcUrls } @@ -241,6 +307,71 @@ export function createEmissionsClient(config: AntseedConfig, overrides?: CryptoC }); } +function contractClientConfig(crypto: ReturnType, contractAddress: string) { + return { + rpcUrl: crypto.rpcUrl, + ...fallbackClientOpts(crypto), + contractAddress, + evmChainId: crypto.evmChainId, + }; +} + +export function createUsageAccountingClient(config: AntseedConfig): UsageAccountingClient { + const crypto = requireCryptoConfig(config); + return new UsageAccountingClient(contractClientConfig(crypto, requireContractAddress(crypto, 'usageAccountingAddress', 'usageAccounting'))); +} + +export function createUsageRewardsClient(config: AntseedConfig): UsageRewardsClient { + const crypto = requireCryptoConfig(config); + return new UsageRewardsClient(contractClientConfig(crypto, requireContractAddress(crypto, 'usageRewardsAddress', 'usageRewards'))); +} + +export function createSellerPoolsClient(config: AntseedConfig): SellerPoolsClient { + const crypto = requireCryptoConfig(config); + return new SellerPoolsClient({ + ...contractClientConfig(crypto, requireContractAddress(crypto, 'sellerPoolsAddress', 'sellerPools')), + antsTokenAddress: requireContractAddress(crypto, 'antsTokenAddress', 'antsToken'), + }); +} + +export function createSellerPoolsRewardsClient(config: AntseedConfig): SellerPoolsRewardsClient { + const crypto = requireCryptoConfig(config); + return new SellerPoolsRewardsClient(contractClientConfig(crypto, requireContractAddress(crypto, 'sellerPoolsRewardsAddress', 'sellerPoolsRewards'))); +} + +export function createSellerRegistryClient(config: AntseedConfig): SellerRegistryClient { + const crypto = requireCryptoConfig(config); + return new SellerRegistryClient(contractClientConfig(crypto, requireContractAddress(crypto, 'sellerRegistryAddress', 'sellerRegistry'))); +} + +export function createPositionInitClient(config: AntseedConfig): PositionInitClient { + const crypto = requireCryptoConfig(config); + return new PositionInitClient(contractClientConfig(crypto, requireContractAddress(crypto, 'positionInitAddress', 'positionInit'))); +} + +export function createEmissionsGateClient(config: AntseedConfig): EmissionsGateClient { + const crypto = requireCryptoConfig(config); + return new EmissionsGateClient(contractClientConfig(crypto, requireContractAddress(crypto, 'emissionsGateAddress', 'emissionsGate'))); +} + +export function createAntsTokenClient(config: AntseedConfig): ANTSTokenClient { + const crypto = requireCryptoConfig(config); + return new ANTSTokenClient(contractClientConfig(crypto, requireContractAddress(crypto, 'antsTokenAddress', 'antsToken'))); +} + +export function createLegacyEmissionsClient(config: AntseedConfig): EmissionsClient { + const crypto = requireCryptoConfig(config); + return new EmissionsClient(contractClientConfig(crypto, requireContractAddress(crypto, 'legacyEmissionsContractAddress', 'legacyEmissions'))); +} + +export function createLegacyStakingClient(config: AntseedConfig): StakingClient { + const crypto = requireCryptoConfig(config); + return new StakingClient({ + ...contractClientConfig(crypto, requireContractAddress(crypto, 'legacyStakingContractAddress', 'legacyStaking')), + usdcAddress: crypto.usdcContractAddress, + }); +} + /** * Create a DepositRelayClient from the CLI config. */ diff --git a/apps/cli/src/config/types.ts b/apps/cli/src/config/types.ts index 712494b22..bd64c47b8 100644 --- a/apps/cli/src/config/types.ts +++ b/apps/cli/src/config/types.ts @@ -265,6 +265,8 @@ export interface PaymentsCLIConfig { depositsContractAddress?: string; /** Deployed AntseedChannels contract address override */ channelsContractAddress?: string; + /** Deployed AntseedRegistry contract address */ + registryContractAddress?: string; /** Deployed AntseedFreeUsage contract address override */ freeUsageContractAddress?: string; /** Deployed AntseedStaking contract address */ @@ -275,6 +277,18 @@ export interface PaymentsCLIConfig { identityRegistryAddress?: string; /** Deployed AntseedEmissions contract address */ emissionsContractAddress?: string; + legacyEmissionsContractAddress?: string; + legacyStakingContractAddress?: string; + legacyEmissionsV1ContractAddress?: string; + antsTokenAddress?: string; + emissionsGateAddress?: string; + sellerPoolsAddress?: string; + sellerRegistryAddress?: string; + positionInitAddress?: string; + usageAccountingAddress?: string; + usageRewardsAddress?: string; + sellerPoolsRewardsAddress?: string; + legacyEmissionsEscrowAddress?: string; /** Deployed AntseedDepositRelay contract address (gasless deposit sweeps) */ depositRelayAddress?: string; /** Default lock amount per session in human-readable USDC (e.g. "1" = 1 USDC) */ diff --git a/apps/website/docs/cli/commands.md b/apps/website/docs/cli/commands.md index 2e029c7eb..3f4b3002e 100644 --- a/apps/website/docs/cli/commands.md +++ b/apps/website/docs/cli/commands.md @@ -24,8 +24,14 @@ antseed seller start Start providing AI services antseed seller start --base-rpc-url Use a custom Base RPC URL for this run antseed seller register Register peer identity on-chain (ERC-8004) -antseed seller stake Stake USDC as a provider (min $10) -antseed seller unstake Withdraw staked USDC +antseed seller stake --epochs + Stake ANTS into your seller pool (post-cutover) +antseed seller legacy stake Stake USDC as a provider (pre-cutover, min $10) +antseed seller legacy unstake Withdraw legacy USDC stake +antseed seller pool bootstrap Claim the legacy-seller starter ANTS position +antseed seller pool positions List pool positions +antseed seller pool rewards [claim] View or claim indexed pool rewards +antseed seller pool withdraw Withdraw pool positions (`--force` for early exit) antseed seller emissions claim Claim accumulated seller payouts ``` @@ -55,6 +61,7 @@ antseed peer View a peer's profile antseed profile Manage your peer profile antseed buyer channels List payment channels antseed seller emissions info View epoch info and ANTS emissions +antseed network contracts Verify configured contracts against the registry antseed network bootstrap Run a dedicated DHT bootstrap node antseed buyer connection Manage connection settings antseed dev Run seller + buyer locally for testing diff --git a/apps/website/docs/guides/become-a-provider.md b/apps/website/docs/guides/become-a-provider.md index 88e37c8a2..068ef5070 100644 --- a/apps/website/docs/guides/become-a-provider.md +++ b/apps/website/docs/guides/become-a-provider.md @@ -14,7 +14,7 @@ AntSeed is designed for providers who build differentiated services — such as ::: :::info Seller ANTS emissions -Starting from the current epoch, seller ANTS emissions are tracked but routed into a dedicated Provider Pool and locked for now. These incentives are not freely claimable yet. Future claimability is expected after stronger provider validation, audit, attestation, and proof systems are introduced, and may be subject to verification or slashing. +The CLI verifies the active contract stack through `AntseedRegistry`. On the legacy stack, seller rewards follow the existing emissions path. After the recognized-usage cutover, finalized legacy rewards remain claimable and new seller/operator plus pool-staker rewards use the recognized-usage contracts. ::: ## Prerequisites @@ -213,13 +213,24 @@ antseed seller status # Register your identity on-chain (ERC-8004) antseed seller register -# Stake USDC (minimum $10) -antseed seller stake 10 +# Stake USDC (minimum $10, legacy stack) +antseed seller legacy stake 10 # Verify everything is ready antseed seller status ``` +On networks that have completed the recognized-usage cutover, use an ANTS seller pool instead of creating new legacy USDC stake: + +```bash +antseed seller register +antseed seller pool bootstrap +antseed seller stake 100 --epochs 4 +antseed seller pool positions +``` + +Pool stake activates after the contract's activation delay. The CLI reports positions as pending, active, matured, closed, or withdrawn and refuses an early withdrawal unless `--force` is supplied to acknowledge slashing. + ## 7. Add Your Services Everything you announce on the network lives in `config.json` under `seller.providers[name].services[id]`. One block per upstream provider plugin, one entry per service. The `add-service` command builds this for you: @@ -342,7 +353,7 @@ This release emits discovery metadata v12. Older buyers reject newer metadata an USDC earnings are paid directly to your wallet address on each `settle()` or `close()` call. No claim step needed for USDC. -Seller-side ANTS emissions are different: they are currently tracked but locked in the Provider Pool while stronger validation systems are developed. Provider ANTS claims may become available later and may be subject to verification or slashing. +Seller-side ANTS rewards are epoch-based. The CLI claims finalized legacy epochs from the legacy emissions contract and, after recognized-usage cutover, claims new operator rewards while exposing separately indexed seller-pool staker rewards. :::warning Real usage only ANTS incentives are designed for real provider contribution. Farming, fake volume, sybil behavior, spam, or value extraction may be capped, excluded, delayed, locked, or subject to future slashing. diff --git a/apps/website/docs/guides/payments.md b/apps/website/docs/guides/payments.md index f5dbcce58..2d036cd89 100644 --- a/apps/website/docs/guides/payments.md +++ b/apps/website/docs/guides/payments.md @@ -118,13 +118,24 @@ Sellers relay buyer deposit sweeps by default: the node verifies and simulates e Providers must stake a minimum of $10 USDC to participate: ```bash -antseed seller stake 10 +antseed seller legacy stake 10 ``` +When the configured network registry has moved to the recognized-usage stack, the CLI rejects new legacy USDC stakes and directs sellers to ANTS pools instead: + +```bash +antseed seller register +antseed seller pool bootstrap +antseed seller stake 100 --epochs 4 +antseed seller pool positions +``` + +The CLI verifies `AntseedRegistry.emissions()` and `staking()` before stack-aware commands. A mismatch between the registry and `payments.crypto` address overrides fails loudly instead of silently selecting another contract stack. `antseed network contracts` shows the active mode and pointer matches. + Staking binds your wallet to an on-chain agent identity (ERC-8004). To withdraw your stake: ```bash -antseed seller unstake +antseed seller legacy unstake ``` ### ANTS Token Emissions @@ -141,6 +152,8 @@ Check your pending emissions: antseed seller emissions info ``` +After cutover, emissions commands include finalized rewards from both stacks. `--legacy-only` and `--new-only` restrict reads or claims when an operator wants to process the stacks separately. + ## Contract Addresses (Base Mainnet) | Contract | Address | diff --git a/e2e/tests/m001-cli.e2e.test.ts b/e2e/tests/m001-cli.e2e.test.ts new file mode 100644 index 000000000..c950640d2 --- /dev/null +++ b/e2e/tests/m001-cli.e2e.test.ts @@ -0,0 +1,128 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { loadOrCreateIdentity } from '../../packages/node/src/p2p/identity.js'; + +const execFile = promisify(execFileCallback); +const existingSandboxOut = process.env.M001_SANDBOX_OUT; +const enabled = Boolean( + process.env.M001_SANDBOX_RPC + && process.env.ANTS_HOLDER + && (process.env.BASE_MAINNET_RPC_URL || existingSandboxOut), +); +const root = resolve(__dirname, '../..'); +const cli = join(root, 'apps/cli/dist/cli/index.js'); + +describe.skipIf(!enabled)('M001 CLI sandbox', () => { + let out: string; + let dataDir: string; + let config: string; + let walletAddress: string; + let ownsSandbox = false; + + async function run(command: string[], expectFailure = false): Promise { + try { + const result = await execFile(process.execPath, [cli, '--config', config, '--data-dir', dataDir, ...command], { + cwd: root, + env: process.env, + }); + if (expectFailure) throw new Error(`Expected failure: ${command.join(' ')}`); + return `${result.stdout}${result.stderr}`; + } catch (error) { + if (!expectFailure) throw error; + const failure = error as Error & { stdout?: string; stderr?: string }; + return `${failure.stdout ?? ''}${failure.stderr ?? ''}`; + } + } + + async function runJson(command: string[]): Promise { + const result = await execFile(process.execPath, [cli, '--config', config, '--data-dir', dataDir, ...command], { + cwd: root, + env: process.env, + }); + return JSON.parse(result.stdout); + } + + async function sandbox(command: string[]) { + return execFile('pnpm', ['m001:sandbox', ...command, '--out', out], { cwd: root, env: process.env }); + } + + beforeAll(async () => { + ownsSandbox = !existingSandboxOut; + out = existingSandboxOut ? resolve(existingSandboxOut) : await mkdtemp(join(tmpdir(), 'antseed-m001-sandbox-')); + dataDir = await mkdtemp(join(tmpdir(), 'antseed-m001-cli-')); + config = join(out, 'cli-config.json'); + await execFile('pnpm', ['--filter', '@antseed/cli', 'build'], { cwd: root, env: process.env }); + const identity = await loadOrCreateIdentity(dataDir); + walletAddress = identity.wallet.address; + if (ownsSandbox) { + const port = new URL(process.env.M001_SANDBOX_RPC!).port || '8545'; + await execFile('pnpm', ['m001:sandbox', 'up', '--port', port, '--out', out], { cwd: root, env: process.env }); + } + await sandbox(['fund-seller', walletAddress]); + }, 900_000); + + afterAll(async () => { + if (ownsSandbox) { + try { await sandbox(['down']); } catch {} + await rm(out, { recursive: true, force: true }); + } + await rm(dataDir, { recursive: true, force: true }); + }); + + it('rehearses legacy, cutover, pools, dual emissions, and mismatch rejection', async () => { + const registration = await run(['seller', 'register']); + const agentId = registration.match(/Agent ID:\s*(\d+)/)?.[1]; + expect(agentId).toBeTruthy(); + await run(['seller', 'legacy', 'stake', '10', '--agent-id', agentId!]); + expect(await run(['seller', 'emissions', 'info'])).toContain('legacy'); + expect(await run(['seller', 'pool', 'bootstrap'], true)).toContain('recognized-usage'); + expect(await run(['network', 'contracts'])).toContain('✓'); + + await sandbox(['cutover']); + expect(await run(['network', 'contracts'])).toContain('recognized-usage'); + expect(await run(['seller', 'legacy', 'stake', '10'], true)).toContain('antseed seller stake'); + expect(await run(['seller', 'stake', '10'], true)).toContain('--epochs'); + await sandbox(['fund-position-init', '5']); + await run(['seller', 'pool', 'bootstrap']); + expect(await run(['seller', 'pool', 'positions'])).toContain('pending'); + await run(['seller', 'register', '--agent-id', agentId!]); + await run(['seller', 'register', '--agent-id', agentId!]); + + await sandbox(['advance-epoch', '2']); + await sandbox(['fund-ants', walletAddress, '100']); + await run(['seller', 'stake', '100', '--epochs', '4', '--agent-id', agentId!]); + const positionsJson = await runJson(['seller', 'pool', 'positions', '--json']) as Array<{ id: number }>; + expect(positionsJson.length).toBeGreaterThanOrEqual(2); + await run(['seller', 'pool', 'rewards', '--json']); + await run(['seller', 'pool', 'rewards', 'claim']); + const newestId = String(positionsJson.at(-1).id); + expect(await run(['seller', 'pool', 'withdraw', newestId], true)).toContain('--force'); + await sandbox(['advance-epoch', '1']); + await run(['seller', 'pool', 'withdraw', newestId, '--force']); + + const sellerInfo = await runJson(['seller', 'emissions', 'info', '--json']) as { + mode: string; + legacy: unknown; + recognizedUsage: unknown; + }; + expect(sellerInfo.mode).toBe('recognized-usage'); + expect(sellerInfo).toHaveProperty('legacy'); + expect(sellerInfo).toHaveProperty('recognizedUsage'); + await run(['seller', 'emissions', 'claim', '--legacy-only']); + await run(['seller', 'emissions', 'claim', '--new-only']); + await run(['buyer', 'emissions', 'info']); + + const broken = JSON.parse(await readFile(config, 'utf8')); + broken.payments.crypto.usageAccountingAddress = '0x0000000000000000000000000000000000000001'; + const brokenConfig = join(out, 'broken-cli-config.json'); + await writeFile(brokenConfig, JSON.stringify(broken, null, 2)); + const original = config; + config = brokenConfig; + expect(await run(['network', 'contracts'], true)).toContain('ContractStackMismatchError'); + config = original; + }, 900_000); +}); diff --git a/package.json b/package.json index ecb91b5ca..26b8044a6 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "contracts:deploy": "node scripts/deploy-contracts.mjs", "contracts:snapshot": "cd packages/contracts && FOUNDRY_PROFILE=snapshot forge test", "contracts:check": "node scripts/check-contracts.mjs", + "m001:sandbox": "node scripts/m001-sandbox.mjs", "clean": "node ./scripts/remove-paths.mjs apps/cli/dist apps/desktop/dist apps/diem-staking/dist apps/network-stats/dist apps/relay/dist apps/website/build e2e/dist packages/api-adapter/dist packages/web-sdk/dist packages/buyer-core/dist packages/protocol/dist packages/bound-agent/dist packages/node/dist packages/provider-core/dist packages/router-core/dist plugins/provider-anthropic/dist plugins/provider-claude-code/dist plugins/provider-claude-oauth/dist plugins/provider-local-llm/dist plugins/provider-openai/dist plugins/provider-openai-responses/dist plugins/router-local/dist", "publish:all": "pnpm run build && pnpm -r publish --no-git-checks", "publish:dry": "pnpm run build && pnpm -r publish --no-git-checks --dry-run", diff --git a/packages/buyer-core/src/base-evm-client.ts b/packages/buyer-core/src/base-evm-client.ts index d7bb9f3a6..9837190ad 100644 --- a/packages/buyer-core/src/base-evm-client.ts +++ b/packages/buyer-core/src/base-evm-client.ts @@ -12,7 +12,7 @@ import { } from 'ethers'; const FALLBACK_STALL_TIMEOUT_MS = 750; -const JSON_RPC_REQUEST_TIMEOUT_MS = 2_500; +const JSON_RPC_REQUEST_TIMEOUT_MS = 10_000; function createJsonRpcProvider(url: string, network?: Network, opts?: object): JsonRpcProvider { const request = new FetchRequest(url); diff --git a/packages/contracts/script/migrations/M001RecognizedUsage/README.md b/packages/contracts/script/migrations/M001RecognizedUsage/README.md index 4595454a2..a3be5e037 100644 --- a/packages/contracts/script/migrations/M001RecognizedUsage/README.md +++ b/packages/contracts/script/migrations/M001RecognizedUsage/README.md @@ -129,3 +129,35 @@ Writes `history/001-recognized-usage-activated.json` and updates after legacy claim activity has wound down. - Fallback for locked-path stragglers: `EmissionsV2.setSellerUnlockPolicy` (plain `onlyOwner`, works even after any registry renouncement). + +## Local CLI rehearsal + +Use the persistent M001 sandbox to rehearse CLI behavior on the same pinned Base mainnet fork as the migration fork test. The sandbox runs the deploy phase first and deliberately stops before cutover, preserving the Anvil process between commands. + +```bash +export BASE_MAINNET_RPC_URL=https://your-archive-base-rpc.example +export ANTS_HOLDER=0x... # ANTS balance at BASE_MAINNET_FORK_BLOCK + +pnpm m001:sandbox up --port 8545 --out .m001-sandbox +pnpm m001:sandbox status --out .m001-sandbox +``` + +The deploy command writes a copied deployment ledger and `.m001-sandbox/cli-config.json`. Point CLI commands at that file to verify legacy mode, then cut over and reuse the refreshed file: + +```bash +antseed --config .m001-sandbox/cli-config.json --data-dir /tmp/antseed-m001 seller emissions info +antseed --config .m001-sandbox/cli-config.json --data-dir /tmp/antseed-m001 network contracts + +pnpm m001:sandbox cutover --out .m001-sandbox +pnpm m001:sandbox fund-position-init 5 --out .m001-sandbox +pnpm m001:sandbox advance-epoch 2 --out .m001-sandbox +pnpm m001:sandbox fund-ants 0xYourCliWallet 100 --out .m001-sandbox + +antseed --config .m001-sandbox/cli-config.json --data-dir /tmp/antseed-m001 seller pool bootstrap +antseed --config .m001-sandbox/cli-config.json --data-dir /tmp/antseed-m001 seller stake 100 --epochs 4 +antseed --config .m001-sandbox/cli-config.json --data-dir /tmp/antseed-m001 seller pool positions + +pnpm m001:sandbox down --out .m001-sandbox +``` + +Additional helpers are `fund-seller
`, `advance-epoch [n]`, `fund-ants
`, and `fund-position-init `. The generated config carries all ledger-derived address overrides; the CLI still reads the registry and refuses commands if either active pointer disagrees. diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index feadd677f..a9157a856 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -155,6 +155,24 @@ export { export { IdentityClient, type IdentityClientConfig } from './payments/evm/identity-client.js'; export { StakingClient, type StakingClientConfig } from './payments/evm/staking-client.js'; export { EmissionsClient, type EmissionsClientConfig, type EmissionsEpochParams } from './payments/evm/emissions-client.js'; +export { RegistryClient, type RegistryClientConfig } from './payments/evm/registry-client.js'; +export { UsageAccountingClient, type UsageAccountingClientConfig } from './payments/evm/usage-accounting-client.js'; +export { UsageRewardsClient, type UsageRewardsClientConfig } from './payments/evm/usage-rewards-client.js'; +export { SellerPoolsClient, type SellerPoolsClientConfig, type SellerPoolPosition } from './payments/evm/seller-pools-client.js'; +export { SellerPoolsRewardsClient, type SellerPoolsRewardsClientConfig } from './payments/evm/seller-pools-rewards-client.js'; +export { SellerRegistryClient, type SellerRegistryClientConfig } from './payments/evm/seller-registry-client.js'; +export { PositionInitClient, type PositionInitClientConfig } from './payments/evm/position-init-client.js'; +export { EmissionsGateClient, type EmissionsGateClientConfig } from './payments/evm/emissions-gate-client.js'; +export { + ContractStackMismatchError, + legacyEpochs, + newEpochs, + resolveContractStack, + type ContractStackAddresses, + type ContractStackMode, + type ContractStackResolution, + type ContractStackRpcOptions, +} from './payments/contract-stack.js'; export { RpcHealthMonitor, probeRpcEndpoint } from './payments/rpc-health.js'; export type { RpcHealthState, RpcHealthStatus, RpcHealthMonitorOptions } from './payments/rpc-health.js'; export { ANTSTokenClient, type ANTSTokenClientConfig } from './payments/evm/ants-token-client.js'; diff --git a/packages/node/src/payments/chain-config.ts b/packages/node/src/payments/chain-config.ts index c7bf788fc..4fe0fc5f1 100644 --- a/packages/node/src/payments/chain-config.ts +++ b/packages/node/src/payments/chain-config.ts @@ -14,6 +14,7 @@ export interface ChainConfig { fallbackRpcUrls?: string[]; depositsContractAddress: string; channelsContractAddress: string; + registryContractAddress?: string; /** Optional AntseedFreeUsage contract address for zero-price signed usage. */ freeUsageContractAddress?: string; stakingContractAddress?: string; @@ -21,7 +22,17 @@ export interface ChainConfig { identityRegistryAddress?: string; emissionsContractAddress?: string; legacyEmissionsContractAddress?: string; + legacyStakingContractAddress?: string; + legacyEmissionsV1ContractAddress?: string; antsTokenAddress?: string; + emissionsGateAddress?: string; + sellerPoolsAddress?: string; + sellerRegistryAddress?: string; + positionInitAddress?: string; + usageAccountingAddress?: string; + usageRewardsAddress?: string; + sellerPoolsRewardsAddress?: string; + legacyEmissionsEscrowAddress?: string; /** Block when Channels contract was deployed. Floor for event log scans. */ channelsDeployBlock?: number; /** AntseedStats contract address. Populated only where an indexer aggregates it. */ @@ -78,6 +89,7 @@ const CHAIN_CONFIGS: Record = { // Nonce sequence: 0=USDC, 1=Registry, 2=ANTSToken, 3=AntseedRegistry, 4=Staking, 5=Deposits, 6=Channels, 7=Stats, 8=Emissions, 9=DepositRelay usdcContractAddress: '0x5FbDB2315678afecb367f032d93F642f64180aa3', identityRegistryAddress: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512', + registryContractAddress: '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0', stakingContractAddress: '0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9', depositsContractAddress: '0x5FC8d32690cc91D4c39d9d3abcBD16989F875707', channelsContractAddress: '0x0165878A594ca255338adfa4d48449f69242Eb8F', @@ -108,13 +120,24 @@ export function resolveChainConfig(overrides?: { fallbackRpcUrls?: string[]; depositsContractAddress?: string; channelsContractAddress?: string; + registryContractAddress?: string; freeUsageContractAddress?: string; stakingContractAddress?: string; usdcContractAddress?: string; identityRegistryAddress?: string; emissionsContractAddress?: string; legacyEmissionsContractAddress?: string; + legacyStakingContractAddress?: string; + legacyEmissionsV1ContractAddress?: string; antsTokenAddress?: string; + emissionsGateAddress?: string; + sellerPoolsAddress?: string; + sellerRegistryAddress?: string; + positionInitAddress?: string; + usageAccountingAddress?: string; + usageRewardsAddress?: string; + sellerPoolsRewardsAddress?: string; + legacyEmissionsEscrowAddress?: string; depositRelayAddress?: string; }): ChainConfig { const base = getChainConfig(overrides?.chainId); @@ -130,13 +153,24 @@ export function resolveChainConfig(overrides?: { ...(resolvedFallbacks !== undefined ? { fallbackRpcUrls: resolvedFallbacks } : {}), ...(overrides?.depositsContractAddress ? { depositsContractAddress: overrides.depositsContractAddress } : {}), ...(overrides?.channelsContractAddress ? { channelsContractAddress: overrides.channelsContractAddress } : {}), + ...(overrides?.registryContractAddress ? { registryContractAddress: overrides.registryContractAddress } : {}), ...(overrides?.freeUsageContractAddress ? { freeUsageContractAddress: overrides.freeUsageContractAddress } : {}), ...(overrides?.stakingContractAddress ? { stakingContractAddress: overrides.stakingContractAddress } : {}), ...(overrides?.usdcContractAddress ? { usdcContractAddress: overrides.usdcContractAddress } : {}), ...(overrides?.identityRegistryAddress ? { identityRegistryAddress: overrides.identityRegistryAddress } : {}), ...(overrides?.emissionsContractAddress ? { emissionsContractAddress: overrides.emissionsContractAddress } : {}), ...(overrides?.legacyEmissionsContractAddress ? { legacyEmissionsContractAddress: overrides.legacyEmissionsContractAddress } : {}), + ...(overrides?.legacyStakingContractAddress ? { legacyStakingContractAddress: overrides.legacyStakingContractAddress } : {}), + ...(overrides?.legacyEmissionsV1ContractAddress ? { legacyEmissionsV1ContractAddress: overrides.legacyEmissionsV1ContractAddress } : {}), ...(overrides?.antsTokenAddress ? { antsTokenAddress: overrides.antsTokenAddress } : {}), + ...(overrides?.emissionsGateAddress ? { emissionsGateAddress: overrides.emissionsGateAddress } : {}), + ...(overrides?.sellerPoolsAddress ? { sellerPoolsAddress: overrides.sellerPoolsAddress } : {}), + ...(overrides?.sellerRegistryAddress ? { sellerRegistryAddress: overrides.sellerRegistryAddress } : {}), + ...(overrides?.positionInitAddress ? { positionInitAddress: overrides.positionInitAddress } : {}), + ...(overrides?.usageAccountingAddress ? { usageAccountingAddress: overrides.usageAccountingAddress } : {}), + ...(overrides?.usageRewardsAddress ? { usageRewardsAddress: overrides.usageRewardsAddress } : {}), + ...(overrides?.sellerPoolsRewardsAddress ? { sellerPoolsRewardsAddress: overrides.sellerPoolsRewardsAddress } : {}), + ...(overrides?.legacyEmissionsEscrowAddress ? { legacyEmissionsEscrowAddress: overrides.legacyEmissionsEscrowAddress } : {}), ...(overrides?.depositRelayAddress ? { depositRelayAddress: overrides.depositRelayAddress } : {}), }; } diff --git a/packages/node/src/payments/contract-stack.test.ts b/packages/node/src/payments/contract-stack.test.ts new file mode 100644 index 000000000..fd9343407 --- /dev/null +++ b/packages/node/src/payments/contract-stack.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import type { ChainConfig } from './chain-config.js'; +import { + ContractStackMismatchError, + legacyEpochs, + newEpochs, + resolveContractStack, +} from './contract-stack.js'; + +const legacyEmissions = '0x0000000000000000000000000000000000000011'; +const legacyStaking = '0x0000000000000000000000000000000000000012'; +const usageAccounting = '0x0000000000000000000000000000000000000021'; +const sellerRegistry = '0x0000000000000000000000000000000000000022'; + +function config(overrides: Partial = {}): ChainConfig { + return { + chainId: 'base-mainnet', evmChainId: 8453, rpcUrl: 'http://rpc', + registryContractAddress: '0x0000000000000000000000000000000000000001', + depositsContractAddress: '0x0000000000000000000000000000000000000002', + channelsContractAddress: '0x0000000000000000000000000000000000000003', + usdcContractAddress: '0x0000000000000000000000000000000000000004', + emissionsContractAddress: legacyEmissions, + stakingContractAddress: legacyStaking, + ...overrides, + }; +} + +describe('resolveContractStack', () => { + it('resolves legacy mode', async () => { + const result = await resolveContractStack(config(), { + registryClient: { emissions: async () => legacyEmissions, staking: async () => legacyStaking }, + legacyEmissionsClient: { getEpochInfo: async () => ({ epoch: 7 }) }, + }); + expect(result.mode).toBe('legacy'); + expect(result.currentEpoch).toBe(7); + }); + + it('resolves recognized usage and honors overrides', async () => { + const result = await resolveContractStack(config({ usageAccountingAddress: usageAccounting, sellerRegistryAddress: sellerRegistry }), { + registryClient: { emissions: async () => usageAccounting, staking: async () => sellerRegistry }, + usageAccountingClient: { currentEpoch: async () => 12, firstRewardedEpoch: async () => 9 }, + }); + expect(result).toMatchObject({ mode: 'recognized-usage', currentEpoch: 12, firstRewardedEpoch: 9 }); + expect(result.addresses.usageAccountingAddress).toBe(usageAccounting); + }); + + it.each([ + ['mismatch', usageAccounting, legacyStaking], + ['zero emissions', '0x0000000000000000000000000000000000000000', legacyStaking], + ['mixed stack', usageAccounting, legacyStaking], + ])('rejects %s', async (_name, emissions, staking) => { + await expect(resolveContractStack(config({ usageAccountingAddress: usageAccounting, sellerRegistryAddress: sellerRegistry }), { + registryClient: { emissions: async () => emissions, staking: async () => staking }, + })).rejects.toBeInstanceOf(ContractStackMismatchError); + }); + + it('wraps RPC errors', async () => { + await expect(resolveContractStack(config(), { + registryClient: { emissions: async () => { throw new Error('offline'); }, staking: async () => legacyStaking }, + })).rejects.toThrow('offline'); + }); + + it('rejects missing registry configuration', async () => { + await expect(resolveContractStack(config({ registryContractAddress: undefined }))).rejects.toBeInstanceOf(ContractStackMismatchError); + }); +}); + +describe('epoch ranges', () => { + it('splits legacy and new epochs at cutover', () => { + expect(legacyEpochs(8, 5)).toEqual([0, 1, 2, 3, 4]); + expect(newEpochs(8, 5)).toEqual([5, 6, 7]); + }); +}); diff --git a/packages/node/src/payments/contract-stack.ts b/packages/node/src/payments/contract-stack.ts new file mode 100644 index 000000000..b0ba9affe --- /dev/null +++ b/packages/node/src/payments/contract-stack.ts @@ -0,0 +1,139 @@ +import type { ChainConfig } from './chain-config.js'; +import { EmissionsClient } from './evm/emissions-client.js'; +import { RegistryClient } from './evm/registry-client.js'; +import { UsageAccountingClient } from './evm/usage-accounting-client.js'; + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +export type ContractStackMode = 'legacy' | 'recognized-usage'; + +export interface ContractStackAddresses { + registryContractAddress: string; + emissionsContractAddress?: string; + stakingContractAddress?: string; + legacyEmissionsContractAddress?: string; + legacyStakingContractAddress?: string; + usageAccountingAddress?: string; + sellerRegistryAddress?: string; + channelsContractAddress: string; + depositsContractAddress: string; + antsTokenAddress?: string; +} + +export interface ContractStackResolution { + mode: ContractStackMode; + currentEpoch: number; + firstRewardedEpoch?: number; + addresses: ContractStackAddresses; + registryPointers: { emissions: string; staking: string }; +} + +interface RegistryReader { + emissions(): Promise; + staking(): Promise; +} + +interface EpochReader { currentEpoch(): Promise; } +interface UsageEpochReader extends EpochReader { firstRewardedEpoch(): Promise; } + +export interface ContractStackRpcOptions { + registryClient?: RegistryReader; + legacyEmissionsClient?: { getEpochInfo(): Promise<{ epoch: number }> }; + usageAccountingClient?: UsageEpochReader; +} + +export class ContractStackMismatchError extends Error { + constructor(message: string) { + super(`Contract stack mismatch: ${message}. Upgrade @antseed/cli or check payments.crypto overrides.`); + this.name = 'ContractStackMismatchError'; + } +} + +function sameAddress(left: string | undefined, right: string | undefined): boolean { + return !!left && !!right && left.toLowerCase() === right.toLowerCase(); +} + +function isZero(address: string | undefined): boolean { + return !address || sameAddress(address, ZERO_ADDRESS); +} + +export function legacyEpochs(currentEpoch: number, firstRewardedEpoch: number): number[] { + return Array.from({ length: Math.max(0, Math.min(currentEpoch, firstRewardedEpoch)) }, (_, epoch) => epoch); +} + +export function newEpochs(currentEpoch: number, firstRewardedEpoch: number): number[] { + const start = Math.max(0, firstRewardedEpoch); + return Array.from({ length: Math.max(0, currentEpoch - start) }, (_, index) => start + index); +} + +export async function resolveContractStack( + chainConfig: ChainConfig, + rpcOptions: ContractStackRpcOptions = {}, +): Promise { + const registryAddress = chainConfig.registryContractAddress; + if (!registryAddress) { + throw new ContractStackMismatchError(`registry address not configured for chain '${chainConfig.chainId}'`); + } + + const addresses: ContractStackAddresses = { + registryContractAddress: registryAddress, + emissionsContractAddress: chainConfig.emissionsContractAddress, + stakingContractAddress: chainConfig.stakingContractAddress, + legacyEmissionsContractAddress: chainConfig.legacyEmissionsContractAddress, + legacyStakingContractAddress: chainConfig.legacyStakingContractAddress, + usageAccountingAddress: chainConfig.usageAccountingAddress, + sellerRegistryAddress: chainConfig.sellerRegistryAddress, + channelsContractAddress: chainConfig.channelsContractAddress, + depositsContractAddress: chainConfig.depositsContractAddress, + antsTokenAddress: chainConfig.antsTokenAddress, + }; + + try { + const registry = rpcOptions.registryClient ?? new RegistryClient({ + rpcUrl: chainConfig.rpcUrl, + fallbackRpcUrls: chainConfig.fallbackRpcUrls, + contractAddress: registryAddress, + evmChainId: chainConfig.evmChainId, + }); + const [registryEmissions, registryStaking] = await Promise.all([registry.emissions(), registry.staking()]); + const registryPointers = { emissions: registryEmissions, staking: registryStaking }; + if (isZero(registryEmissions) || isZero(registryStaking)) { + throw new ContractStackMismatchError(`registry returned zero pointer(s): emissions=${registryEmissions}, staking=${registryStaking}`); + } + + const recognizedConfigured = !!chainConfig.usageAccountingAddress || !!chainConfig.sellerRegistryAddress; + const recognizedMatch = sameAddress(registryEmissions, chainConfig.usageAccountingAddress) + && sameAddress(registryStaking, chainConfig.sellerRegistryAddress); + if (recognizedConfigured && recognizedMatch) { + const usage = rpcOptions.usageAccountingClient ?? new UsageAccountingClient({ + rpcUrl: chainConfig.rpcUrl, + fallbackRpcUrls: chainConfig.fallbackRpcUrls, + contractAddress: chainConfig.usageAccountingAddress!, + evmChainId: chainConfig.evmChainId, + }); + const [currentEpoch, firstRewardedEpoch] = await Promise.all([usage.currentEpoch(), usage.firstRewardedEpoch()]); + return { mode: 'recognized-usage', currentEpoch, firstRewardedEpoch, addresses, registryPointers }; + } + + const legacyMatch = !recognizedConfigured + && sameAddress(registryEmissions, chainConfig.emissionsContractAddress) + && sameAddress(registryStaking, chainConfig.stakingContractAddress); + if (legacyMatch) { + const emissions = rpcOptions.legacyEmissionsClient ?? new EmissionsClient({ + rpcUrl: chainConfig.rpcUrl, + fallbackRpcUrls: chainConfig.fallbackRpcUrls, + contractAddress: chainConfig.emissionsContractAddress!, + evmChainId: chainConfig.evmChainId, + }); + const { epoch: currentEpoch } = await emissions.getEpochInfo(); + return { mode: 'legacy', currentEpoch, addresses, registryPointers }; + } + + throw new ContractStackMismatchError( + `registry emissions=${registryEmissions}, staking=${registryStaking}; configured legacy emissions=${chainConfig.emissionsContractAddress ?? 'missing'}, staking=${chainConfig.stakingContractAddress ?? 'missing'}; configured recognized emissions=${chainConfig.usageAccountingAddress ?? 'missing'}, staking=${chainConfig.sellerRegistryAddress ?? 'missing'}`, + ); + } catch (error) { + if (error instanceof ContractStackMismatchError) throw error; + throw new ContractStackMismatchError(`failed to verify registry ${registryAddress}: ${(error as Error).message}`); + } +} diff --git a/packages/node/src/payments/evm/ants-token-client.ts b/packages/node/src/payments/evm/ants-token-client.ts index b6e5ee509..d49fd1855 100644 --- a/packages/node/src/payments/evm/ants-token-client.ts +++ b/packages/node/src/payments/evm/ants-token-client.ts @@ -10,6 +10,7 @@ export interface ANTSTokenClientConfig { const ANTS_TOKEN_ABI = [ 'function balanceOf(address account) external view returns (uint256)', + 'function allowance(address owner, address spender) external view returns (uint256)', 'function totalSupply() external view returns (uint256)', 'function name() external view returns (string)', 'function symbol() external view returns (string)', @@ -31,6 +32,11 @@ export class ANTSTokenClient extends BaseEvmClient { return contract.getFunction('balanceOf')(address); } + async allowance(owner: string, spender: string): Promise { + const contract = new Contract(this._contractAddress, ANTS_TOKEN_ABI, this._provider); + return contract.getFunction('allowance')(owner, spender); + } + async totalSupply(): Promise { const contract = new Contract(this._contractAddress, ANTS_TOKEN_ABI, this._provider); return contract.getFunction('totalSupply')(); diff --git a/packages/node/src/payments/evm/emissions-gate-client.ts b/packages/node/src/payments/evm/emissions-gate-client.ts new file mode 100644 index 000000000..19c59b946 --- /dev/null +++ b/packages/node/src/payments/evm/emissions-gate-client.ts @@ -0,0 +1,19 @@ +import { Contract } from 'ethers'; +import { BaseEvmClient } from './base-evm-client.js'; +export interface EmissionsGateClientConfig { rpcUrl: string; fallbackRpcUrls?: string[]; contractAddress: string; evmChainId?: number; } +const ABI = [ + 'function currentEpoch() external view returns (uint256)', + 'function effectiveEpoch() external view returns (uint256)', + 'function currentEmissionRate() external view returns (uint256)', + 'function epochDuration() external view returns (uint256)', + 'function getEpochEmission(uint256 epoch) external view returns (uint256)', +] as const; +export class EmissionsGateClient extends BaseEvmClient { + constructor(config: EmissionsGateClientConfig) { super(config.rpcUrl, config.contractAddress, config.fallbackRpcUrls, config.evmChainId); } + private contract(): Contract { return new Contract(this._contractAddress, ABI, this._provider); } + async currentEpoch(): Promise { return Number(await this.contract().getFunction('currentEpoch')()); } + async effectiveEpoch(): Promise { return Number(await this.contract().getFunction('effectiveEpoch')()); } + currentEmissionRate(): Promise { return this.contract().getFunction('currentEmissionRate')(); } + async epochDuration(): Promise { return Number(await this.contract().getFunction('epochDuration')()); } + getEpochEmission(epoch: number): Promise { return this.contract().getFunction('getEpochEmission')(epoch); } +} diff --git a/packages/node/src/payments/evm/position-init-client.ts b/packages/node/src/payments/evm/position-init-client.ts new file mode 100644 index 000000000..f5598385d --- /dev/null +++ b/packages/node/src/payments/evm/position-init-client.ts @@ -0,0 +1,19 @@ +import { Contract, type AbstractSigner } from 'ethers'; +import { BaseEvmClient } from './base-evm-client.js'; +export interface PositionInitClientConfig { rpcUrl: string; fallbackRpcUrls?: string[]; contractAddress: string; evmChainId?: number; } +const ABI = [ + 'function initPosition() external returns (uint256 positionId)', + 'function remainingInits() external view returns (uint256)', + 'function agentInitialized(uint256 agentId) external view returns (bool)', + 'function initAmount() external view returns (uint256)', + 'function initEndEpoch() external view returns (uint256)', +] as const; +export class PositionInitClient extends BaseEvmClient { + constructor(config: PositionInitClientConfig) { super(config.rpcUrl, config.contractAddress, config.fallbackRpcUrls, config.evmChainId); } + private contract(): Contract { return new Contract(this._contractAddress, ABI, this._provider); } + initPosition(signer: AbstractSigner): Promise { return this._execWrite(signer, ABI, 'initPosition'); } + remainingInits(): Promise { return this.contract().getFunction('remainingInits')(); } + agentInitialized(agentId: number): Promise { return this.contract().getFunction('agentInitialized')(agentId); } + initAmount(): Promise { return this.contract().getFunction('initAmount')(); } + async initEndEpoch(): Promise { return Number(await this.contract().getFunction('initEndEpoch')()); } +} diff --git a/packages/node/src/payments/evm/registry-client.ts b/packages/node/src/payments/evm/registry-client.ts new file mode 100644 index 000000000..dc726ac2c --- /dev/null +++ b/packages/node/src/payments/evm/registry-client.ts @@ -0,0 +1,34 @@ +import { Contract } from 'ethers'; +import { BaseEvmClient } from './base-evm-client.js'; + +export interface RegistryClientConfig { + rpcUrl: string; + fallbackRpcUrls?: string[]; + contractAddress: string; + evmChainId?: number; +} + +const REGISTRY_ABI = [ + 'function emissions() external view returns (address)', + 'function staking() external view returns (address)', + 'function channels() external view returns (address)', + 'function deposits() external view returns (address)', + 'function antsToken() external view returns (address)', +] as const; + +export class RegistryClient extends BaseEvmClient { + constructor(config: RegistryClientConfig) { + super(config.rpcUrl, config.contractAddress, config.fallbackRpcUrls, config.evmChainId); + } + + private async _address(method: string): Promise { + const contract = new Contract(this._contractAddress, REGISTRY_ABI, this._provider); + return contract.getFunction(method)() as Promise; + } + + emissions(): Promise { return this._address('emissions'); } + staking(): Promise { return this._address('staking'); } + channels(): Promise { return this._address('channels'); } + deposits(): Promise { return this._address('deposits'); } + antsToken(): Promise { return this._address('antsToken'); } +} diff --git a/packages/node/src/payments/evm/seller-pools-client.ts b/packages/node/src/payments/evm/seller-pools-client.ts new file mode 100644 index 000000000..346b201fa --- /dev/null +++ b/packages/node/src/payments/evm/seller-pools-client.ts @@ -0,0 +1,68 @@ +import { Contract, type AbstractSigner } from 'ethers'; +import { BaseEvmClient } from './base-evm-client.js'; + +export interface SellerPoolsClientConfig { + rpcUrl: string; + fallbackRpcUrls?: string[]; + contractAddress: string; + antsTokenAddress: string; + evmChainId?: number; +} + +export interface SellerPoolPosition { + id: number; + owner: string; + agentId: number; + amount: bigint; + weightAmount: bigint; + stakeStartEpoch: number; + stakeEndEpoch: number; + closedAtEpoch: number; + withdrawn: boolean; +} + +const SELLER_POOLS_ABI = [ + 'function stake(uint256 agentId, uint256 amount, uint256 stakeEpochs) external returns (uint256 positionId)', + 'function stakerPositionCount(address staker) external view returns (uint256)', + 'function stakerPositionIds(address staker, uint256 offset, uint256 limit) external view returns (uint256[])', + 'function positions(uint256 positionId) external view returns (address owner, uint256 agentId, uint256 amount, uint256 weightAmount, uint64 stakeStartEpoch, uint64 stakeEndEpoch, uint64 closedAtEpoch, bool withdrawn)', + 'function positionWithdrawableEpoch(uint256 positionId) external view returns (uint64)', + 'function withdrawStakes(uint256[] positionIds) external returns (uint256 returnedAmount, uint256 slashedAmount)', + 'function agentIdForSeller(address seller) external view returns (uint256)', + 'function currentEpoch() external view returns (uint256)', + 'function stakeActivationDelay() external view returns (uint256)', + 'function minStakeEpochs() external view returns (uint256)', + 'function MAX_STAKE_EPOCHS() external view returns (uint256)', + 'function hasPoolAtEpoch(uint256 agentId, uint256 epoch) external view returns (bool)', + 'function poolActiveStakeAtEpoch(uint256 agentId, uint256 epoch) external view returns (uint256)', +] as const; + +export class SellerPoolsClient extends BaseEvmClient { + private readonly antsTokenAddress: string; + constructor(config: SellerPoolsClientConfig) { + super(config.rpcUrl, config.contractAddress, config.fallbackRpcUrls, config.evmChainId); + this.antsTokenAddress = config.antsTokenAddress; + } + private contract(): Contract { return new Contract(this._contractAddress, SELLER_POOLS_ABI, this._provider); } + stake(signer: AbstractSigner, agentId: number, amount: bigint, epochs: number): Promise { + return this._approveAndExec(signer, this.antsTokenAddress, amount, SELLER_POOLS_ABI, 'stake', agentId, amount, epochs); + } + async stakerPositionCount(staker: string): Promise { return Number(await this.contract().getFunction('stakerPositionCount')(staker)); } + async stakerPositionIds(staker: string, offset = 0, limit = 256): Promise { + const ids = await this.contract().getFunction('stakerPositionIds')(staker, offset, limit) as bigint[]; + return ids.map(Number); + } + async position(id: number): Promise { + const result = await this.contract().getFunction('positions')(id); + return { id, owner: result[0], agentId: Number(result[1]), amount: result[2], weightAmount: result[3], stakeStartEpoch: Number(result[4]), stakeEndEpoch: Number(result[5]), closedAtEpoch: Number(result[6]), withdrawn: result[7] }; + } + async positionWithdrawableEpoch(id: number): Promise { return Number(await this.contract().getFunction('positionWithdrawableEpoch')(id)); } + withdrawStakes(signer: AbstractSigner, ids: number[]): Promise { return this._execWrite(signer, SELLER_POOLS_ABI, 'withdrawStakes', ids); } + async agentIdForSeller(seller: string): Promise { return Number(await this.contract().getFunction('agentIdForSeller')(seller)); } + async currentEpoch(): Promise { return Number(await this.contract().getFunction('currentEpoch')()); } + async stakeActivationDelay(): Promise { return Number(await this.contract().getFunction('stakeActivationDelay')()); } + async minStakeEpochs(): Promise { return Number(await this.contract().getFunction('minStakeEpochs')()); } + async maxStakeEpochs(): Promise { return Number(await this.contract().getFunction('MAX_STAKE_EPOCHS')()); } + hasPoolAtEpoch(agentId: number, epoch: number): Promise { return this.contract().getFunction('hasPoolAtEpoch(uint256,uint256)')(agentId, epoch); } + poolActiveStakeAtEpoch(agentId: number, epoch: number): Promise { return this.contract().getFunction('poolActiveStakeAtEpoch(uint256,uint256)')(agentId, epoch); } +} diff --git a/packages/node/src/payments/evm/seller-pools-rewards-client.ts b/packages/node/src/payments/evm/seller-pools-rewards-client.ts new file mode 100644 index 000000000..ae5b5ab41 --- /dev/null +++ b/packages/node/src/payments/evm/seller-pools-rewards-client.ts @@ -0,0 +1,15 @@ +import { Contract, type AbstractSigner } from 'ethers'; +import { BaseEvmClient } from './base-evm-client.js'; + +export interface SellerPoolsRewardsClientConfig { rpcUrl: string; fallbackRpcUrls?: string[]; contractAddress: string; evmChainId?: number; } +const ABI = [ + 'function pendingIndexedStakerReward(uint256 positionId) external view returns (uint256)', + 'function claimStakerRewards(uint256 positionId, address recipient) external', + 'function claimStakerRewardsBatch(uint256[] positionIds, address recipient) external', +] as const; +export class SellerPoolsRewardsClient extends BaseEvmClient { + constructor(config: SellerPoolsRewardsClientConfig) { super(config.rpcUrl, config.contractAddress, config.fallbackRpcUrls, config.evmChainId); } + pendingIndexedStakerReward(positionId: number): Promise { return new Contract(this._contractAddress, ABI, this._provider).getFunction('pendingIndexedStakerReward')(positionId); } + claimStakerRewards(signer: AbstractSigner, positionId: number, recipient: string): Promise { return this._execWrite(signer, ABI, 'claimStakerRewards', positionId, recipient); } + claimStakerRewardsBatch(signer: AbstractSigner, positionIds: number[], recipient: string): Promise { return this._execWrite(signer, ABI, 'claimStakerRewardsBatch', positionIds, recipient); } +} diff --git a/packages/node/src/payments/evm/seller-registry-client.ts b/packages/node/src/payments/evm/seller-registry-client.ts new file mode 100644 index 000000000..76ff9a388 --- /dev/null +++ b/packages/node/src/payments/evm/seller-registry-client.ts @@ -0,0 +1,22 @@ +import { Contract, type AbstractSigner } from 'ethers'; +import { BaseEvmClient } from './base-evm-client.js'; + +export interface SellerRegistryClientConfig { rpcUrl: string; fallbackRpcUrls?: string[]; contractAddress: string; evmChainId?: number; } +const ABI = [ + 'function registerSeller(uint256 agentId) external', + 'function getAgentId(address seller) external view returns (uint256)', + 'function getStake(address seller) external view returns (uint256)', + 'function isStakedAboveMin(address seller) external view returns (bool)', + 'function minSellerPoolStake() external view returns (uint256)', + 'function legacyStakeEligibilityEnabled() external view returns (bool)', +] as const; +export class SellerRegistryClient extends BaseEvmClient { + constructor(config: SellerRegistryClientConfig) { super(config.rpcUrl, config.contractAddress, config.fallbackRpcUrls, config.evmChainId); } + private contract(): Contract { return new Contract(this._contractAddress, ABI, this._provider); } + registerSeller(signer: AbstractSigner, agentId: number): Promise { return this._execWrite(signer, ABI, 'registerSeller', agentId); } + async getAgentId(seller: string): Promise { return Number(await this.contract().getFunction('getAgentId')(seller)); } + getStake(seller: string): Promise { return this.contract().getFunction('getStake')(seller); } + isStakedAboveMin(seller: string): Promise { return this.contract().getFunction('isStakedAboveMin')(seller); } + minSellerPoolStake(): Promise { return this.contract().getFunction('minSellerPoolStake')(); } + legacyStakeEligibilityEnabled(): Promise { return this.contract().getFunction('legacyStakeEligibilityEnabled')(); } +} diff --git a/packages/node/src/payments/evm/usage-accounting-client.ts b/packages/node/src/payments/evm/usage-accounting-client.ts new file mode 100644 index 000000000..81147e109 --- /dev/null +++ b/packages/node/src/payments/evm/usage-accounting-client.ts @@ -0,0 +1,45 @@ +import { Contract, type AbstractSigner } from 'ethers'; +import { BaseEvmClient } from './base-evm-client.js'; + +export interface UsageAccountingClientConfig { + rpcUrl: string; + fallbackRpcUrls?: string[]; + contractAddress: string; + evmChainId?: number; +} + +const USAGE_ACCOUNTING_ABI = [ + 'function currentEpoch() external view returns (uint256)', + 'function firstRewardedEpoch() external view returns (uint256)', + 'function pendingEmissions(address account, uint256[] epochs) external view returns (uint256 seller, uint256 buyer)', + 'function claimSellerEmissions(uint256[] epochs) external', + 'function sellerPointsByEpoch(uint256 epoch, address seller) external view returns (uint256)', + 'function buyerPointsByEpoch(uint256 epoch, address buyer) external view returns (uint256)', + 'function sellerAgentIdByEpoch(uint256 epoch, address seller) external view returns (uint256)', +] as const; + +export class UsageAccountingClient extends BaseEvmClient { + constructor(config: UsageAccountingClientConfig) { + super(config.rpcUrl, config.contractAddress, config.fallbackRpcUrls, config.evmChainId); + } + + private contract(): Contract { return new Contract(this._contractAddress, USAGE_ACCOUNTING_ABI, this._provider); } + async currentEpoch(): Promise { return Number(await this.contract().getFunction('currentEpoch')()); } + async firstRewardedEpoch(): Promise { return Number(await this.contract().getFunction('firstRewardedEpoch')()); } + async pendingEmissions(account: string, epochs: number[]): Promise<{ seller: bigint; buyer: bigint }> { + const [seller, buyer] = await this.contract().getFunction('pendingEmissions')(account, epochs); + return { seller, buyer }; + } + claimSellerEmissions(signer: AbstractSigner, epochs: number[]): Promise { + return this._execWrite(signer, USAGE_ACCOUNTING_ABI, 'claimSellerEmissions', epochs); + } + sellerPointsByEpoch(epoch: number, seller: string): Promise { + return this.contract().getFunction('sellerPointsByEpoch')(epoch, seller); + } + buyerPointsByEpoch(epoch: number, buyer: string): Promise { + return this.contract().getFunction('buyerPointsByEpoch')(epoch, buyer); + } + async sellerAgentIdByEpoch(epoch: number, seller: string): Promise { + return Number(await this.contract().getFunction('sellerAgentIdByEpoch')(epoch, seller)); + } +} diff --git a/packages/node/src/payments/evm/usage-rewards-client.ts b/packages/node/src/payments/evm/usage-rewards-client.ts new file mode 100644 index 000000000..3645bade9 --- /dev/null +++ b/packages/node/src/payments/evm/usage-rewards-client.ts @@ -0,0 +1,34 @@ +import { Contract, type AbstractSigner } from 'ethers'; +import { BaseEvmClient } from './base-evm-client.js'; + +export interface UsageRewardsClientConfig { + rpcUrl: string; + fallbackRpcUrls?: string[]; + contractAddress: string; + evmChainId?: number; +} + +const USAGE_REWARDS_ABI = [ + 'function pendingAgentReward(uint256 agentId, uint256 epoch) external view returns (uint256)', + 'function pendingBuyerReward(address buyer, uint256 epoch) external view returns (uint256)', + 'function agentEpochClaimed(uint256 agentId, uint256 epoch) external view returns (bool)', + 'function buyerEpochClaimed(address buyer, uint256 epoch) external view returns (bool)', + 'function claimAgentReward(uint256 agentId, uint256 epoch) external', + 'function claimBuyerReward(address buyer, uint256 epoch) external', + 'function rewardRecipient(uint256 agentId) external view returns (address)', +] as const; + +export class UsageRewardsClient extends BaseEvmClient { + constructor(config: UsageRewardsClientConfig) { + super(config.rpcUrl, config.contractAddress, config.fallbackRpcUrls, config.evmChainId); + } + + private contract(): Contract { return new Contract(this._contractAddress, USAGE_REWARDS_ABI, this._provider); } + pendingAgentReward(agentId: number, epoch: number): Promise { return this.contract().getFunction('pendingAgentReward')(agentId, epoch); } + pendingBuyerReward(buyer: string, epoch: number): Promise { return this.contract().getFunction('pendingBuyerReward')(buyer, epoch); } + agentEpochClaimed(agentId: number, epoch: number): Promise { return this.contract().getFunction('agentEpochClaimed')(agentId, epoch); } + buyerEpochClaimed(buyer: string, epoch: number): Promise { return this.contract().getFunction('buyerEpochClaimed')(buyer, epoch); } + claimAgentReward(signer: AbstractSigner, agentId: number, epoch: number): Promise { return this._execWrite(signer, USAGE_REWARDS_ABI, 'claimAgentReward', agentId, epoch); } + claimBuyerReward(signer: AbstractSigner, buyer: string, epoch: number): Promise { return this._execWrite(signer, USAGE_REWARDS_ABI, 'claimBuyerReward', buyer, epoch); } + rewardRecipient(agentId: number): Promise { return this.contract().getFunction('rewardRecipient')(agentId); } +} diff --git a/packages/node/src/payments/generated-contract-addresses.ts b/packages/node/src/payments/generated-contract-addresses.ts index e76734122..44f5e3763 100644 --- a/packages/node/src/payments/generated-contract-addresses.ts +++ b/packages/node/src/payments/generated-contract-addresses.ts @@ -3,6 +3,7 @@ export const DEPLOYED_CONTRACT_ADDRESSES = { 'base-mainnet': { evmChainId: 8453, + registryContractAddress: '0xf33fC901BFa97326379A369401F4490E231B69B0', usdcContractAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', depositsContractAddress: '0x0F7a3a8f4Da01637d1202bb5443fcF7F88F99fD2', channelsContractAddress: '0xBA66d3b4fbCf472F6F11D6F9F96aaCE96516F09d', @@ -19,6 +20,7 @@ export const DEPLOYED_CONTRACT_ADDRESSES = { }, 'base-sepolia': { evmChainId: 84532, + registryContractAddress: '0x81562e904D228Ee05DB473f5c9453F0f0D65ad5A', usdcContractAddress: '0xcA04797CaB6B412Cee6798B7314a05AdFDc3Cf23', depositsContractAddress: '0x96f083A9801AFdcE7D764651954A1A9Fbd489FEA', channelsContractAddress: '0x3b0b94AC27C042CAC17103A897Fb5cEb7D8b4cf7', diff --git a/packages/node/src/payments/index.ts b/packages/node/src/payments/index.ts index 13a3f4cab..1bb79c027 100644 --- a/packages/node/src/payments/index.ts +++ b/packages/node/src/payments/index.ts @@ -84,6 +84,34 @@ export type { ANTSTokenClientConfig } from './evm/ants-token-client.js'; // Emissions export { EmissionsClient } from './evm/emissions-client.js'; export type { EmissionsClientConfig, EmissionsEpochParams } from './evm/emissions-client.js'; +export { RegistryClient } from './evm/registry-client.js'; +export type { RegistryClientConfig } from './evm/registry-client.js'; +export { UsageAccountingClient } from './evm/usage-accounting-client.js'; +export type { UsageAccountingClientConfig } from './evm/usage-accounting-client.js'; +export { UsageRewardsClient } from './evm/usage-rewards-client.js'; +export type { UsageRewardsClientConfig } from './evm/usage-rewards-client.js'; +export { SellerPoolsClient } from './evm/seller-pools-client.js'; +export type { SellerPoolsClientConfig, SellerPoolPosition } from './evm/seller-pools-client.js'; +export { SellerPoolsRewardsClient } from './evm/seller-pools-rewards-client.js'; +export type { SellerPoolsRewardsClientConfig } from './evm/seller-pools-rewards-client.js'; +export { SellerRegistryClient } from './evm/seller-registry-client.js'; +export type { SellerRegistryClientConfig } from './evm/seller-registry-client.js'; +export { PositionInitClient } from './evm/position-init-client.js'; +export type { PositionInitClientConfig } from './evm/position-init-client.js'; +export { EmissionsGateClient } from './evm/emissions-gate-client.js'; +export type { EmissionsGateClientConfig } from './evm/emissions-gate-client.js'; +export { + ContractStackMismatchError, + legacyEpochs, + newEpochs, + resolveContractStack, +} from './contract-stack.js'; +export type { + ContractStackAddresses, + ContractStackMode, + ContractStackResolution, + ContractStackRpcOptions, +} from './contract-stack.js'; // Channel persistence export { ChannelStore, CHANNEL_KIND, CHANNEL_ROLE, CHANNEL_STATUS } from './channel-store.js'; diff --git a/scripts/deploy-contracts.test.mjs b/scripts/deploy-contracts.test.mjs index 1df3b1944..7001d32ec 100644 --- a/scripts/deploy-contracts.test.mjs +++ b/scripts/deploy-contracts.test.mjs @@ -32,6 +32,8 @@ import { } from './deployments/m001-cutover.mjs'; import { recordErrors } from './validate-contract-deployments.mjs'; import { parseSignerSpecs, resolveSigners } from './deployments/runtime/signers.mjs'; +import { parseSandboxArgs } from './m001-sandbox.mjs'; +import { renderNetwork } from './generate-contract-chain-config.mjs'; const ADDRESS = { registry: '0x0000000000000000000000000000000000000001', @@ -93,6 +95,28 @@ test('parses the explicit deployment modes', () => { assert.equal(parseDeployArgs(['M001', '--network', 'base-mainnet', '--fork-test']).mode, 'fork-test'); }); +test('parses M001 sandbox options without starting Anvil', () => { + const parsed = parseSandboxArgs(['up', '--port', '18545', '--out', 'tmp/sandbox']); + assert.equal(parsed.command, 'up'); + assert.equal(parsed.port, 18545); + assert.equal(parsed.out.endsWith(path.join('tmp', 'sandbox')), true); +}); + +test('renders recognized-usage deployment fields into chain config', () => { + const rendered = renderNetwork({ chainId: 8453, contracts: { + registry: { address: ADDRESS.registry }, + legacyStaking: { address: ADDRESS.legacyStaking }, + legacyEmissionsV1: { address: ADDRESS.legacyEmissions }, + usageAccounting: { address: ADDRESS.usageAccounting }, + sellerRegistry: { address: ADDRESS.sellerRegistry }, + } }); + assert.equal(rendered.registryContractAddress, ADDRESS.registry); + assert.equal(rendered.legacyStakingContractAddress, ADDRESS.legacyStaking); + assert.equal(rendered.legacyEmissionsV1ContractAddress, ADDRESS.legacyEmissions); + assert.equal(rendered.usageAccountingAddress, ADDRESS.usageAccounting); + assert.equal(rendered.sellerRegistryAddress, ADDRESS.sellerRegistry); +}); + test('parses signer specs and resolves them to addresses, never keys', async () => { assert.deepEqual(parseSignerSpecs(['a=ledger', 'b=keystore:/k/b']), { a: 'ledger', b: 'keystore:/k/b' }); assert.throws(() => parseSignerSpecs(['a=ledger', 'a=ledger:1']), /given twice/); @@ -259,6 +283,7 @@ test('updates canonical contract aliases when a migration activates', () => { contracts: { emissions: { address: ADDRESS.legacyEmissions, deployedInRelease: null }, staking: { address: ADDRESS.legacyStaking, deployedInRelease: null }, + legacyEmissions: { address: '0x0000000000000000000000000000000000000008', deployedInRelease: null }, }, }; const activeContracts = { @@ -270,6 +295,9 @@ test('updates canonical contract aliases when a migration activates', () => { assert.equal(current.contracts.emissions.address, ADDRESS.usageAccounting); assert.equal(current.contracts.staking.address, ADDRESS.sellerRegistry); + assert.equal(current.contracts.legacyEmissions.address, ADDRESS.legacyEmissions); + assert.equal(current.contracts.legacyStaking.address, ADDRESS.legacyStaking); + assert.equal(current.contracts.legacyEmissionsV1.address, '0x0000000000000000000000000000000000000008'); assert.notEqual(current.contracts.emissions, current.contracts.usageAccounting); assert.equal(current.contracts.emissions.deployedInRelease, true); }); @@ -456,6 +484,10 @@ test('always terminates disposable Anvil forks after success or failure', async assert.equal(result, 'http://127.0.0.1:18545'); assert.equal(children[0].killed, true); assert.equal(children[0].args.includes('--fork-block-number'), false, 'latest block is used when none is pinned'); + assert.equal(children[0].args.includes('--no-rate-limit'), true); + assert.equal(children[0].args.includes('--retries'), true); + assert.equal(children[0].args.includes('--timeout'), true); + assert.equal(children[0].args.includes('--disable-min-priority-fee'), true); assert.deepEqual(advances, [{ rpcUrl: result, timestamp: 1234 }]); await assert.rejects( @@ -468,6 +500,16 @@ test('always terminates disposable Anvil forks after success or failure', async ); assert.equal(children[1].killed, true); assert.equal(children[1].args.includes('42'), true); + + await withAnvilFork( + { forkUrl: 'https://rpc.example', chainId: 999, port: 19545, keepAlive: true }, + async ({ rpcUrl, child }) => { + assert.equal(rpcUrl, 'http://127.0.0.1:19545'); + assert.equal(child, children[2]); + }, + dependencies, + ); + assert.equal(children[2].killed, false); }); test('rebuilds a missing checkpoint from the committed history record', async () => { diff --git a/scripts/deployments/m001-cutover.mjs b/scripts/deployments/m001-cutover.mjs index 0e0134613..f8293cea3 100644 --- a/scripts/deployments/m001-cutover.mjs +++ b/scripts/deployments/m001-cutover.mjs @@ -161,6 +161,7 @@ async function runCutoverOnRpc(options, deps, schedule) { etherscanApiKey, env: environment, walletArgs, + slow: forkTest, }); if (simulation) { diff --git a/scripts/deployments/m001.mjs b/scripts/deployments/m001.mjs index 299fe303e..9828a4c4d 100644 --- a/scripts/deployments/m001.mjs +++ b/scripts/deployments/m001.mjs @@ -42,14 +42,14 @@ import { runMigration } from './runtime/runner.mjs'; const VERIFICATION_MINTER_ID = '0xd8018a5ea0ce31650e6d51e87c96f1d258a180b37e42ce66e7adf1c8ac666b57'; // --fork-test rehearsal fixtures (Base mainnet only). -const BASE_MAINNET_FORK_BLOCK = 50_571_469; -const BASE_MAINNET_DIEM_PROXY = '0x1f228613116E2d08014DfdCC198377C8dedf18C9'; +export const BASE_MAINNET_FORK_BLOCK = 50_571_469; +export const BASE_MAINNET_DIEM_PROXY = '0x1f228613116E2d08014DfdCC198377C8dedf18C9'; // An account with DIEM staked on the proxy at the fork block; impersonated to // fund the pre-cutover reward epoch so Cutover.s.sol exercises its // "already funded" path exactly as it will on mainnet. -const BASE_MAINNET_DIEM_STAKER = '0x48F4142F4AbF7b77a03f0cDffcd511eDD9B6d54a'; -const ANVIL_ACCOUNT_0 = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; -const ANVIL_ACCOUNT_1 = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; +export const BASE_MAINNET_DIEM_STAKER = '0x48F4142F4AbF7b77a03f0cDffcd511eDD9B6d54a'; +export const ANVIL_ACCOUNT_0 = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; +export const ANVIL_ACCOUNT_1 = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; const M001_TESTNET = 'base-sepolia'; const M001_ANVIL_FORK = 'base-mainnet'; @@ -137,7 +137,13 @@ export function validateM001Baseline(canonical) { } export function applyActiveContracts(current, activeContracts) { + const legacyEmissionsV2 = current.contracts.emissions; + const legacyStaking = current.contracts.staking; + const legacyEmissionsV1 = current.contracts.legacyEmissions; for (const contract of Object.values(current.contracts)) contract.deployedInRelease = false; + if (legacyEmissionsV2) current.contracts.legacyEmissions = { ...legacyEmissionsV2, deployedInRelease: false }; + if (legacyStaking) current.contracts.legacyStaking = { ...legacyStaking, deployedInRelease: false }; + if (legacyEmissionsV1) current.contracts.legacyEmissionsV1 = { ...legacyEmissionsV1, deployedInRelease: false }; Object.assign(current.contracts, activeContracts); current.contracts.emissions = { ...activeContracts.usageAccounting }; current.contracts.staking = { ...activeContracts.sellerRegistry }; @@ -569,6 +575,7 @@ const deployPhase = { etherscanApiKey: environment.BASESCAN_API_KEY, env: environment, walletArgs: wallet.forgeArgs, + slow: context.forkTest, }); if (mode !== 'broadcast') return; const checkpoint = await recordDeployment(context); @@ -679,7 +686,7 @@ function idleMessage(observation) { // Fork test // --------------------------------------------------------------------------- -function prepareForkOwners(context) { +export function prepareForkOwners(context) { const { rpcUrl, expected } = context; const rewardsPool = call(rpcUrl, expected.legacyEmissions, 'sellerRewardsPool()(address)'); for (const [contract, recipient] of [ @@ -700,7 +707,7 @@ function prepareForkOwners(context) { * can be constructed against it; a real address can be supplied via * WASH_TRADING_REGISTRY to exercise the live contract instead. */ -function deployForkWashTradingStub(rpcUrl) { +export function deployForkWashTradingStub(rpcUrl) { // PUSH1 0x00 PUSH1 0x00 MSTORE PUSH1 0x20 PUSH1 0x00 RETURN — returns 32 zero bytes for any call. const runtime = '600060005260206000f3'; const initcode = `0x69${runtime}600052600a6016f3`; @@ -712,7 +719,7 @@ function deployForkWashTradingStub(rpcUrl) { } /** Unlocks the impersonated staker so Cutover.s.sol can claim as it on the fork. */ -function prepareForkStaker(context) { +export function prepareForkStaker(context) { cast(context.rpcUrl, ['rpc', 'anvil_impersonateAccount', BASE_MAINNET_DIEM_STAKER]); cast(context.rpcUrl, ['rpc', 'anvil_setBalance', BASE_MAINNET_DIEM_STAKER, '0x3635C9ADC5DEA00000']); } diff --git a/scripts/deployments/runtime/anvil.mjs b/scripts/deployments/runtime/anvil.mjs index a2a6025c6..a82c169a6 100644 --- a/scripts/deployments/runtime/anvil.mjs +++ b/scripts/deployments/runtime/anvil.mjs @@ -34,27 +34,41 @@ export function advanceTimeTo(rpcUrl, timestamp) { cast(rpcUrl, ['rpc', 'evm_mine']); } -/** Boots a forked Anvil node, optionally advances time, and always tears it down. */ -export async function withAnvilFork({ forkUrl, forkBlockNumber, chainId, timestamp }, body, dependencies = {}) { +/** Boots a forked Anvil node, optionally advances time, and tears it down unless keepAlive is set. */ +export async function withAnvilFork({ forkUrl, forkBlockNumber, chainId, timestamp, port, keepAlive = false }, body, dependencies = {}) { const allocatePort = dependencies.availablePort ?? availablePort; const spawnProcess = dependencies.spawn ?? spawn; const waitForProcess = dependencies.waitForAnvil ?? waitForAnvil; const advance = dependencies.advanceTimeTo ?? advanceTimeTo; - const port = await allocatePort(); - const rpcUrl = `http://127.0.0.1:${port}`; + const selectedPort = port ?? await allocatePort(); + const rpcUrl = `http://127.0.0.1:${selectedPort}`; const args = [ '--fork-url', forkUrl, '--chain-id', String(chainId), - '--port', String(port), + '--port', String(selectedPort), '--silent', + '--no-rate-limit', + '--retries', '20', + '--timeout', '120000', + // A Base fork inherits mainnet's fee history, so Anvil enforces a minimum + // priority fee above the fee Foundry picks for the fork. Those transactions + // are accepted but never mined, and `forge script` then blocks forever + // polling for receipts. Disable the floor and zero the fork fee market. + '--disable-min-priority-fee', + '--base-fee', '0', + '--gas-price', '0', ]; if (forkBlockNumber != null) args.push('--fork-block-number', String(forkBlockNumber)); - const child = spawnProcess('anvil', args, { stdio: 'inherit' }); + const child = spawnProcess('anvil', args, keepAlive + ? { stdio: 'ignore', detached: true } + : { stdio: 'inherit' }); try { await waitForProcess(rpcUrl, child); if (timestamp != null) advance(rpcUrl, timestamp); - return await body({ rpcUrl }); + const result = await body({ rpcUrl, child }); + if (keepAlive) child.unref?.(); + return result; } finally { - child.kill('SIGTERM'); + if (!keepAlive) child.kill('SIGTERM'); } } diff --git a/scripts/deployments/runtime/foundry.mjs b/scripts/deployments/runtime/foundry.mjs index e61de6710..4f015ff74 100644 --- a/scripts/deployments/runtime/foundry.mjs +++ b/scripts/deployments/runtime/foundry.mjs @@ -25,10 +25,11 @@ export function parseHexNumber(value) { * Runs a Foundry script. `contractNames` maps Solidity contract names to * ledger keys so a migration never has to parse broadcast files itself. */ -export function runForgeScript({ target, rpcUrl, broadcast, verify, etherscanApiKey, env, walletArgs = [] }) { +export function runForgeScript({ target, rpcUrl, broadcast, verify, etherscanApiKey, env, walletArgs = [], slow = false }) { const args = ['script', target, '--rpc-url', rpcUrl, '--via-ir', ...walletArgs]; if (broadcast) { args.push('--broadcast'); + if (slow) args.push('--slow'); // Basescan only accepts Etherscan V2 API keys; pin the version so a stale // Foundry default cannot fail verification after transactions were sent. if (verify) args.push('--verify', '--etherscan-api-key', etherscanApiKey, '--etherscan-api-version', 'v2'); diff --git a/scripts/generate-contract-chain-config.mjs b/scripts/generate-contract-chain-config.mjs index ffa179831..ef4edd236 100644 --- a/scripts/generate-contract-chain-config.mjs +++ b/scripts/generate-contract-chain-config.mjs @@ -12,7 +12,8 @@ export const generatedChainConfigFile = path.join( 'packages/node/src/payments/generated-contract-addresses.ts', ); -const contractFields = { +export const contractFields = { + registry: 'registryContractAddress', usdc: 'usdcContractAddress', deposits: 'depositsContractAddress', channels: 'channelsContractAddress', @@ -20,17 +21,27 @@ const contractFields = { staking: 'stakingContractAddress', emissions: 'emissionsContractAddress', legacyEmissions: 'legacyEmissionsContractAddress', + legacyStaking: 'legacyStakingContractAddress', + legacyEmissionsV1: 'legacyEmissionsV1ContractAddress', antsToken: 'antsTokenAddress', identityRegistry: 'identityRegistryAddress', stats: 'statsContractAddress', depositRelay: 'depositRelayAddress', + emissionsGate: 'emissionsGateAddress', + sellerPools: 'sellerPoolsAddress', + sellerRegistry: 'sellerRegistryAddress', + positionInit: 'positionInitAddress', + usageAccounting: 'usageAccountingAddress', + usageRewards: 'usageRewardsAddress', + sellerPoolsRewards: 'sellerPoolsRewardsAddress', + legacyEmissionsEscrow: 'legacyEmissionsEscrowAddress', }; async function readDeployment(network) { return JSON.parse(await readFile(path.join(deploymentsRoot, network, 'current.json'), 'utf8')); } -function renderNetwork(record) { +export function renderNetwork(record) { const values = { evmChainId: record.chainId }; for (const [contractName, field] of Object.entries(contractFields)) { const contract = record.contracts[contractName]; diff --git a/scripts/m001-sandbox.mjs b/scripts/m001-sandbox.mjs new file mode 100644 index 000000000..c969af3d3 --- /dev/null +++ b/scripts/m001-sandbox.mjs @@ -0,0 +1,290 @@ +#!/usr/bin/env node + +import { mkdir, readFile, rm } from 'node:fs/promises'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { + ANVIL_ACCOUNT_0, + ANVIL_ACCOUNT_1, + BASE_MAINNET_DIEM_PROXY, + BASE_MAINNET_DIEM_STAKER, + BASE_MAINNET_FORK_BLOCK, + deployForkWashTradingStub, + migration, + prepareForkOwners, + prepareForkStaker, +} from './deployments/m001.mjs'; +import { withAnvilFork, advanceTimeTo, impersonatedSend } from './deployments/runtime/anvil.mjs'; +import { call, cast, firstValue } from './deployments/runtime/chain.mjs'; +import { loadContext } from './deployments/runtime/ledger.mjs'; +import { runMigration } from './deployments/runtime/runner.mjs'; +import { writeJsonAtomic } from './deployments/runtime/artifacts.mjs'; +import { loadDotEnv } from './deployments/runtime/env.mjs'; +import { renderNetwork } from './generate-contract-chain-config.mjs'; + +const DEFAULT_OUT = '.m001-sandbox'; +const DEFAULT_PORT = 8545; +const STATE_FILE = 'sandbox.json'; +const CLI_CONFIG_FILE = 'cli-config.json'; +const WEEK_SECONDS = 7 * 24 * 60 * 60; + +function usage() { + console.log(`Usage: pnpm m001:sandbox [args] [--port ] [--out ] + +Commands: + up + cutover + advance-epoch [n] + fund-seller
+ fund-ants
+ fund-position-init + status + down`); +} + +export function parseSandboxArgs(argv) { + const args = [...argv]; + const command = args.shift(); + let port = DEFAULT_PORT; + let out = DEFAULT_OUT; + const positional = []; + while (args.length) { + const arg = args.shift(); + if (arg === '--port') port = Number(args.shift()); + else if (arg === '--out') out = args.shift(); + else positional.push(arg); + } + if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error('--port must be a valid TCP port'); + return { command, port, out: path.resolve(out), positional }; +} + +function statePath(out) { return path.join(out, STATE_FILE); } +function cliConfigPath(out) { return path.join(out, CLI_CONFIG_FILE); } + +async function readJson(file) { return JSON.parse(await readFile(file, 'utf8')); } + +async function readState(out) { + try { return await readJson(statePath(out)); } + catch { throw new Error(`M001 sandbox is not running at ${out}. Run 'pnpm m001:sandbox up' first.`); } +} + +function processIsRunning(pid) { + try { process.kill(pid, 0); return true; } + catch { return false; } +} + +function forkEnvironment(washTradingRegistry) { + return { + WASH_TRADING_REGISTRY: washTradingRegistry, + VERIFICATION_WALLET: ANVIL_ACCOUNT_1, + DIEM_STAKING_PROXY: BASE_MAINNET_DIEM_PROXY, + ANTSEED_DEPLOY_CONFIRM: 'base-mainnet', + }; +} + +function forkSigners() { + return { + deployer: `unlocked:${ANVIL_ACCOUNT_0}`, + registryOwner: `unlocked:${ANVIL_ACCOUNT_0}`, + channelsOwner: `unlocked:${ANVIL_ACCOUNT_1}`, + sellerRewardsPoolOwner: `unlocked:${ANVIL_ACCOUNT_1}`, + diemStaker: `unlocked:${BASE_MAINNET_DIEM_STAKER}`, + }; +} + +async function writeCliConfig(out, rpcUrl) { + const record = await readJson(path.join(out, 'base-mainnet', 'current.json')); + const generated = renderNetwork(record); + const crypto = { chainId: 'base-mainnet', rpcUrl, fallbackRpcUrls: [] }; + for (const [key, value] of Object.entries(generated)) { + if (key.endsWith('Address')) crypto[key] = value; + } + await writeJsonAtomic(cliConfigPath(out), { payments: { crypto } }); +} + +async function up(options) { + const forkUrl = process.env.BASE_MAINNET_RPC_URL; + if (!forkUrl) throw new Error('BASE_MAINNET_RPC_URL is required'); + await mkdir(options.out, { recursive: true }); + try { + const existing = await readJson(statePath(options.out)); + if (processIsRunning(existing.pid)) throw new Error(`Sandbox already running at ${existing.rpcUrl} (pid ${existing.pid})`); + } catch (error) { + if ((error).message?.startsWith('Sandbox already')) throw error; + } + + await withAnvilFork({ + forkUrl, + forkBlockNumber: BASE_MAINNET_FORK_BLOCK, + chainId: 8453, + port: options.port, + keepAlive: true, + }, async ({ rpcUrl, child }) => { + try { + const context = await loadContext(migration, 'base-mainnet', { rpcUrl, outputRoot: options.out, forkTest: true }); + prepareForkOwners(context); + const washTradingRegistry = process.env.WASH_TRADING_REGISTRY ?? deployForkWashTradingStub(rpcUrl); + const overrides = { + rpcUrl, + outputRoot: options.out, + forkTest: true, + environment: forkEnvironment(washTradingRegistry), + signers: forkSigners(), + }; + const observation = await runMigration(migration, { network: 'base-mainnet', mode: 'broadcast', signers: {} }, overrides); + if (observation.state !== 'awaiting-epoch') throw new Error(`Expected awaiting-epoch after deploy, got ${observation.state}`); + await writeCliConfig(options.out, rpcUrl); + await writeJsonAtomic(statePath(options.out), { + pid: child.pid, + rpcUrl, + port: options.port, + outputRoot: options.out, + washTradingRegistry, + cutoverTimestamp: observation.deployment.checkpoint.cutoverTimestamp, + startedAt: new Date().toISOString(), + }); + console.log(`M001 sandbox deployed (pre-cutover): ${rpcUrl}`); + console.log(`CLI config: ${cliConfigPath(options.out)}`); + } catch (error) { + child.kill('SIGTERM'); + throw error; + } + }); +} + +async function cutover(options) { + const state = await readState(options.out); + if (!processIsRunning(state.pid)) throw new Error(`Anvil process ${state.pid} is not running`); + advanceTimeTo(state.rpcUrl, state.cutoverTimestamp + 1); + const context = await loadContext(migration, 'base-mainnet', { rpcUrl: state.rpcUrl, outputRoot: options.out, forkTest: true }); + prepareForkStaker(context); + const observation = await runMigration( + migration, + { network: 'base-mainnet', mode: 'broadcast', signers: {} }, + { + rpcUrl: state.rpcUrl, + outputRoot: options.out, + forkTest: true, + environment: forkEnvironment(state.washTradingRegistry), + signers: forkSigners(), + }, + ); + if (observation.state !== 'active') throw new Error(`Expected active after cutover, got ${observation.state}`); + await writeCliConfig(options.out, state.rpcUrl); + console.log(`M001 cutover complete. CLI config refreshed: ${cliConfigPath(options.out)}`); +} + +async function advanceEpoch(options) { + const state = await readState(options.out); + const count = Number(options.positional[0] ?? 1); + if (!Number.isInteger(count) || count <= 0) throw new Error('advance-epoch count must be a positive integer'); + const latest = JSON.parse(cast(state.rpcUrl, ['block', 'latest', '--json'])); + const timestamp = Number(BigInt(latest.timestamp)); + advanceTimeTo(state.rpcUrl, timestamp + count * WEEK_SECONDS); + console.log(`Advanced ${count} epoch(s).`); +} + +function requireAddress(value, label) { + if (!/^0x[0-9a-fA-F]{40}$/.test(value ?? '')) throw new Error(`${label} must be an EVM address`); + return value; +} + +function parseAntsAmount(value) { + const match = value?.match(/^(\d+)(?:\.(\d{1,18}))?$/); + if (!match) throw new Error('ANTS amount must be a positive number with at most 18 decimals'); + const amount = BigInt(match[1]) * 10n ** 18n + BigInt((match[2] ?? '').padEnd(18, '0') || '0'); + if (amount <= 0n) throw new Error('ANTS amount must be positive'); + return amount; +} + +async function currentContracts(out) { + return (await readJson(path.join(out, 'base-mainnet', 'current.json'))).contracts; +} + +async function fundSeller(options) { + const state = await readState(options.out); + const recipient = requireAddress(options.positional[0], 'seller address'); + const contracts = await currentContracts(options.out); + const holder = contracts.legacyStaking?.address ?? contracts.staking.address; + impersonatedSend(state.rpcUrl, holder, contracts.usdc.address, 'transfer(address,uint256)', [recipient, '50000000']); + cast(state.rpcUrl, ['rpc', 'anvil_setBalance', recipient, '0xDE0B6B3A7640000']); + console.log(`Funded ${recipient} with 50 USDC and 1 ETH.`); +} + +function fundAntsFromHolder(rpcUrl, token, holder, recipient, amount) { + const owner = call(rpcUrl, token, 'owner()(address)'); + impersonatedSend(rpcUrl, owner, token, 'setTransferWhitelist(address,bool)', [holder, 'true']); + try { + impersonatedSend(rpcUrl, holder, token, 'transfer(address,uint256)', [recipient, String(amount)]); + } finally { + impersonatedSend(rpcUrl, owner, token, 'setTransferWhitelist(address,bool)', [holder, 'false']); + } +} + +async function fundAnts(options) { + const state = await readState(options.out); + const recipient = requireAddress(options.positional[0], 'recipient address'); + const amountText = options.positional[1]; + if (!amountText) throw new Error('fund-ants requires an amount'); + const amount = parseAntsAmount(amountText); + const holder = requireAddress(process.env.ANTS_HOLDER, 'ANTS_HOLDER'); + const contracts = await currentContracts(options.out); + fundAntsFromHolder(state.rpcUrl, contracts.antsToken.address, holder, recipient, amount); + const owner = call(state.rpcUrl, contracts.antsToken.address, 'owner()(address)'); + impersonatedSend(state.rpcUrl, owner, contracts.antsToken.address, 'setTransferWhitelist(address,bool)', [recipient, 'true']); + console.log(`Funded ${recipient} with ${amountText} ANTS.`); +} + +async function fundPositionInit(options) { + const state = await readState(options.out); + const count = Number(options.positional[0]); + if (!Number.isInteger(count) || count <= 0) throw new Error('fund-position-init requires a positive integer'); + const holder = requireAddress(process.env.ANTS_HOLDER, 'ANTS_HOLDER'); + const contracts = await currentContracts(options.out); + if (!contracts.positionInit) throw new Error('positionInit is not active; run cutover first'); + const initAmount = BigInt(firstValue(call(state.rpcUrl, contracts.positionInit.address, 'initAmount()(uint256)'))); + fundAntsFromHolder(state.rpcUrl, contracts.antsToken.address, holder, contracts.positionInit.address, initAmount * BigInt(count)); + console.log(`Funded PositionInit for ${count} starter position(s).`); +} + +async function status(options) { + const state = await readState(options.out); + const contracts = await currentContracts(options.out); + const registry = contracts.registry.address; + console.log(`RPC: ${state.rpcUrl}`); + console.log(`PID: ${state.pid} (${processIsRunning(state.pid) ? 'running' : 'stopped'})`); + console.log(`Release: ${(await readJson(path.join(options.out, 'base-mainnet', 'current.json'))).release}`); + console.log(`Registry emissions: ${call(state.rpcUrl, registry, 'emissions()(address)')}`); + console.log(`Registry staking: ${call(state.rpcUrl, registry, 'staking()(address)')}`); +} + +async function down(options) { + const state = await readState(options.out); + if (processIsRunning(state.pid)) process.kill(state.pid, 'SIGTERM'); + await rm(statePath(options.out), { force: true }); + console.log(`Stopped M001 sandbox on ${state.rpcUrl}.`); +} + +export async function runSandbox(options) { + await loadDotEnv(); + switch (options.command) { + case 'up': return up(options); + case 'cutover': return cutover(options); + case 'advance-epoch': return advanceEpoch(options); + case 'fund-seller': return fundSeller(options); + case 'fund-ants': return fundAnts(options); + case 'fund-position-init': return fundPositionInit(options); + case 'status': return status(options); + case 'down': return down(options); + default: usage(); throw new Error(`Unknown or missing command: ${options.command ?? '(none)'}`); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + runSandbox(parseSandboxArgs(process.argv.slice(2))).catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} From 9c19a9122130ca40c958588df49ccfd1d8d33b2a Mon Sep 17 00:00:00 2001 From: alexanderludwig Date: Sat, 5 Sep 2026 01:31:24 +0200 Subject: [PATCH 2/3] fix(cli): refine seller registration rewards and withdrawal safety --- apps/cli/README.md | 25 ++- apps/cli/package.json | 1 + apps/cli/src/cli/commands/emissions.ts | 74 +++---- .../cli/src/cli/commands/network/contracts.ts | 2 +- apps/cli/src/cli/commands/reward-actions.ts | 109 +++++++++ .../commands/seller/contract-clients.test.ts | 143 ++++++++++++ apps/cli/src/cli/commands/seller/index.ts | 2 + apps/cli/src/cli/commands/seller/pool.test.ts | 11 +- apps/cli/src/cli/commands/seller/pool.ts | 151 +++++++++---- apps/cli/src/cli/commands/seller/register.ts | 16 +- .../src/cli/commands/seller/rewards.test.ts | 150 +++++++++++++ apps/cli/src/cli/commands/seller/rewards.ts | 134 +++++++++++ .../cli/src/cli/commands/seller/stake.test.ts | 39 +++- apps/cli/src/cli/commands/seller/stake.ts | 16 +- apps/cli/src/cli/commands/seller/status.ts | 2 +- apps/cli/src/cli/payment-utils.ts | 20 +- apps/cli/src/cli/reward-preview.ts | 45 ++++ apps/cli/src/cli/seller-contract-clients.ts | 208 ++++++++++++++++++ pnpm-lock.yaml | 3 + 19 files changed, 1023 insertions(+), 128 deletions(-) create mode 100644 apps/cli/src/cli/commands/reward-actions.ts create mode 100644 apps/cli/src/cli/commands/seller/contract-clients.test.ts create mode 100644 apps/cli/src/cli/commands/seller/rewards.test.ts create mode 100644 apps/cli/src/cli/commands/seller/rewards.ts create mode 100644 apps/cli/src/cli/reward-preview.ts create mode 100644 apps/cli/src/cli/seller-contract-clients.ts diff --git a/apps/cli/README.md b/apps/cli/README.md index 0a6a48359..a9c6a1319 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -13,14 +13,13 @@ Command-line interface and web dashboard for the AntSeed Network — a P2P netwo | **Providing** | | | `antseed seller start` | Start providing AI services on the P2P network | | `antseed seller register` | Register peer identity on-chain (ERC-8004) | -| `antseed seller stake --epochs ` | Stake ANTS into your seller pool (recognized-usage stack) | +| `antseed seller stake [--epochs ]` | Stake as a provider: ANTS after the recognized-usage upgrade, USDC before it | | `antseed seller legacy stake ` | Stake USDC as a provider before cutover (min $10) | | `antseed seller legacy unstake` | Withdraw legacy USDC stake | -| `antseed seller pool bootstrap` | Claim the legacy-seller starter ANTS position after the recognized-usage cutover | +| `antseed seller pool claim-starter` | Claim the legacy-seller starter ANTS position after the recognized-usage upgrade | | `antseed seller pool positions` | List seller-pool positions and lifecycle state | -| `antseed seller pool rewards [claim]` | View or claim indexed pool rewards | -| `antseed seller pool withdraw [--force]` | Withdraw matured positions or explicitly accept early-exit slashing | -| `antseed seller emissions claim` | Claim accumulated seller payouts | +| `antseed seller pool withdraw [--accept-slashing]` | Withdraw positions, with a slashing estimate and confirmation for early exits | +| `antseed seller rewards [claim]` | View or claim all seller rewards | | **Buying** | | | `antseed buyer start` | Start the buyer proxy and connect to sellers | | `antseed buyer start --router ` | Start the buyer proxy with a non-default router | @@ -357,14 +356,22 @@ After the M001 recognized-usage cutover, new seller stake moves from legacy USDC ```bash antseed seller register -antseed seller pool bootstrap +antseed seller pool claim-starter antseed seller stake 100 --epochs 4 antseed seller pool positions -antseed seller pool rewards -antseed seller pool rewards claim +antseed seller rewards +antseed seller rewards claim ``` -`antseed seller stake` targets the active stack: ANTS seller pools after cutover (`--epochs` required) and legacy USDC before it. `antseed seller legacy stake` is explicit legacy USDC staking and refuses to run after cutover. `antseed seller legacy unstake` (also available as `antseed seller unstake`) withdraws legacy stake and warns that doing so can remove temporary eligibility before an ANTS pool becomes active. Emissions commands verify the registry before every read or claim and, after cutover, process finalized legacy epochs and recognized-usage epochs in the same run. Use `--legacy-only` or `--new-only` to restrict a claim. +`antseed seller stake` automatically uses ANTS after the recognized-usage upgrade (`--epochs` required) and legacy USDC before it. `antseed seller legacy stake` is explicit legacy USDC staking and refuses to run after the upgrade. `antseed seller legacy unstake` (also available as `antseed seller unstake`) withdraws legacy stake and warns that doing so can remove temporary eligibility before an ANTS position becomes active. `antseed seller rewards` combines legacy emissions, recognized-use emissions, and pool-staking rewards. The specialized `seller emissions` and `seller pool rewards` commands remain available for advanced use. + +`seller register` explicitly binds your existing agent identity to the current seller registry, independently of legacy stake. Repeating it when already bound sends no transaction. If registration needs updating, `seller stake` stops and asks you to run `antseed seller register`; staking never registers you silently. + +`seller rewards` is read-only: it calculates unclaimed rewards from completed epochs using existing contract getters, including pool earnings that have not yet been indexed. It does not sign transactions or spend gas. Pool previews use the same reward-index and position-segment rounding as the payout calculation. The pool contribution is read at a single block; amounts can change before a claim confirms. Historical position discovery includes withdrawn and closed positions using receipt burn events and may require an archive-capable RPC with historical log support. Read failures are reported rather than treated as zero rewards. + +`seller rewards claim` prepares pool accounting in bounded transactions when necessary, then claims the rewards. Preparation and claims require gas. Confirmed transaction hashes are printed immediately, and received amounts are read from ANTS transfer receipts. If a later step fails, the CLI reports partial completion; rerun the command to collect remaining rewards. Compatibility commands `seller emissions`, `seller pool rewards`, and `seller unstake` remain callable but are hidden from the primary help listing. + +Early withdrawal requires `--accept-slashing` and interactive confirmation; add `--yes` for automation. The CLI rechecks the estimate before submitting. Existing contracts determine slashing at execution and do not accept a maximum-loss bound, so the displayed estimate is not a guaranteed cap if rates change before confirmation. ### M001 Anvil rehearsal diff --git a/apps/cli/package.json b/apps/cli/package.json index 0611a7de9..d612b120e 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -35,6 +35,7 @@ "cli-table3": "^0.6.5", "commander": "^14.0.3", "dotenv": "^16.6.1", + "ethers": "~6.16.0", "open": "^11.0.0", "ora": "^9.3.0", "qrcode": "^1.5.4" diff --git a/apps/cli/src/cli/commands/emissions.ts b/apps/cli/src/cli/commands/emissions.ts index c7fbb3c1c..37cb46c1b 100644 --- a/apps/cli/src/cli/commands/emissions.ts +++ b/apps/cli/src/cli/commands/emissions.ts @@ -5,6 +5,7 @@ import { getGlobalOptions } from './types.js'; import { loadConfig } from '../../config/loader.js'; import { createEmissionsClient, + createAntsTokenClient, createLegacyEmissionsClient, createSellerPoolsClient, createUsageAccountingClient, @@ -14,6 +15,7 @@ import { resolveCliContractStack, } from '../payment-utils.js'; import { legacyEpochs, newEpochs } from '@antseed/node/payments'; +import { claimBuyerEpochRewards, claimEpochRewards, pendingEpochRewards, RewardClaimProgress } from './reward-actions.js'; export type EmissionsRole = 'seller' | 'buyer'; @@ -48,14 +50,14 @@ function pendingJsonKey(role: EmissionsRole): 'pendingSeller' | 'pendingBuyer' { export function registerEmissionsCommand(parentCmd: Command, role: EmissionsRole): void { const emissions = parentCmd - .command('emissions') + .command('emissions', { hidden: role === 'seller' }) .description('View epoch info and pending ANTS emissions'); emissions .command('info') .description('Show current epoch info and pending emissions') .option('--json', 'output as JSON', false) - .option('--legacy-only', 'show only legacy-stack rewards', false) + .option('--legacy-only', 'show only rewards earned before the recognized-usage upgrade', false) .option('--new-only', 'show only recognized-usage rewards', false) .action(async (options) => { const globalOpts = getGlobalOptions(parentCmd); @@ -78,32 +80,33 @@ export function registerEmissionsCommand(parentCmd: Command, role: EmissionsRole let emissionRate = 0n; let epochDuration = 0; - if (selected.legacy && legacyIds.length >= 0) { + if (selected.legacy && (stack.mode === 'legacy' || stack.addresses.legacyEmissionsContractAddress)) { const client = stack.mode === 'legacy' ? createEmissionsClient(config) : createLegacyEmissionsClient(config); const [epochInfo, pending] = await Promise.all([ client.getEpochInfo(), - client.pendingEmissions(address, legacyIds), + pendingEpochRewards(legacyIds, async (epochs) => claimablePendingForRole(await client.pendingEmissions(address, epochs), role)), ]); emissionRate = epochInfo.emission; epochDuration = epochInfo.epochDuration; - legacyPending = claimablePendingForRole(pending, role); + legacyPending = pending; } let noCurrentPool = false; if (selected.recognized && stack.mode === 'recognized-usage') { if (role === 'seller') { const usage = createUsageAccountingClient(config); - const pending = await usage.pendingEmissions(address, recognizedIds); - recognizedPending = pending.seller; + recognizedPending = await pendingEpochRewards(recognizedIds, async (epochs) => (await usage.pendingEmissions(address, epochs)).seller); const pools = createSellerPoolsClient(config); const agentId = await pools.agentIdForSeller(address); noCurrentPool = agentId === 0 || !(await pools.hasPoolAtEpoch(agentId, stack.currentEpoch)); } else { const rewards = createUsageRewardsClient(config); - const amounts = await Promise.all(recognizedIds.map(async (epoch) => ( - await rewards.buyerEpochClaimed(address, epoch) ? 0n : rewards.pendingBuyerReward(address, epoch) - ))); - recognizedPending = amounts.reduce((total, value) => total + value, 0n); + recognizedPending = await pendingEpochRewards(recognizedIds, async (epochs) => { + const amounts = await Promise.all(epochs.map(async (epoch) => ( + await rewards.buyerEpochClaimed(address, epoch) ? 0n : rewards.pendingBuyerReward(address, epoch) + ))); + return amounts.reduce((total, value) => total + value, 0n); + }); } } @@ -144,7 +147,7 @@ export function registerEmissionsCommand(parentCmd: Command, role: EmissionsRole emissions .command('claim') .description(`Claim pending ${role} ANTS emissions`) - .option('--legacy-only', 'claim only legacy-stack rewards', false) + .option('--legacy-only', 'claim only rewards earned before the recognized-usage upgrade', false) .option('--new-only', 'claim only recognized-usage rewards', false) .action(async (options) => { const globalOpts = getGlobalOptions(parentCmd); @@ -154,6 +157,7 @@ export function registerEmissionsCommand(parentCmd: Command, role: EmissionsRole console.log(chalk.dim(`Wallet: ${address}`)); const spinner = ora(`Claiming ${role} emissions...`).start(); + let progress: RewardClaimProgress | undefined; try { const selected = selectedEmissionStacks(options); @@ -164,50 +168,42 @@ export function registerEmissionsCommand(parentCmd: Command, role: EmissionsRole const recognizedIds = stack.mode === 'recognized-usage' ? newEpochs(stack.currentEpoch, stack.firstRewardedEpoch!) : []; - let claimed = 0n; - const transactions: string[] = []; + const token = createAntsTokenClient(config); + progress = new RewardClaimProgress( + (hash) => console.log(chalk.dim(`Claim transaction confirmed: ${hash}`)), + (hash) => token.receivedInTransaction(hash, address), + ); - if (selected.legacy) { + if (selected.legacy && (stack.mode === 'legacy' || stack.addresses.legacyEmissionsContractAddress)) { const client = stack.mode === 'legacy' ? createEmissionsClient(config) : createLegacyEmissionsClient(config); - const pending = await client.pendingEmissions(address, legacyIds); - const amount = claimablePendingForRole(pending, role); - if (amount > 0n) { - transactions.push(role === 'seller' - ? await client.claimSellerEmissions(wallet, legacyIds) - : await client.claimBuyerEmissions(wallet, address, legacyIds)); - claimed += amount; - } + await claimEpochRewards(legacyIds, + async (epochs) => claimablePendingForRole(await client.pendingEmissions(address, epochs), role), + (epochs) => role === 'seller' ? client.claimSellerEmissions(wallet, epochs) : client.claimBuyerEmissions(wallet, address, epochs), + progress.record); } if (selected.recognized && stack.mode === 'recognized-usage') { if (role === 'seller') { const usage = createUsageAccountingClient(config); - const pending = await usage.pendingEmissions(address, recognizedIds); - if (pending.seller > 0n) { - transactions.push(await usage.claimSellerEmissions(wallet, recognizedIds)); - claimed += pending.seller; - } + await claimEpochRewards(recognizedIds, + async (epochs) => (await usage.pendingEmissions(address, epochs)).seller, + (epochs) => usage.claimSellerEmissions(wallet, epochs), progress.record); } else { const rewards = createUsageRewardsClient(config); - for (const epoch of recognizedIds.slice(-104)) { - if (await rewards.buyerEpochClaimed(address, epoch)) continue; - const amount = await rewards.pendingBuyerReward(address, epoch); - if (amount === 0n) continue; - transactions.push(await rewards.claimBuyerReward(wallet, address, epoch)); - claimed += amount; - } + await claimBuyerEpochRewards(recognizedIds, + async (epoch) => await rewards.buyerEpochClaimed(address, epoch) ? 0n : rewards.pendingBuyerReward(address, epoch), + (epoch) => rewards.claimBuyerReward(wallet, address, epoch), progress.record); } } - if (claimed === 0n) { + if (progress.claimed === 0n) { spinner.succeed(chalk.yellow(`No pending ${role} emissions to claim.`)); return; } - spinner.succeed(chalk.green(`Claimed ${formatAnts(claimed)} ANTS`)); - for (const txHash of transactions) console.log(chalk.dim(`Transaction: ${txHash}`)); + spinner.succeed(chalk.green(`Claimed ${formatAnts(progress.claimed)} ANTS`)); } catch (err) { - spinner.fail(chalk.red(`Claim failed: ${(err as Error).message}`)); + spinner.fail(chalk.red(progress?.failure((err as Error).message) ?? `Claim failed: ${(err as Error).message}`)); process.exit(1); } }); diff --git a/apps/cli/src/cli/commands/network/contracts.ts b/apps/cli/src/cli/commands/network/contracts.ts index e317acfe2..a37dbed15 100644 --- a/apps/cli/src/cli/commands/network/contracts.ts +++ b/apps/cli/src/cli/commands/network/contracts.ts @@ -38,7 +38,7 @@ export function registerNetworkContractsCommand(networkCmd: Command): void { }, null, 2)); return; } - console.log(chalk.bold(`Contract Stack (${crypto.chainId})\n`)); + console.log(chalk.bold(`Contracts (${crypto.chainId})\n`)); console.log(`Mode: ${chalk.cyan(stack.mode)}`); console.log(`Current epoch: ${stack.currentEpoch}`); if (stack.firstRewardedEpoch !== undefined) console.log(`First rewarded epoch: ${stack.firstRewardedEpoch}`); diff --git a/apps/cli/src/cli/commands/reward-actions.ts b/apps/cli/src/cli/commands/reward-actions.ts new file mode 100644 index 000000000..2d51c6bb8 --- /dev/null +++ b/apps/cli/src/cli/commands/reward-actions.ts @@ -0,0 +1,109 @@ +import type { CliSellerPoolsClient as SellerPoolsClient, CliSellerPoolsRewardsClient as SellerPoolsRewardsClient } from '../seller-contract-clients.js'; + +export type RewardTransactionRecorder = (hash: string, kind: 'claim' | 'accounting') => Promise; + +export async function pendingEpochRewards( + epochs: number[], + readPending: (epochs: number[]) => Promise, +): Promise { + let amount = 0n; + for (let offset = 0; offset < epochs.length; offset += 32) { + amount += await readPending(epochs.slice(offset, offset + 32)); + } + return amount; +} + +export async function claimEpochRewards( + epochs: number[], + readPending: (epochs: number[]) => Promise, + claim: (epochs: number[]) => Promise, + record: RewardTransactionRecorder, +): Promise { + for (let offset = 0; offset < epochs.length; offset += 32) { + const batch = epochs.slice(offset, offset + 32); + if (await readPending(batch) > 0n) await record(await claim(batch), 'claim'); + } +} + +export async function claimBuyerEpochRewards( + epochs: number[], + readPending: (epoch: number) => Promise, + claim: (epoch: number) => Promise, + record: RewardTransactionRecorder, +): Promise { + for (const epoch of epochs) { + if (await readPending(epoch) > 0n) await record(await claim(epoch), 'claim'); + } +} + +type PoolReader = Pick; +type PoolRewards = Pick; +type RewardSigner = Parameters[0]; + +export async function previewPoolRewards(pools: PoolReader, rewards: PoolRewards, address: string, positionId?: number) { + const positions = positionId === undefined ? await pools.rewardPositions(address) : [await pools.position(positionId)]; + const pending: Array<{ id: number; agentId: number; amount: bigint; closedAtEpoch: number }> = []; + for (const position of positions) { + if (position.owner.toLowerCase() !== address.toLowerCase()) throw new Error(`Position ${position.id} is not owned by this wallet.`); + pending.push({ id: position.id, agentId: position.agentId, amount: await rewards.previewStakerReward(position.id), closedAtEpoch: position.closedAtEpoch }); + } + return pending; +} + +export async function claimPoolRewards( + pools: PoolReader, + rewards: PoolRewards, + wallet: RewardSigner, + address: string, + recipient: string, + record: RewardTransactionRecorder, + preparing: () => void, + positionId?: number, +): Promise { + const pending = await previewPoolRewards(pools, rewards, address, positionId); + const rewardedPositions = pending.filter((position) => position.amount > 0n); + if (rewardedPositions.length === 0) return; + const currentEpoch = await pools.currentEpoch(); + for (const agentId of new Set(rewardedPositions.map((position) => position.agentId))) { + const targetEpoch = rewardedPositions.filter((position) => position.agentId === agentId) + .reduce((latest, position) => Math.max(latest, Math.min(currentEpoch, position.closedAtEpoch || currentEpoch)), 0); + let cursor = await rewards.poolRewardIndexNextEpoch(agentId) || await rewards.initialIndexEpoch(); + if (cursor < targetEpoch) preparing(); + while (cursor < targetEpoch) { + await record(await rewards.indexPoolRewards(wallet, agentId, Math.min(16, targetEpoch - cursor)), 'accounting'); + const next = await rewards.poolRewardIndexNextEpoch(agentId); + if (next <= cursor) throw new Error('Reward preparation made no progress. Retry the claim later.'); + cursor = next; + } + } + const ids: number[] = []; + for (const position of rewardedPositions) { + if (await rewards.pendingIndexedStakerReward(position.id) > 0n) ids.push(position.id); + } + for (let offset = 0; offset < ids.length; offset += 32) { + await record(await rewards.claimStakerRewardsBatch(wallet, ids.slice(offset, offset + 32), recipient), 'claim'); + } +} + +export class RewardClaimProgress { + claimed = 0n; + transactions: string[] = []; + + constructor( + private readonly report: (hash: string, kind: 'claim' | 'accounting') => void, + private readonly received: (hash: string) => Promise, + ) {} + + record: RewardTransactionRecorder = async (hash, kind) => { + this.transactions.push(hash); + this.report(hash, kind); + if (kind === 'claim') this.claimed += await this.received(hash); + }; + + failure(message: string): string { + if (this.transactions.length === 0) return `Claim failed: ${message}`; + return `Claim incomplete: ${this.transactions.length} transaction(s) already confirmed (shown above). ${message}. Re-run the claim to collect remaining rewards.`; + } +} diff --git a/apps/cli/src/cli/commands/seller/contract-clients.test.ts b/apps/cli/src/cli/commands/seller/contract-clients.test.ts new file mode 100644 index 000000000..04f99df37 --- /dev/null +++ b/apps/cli/src/cli/commands/seller/contract-clients.test.ts @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Interface, ZeroAddress, type AbstractSigner } from 'ethers'; +import { CliSellerPoolsClient, CliSellerPoolsRewardsClient, CliSellerRegistryClient, CliAntsTokenClient, collectPositionIds, registerSellerBinding, requireSellerBinding } from '../../seller-contract-clients.js'; +import { requireCryptoConfig } from '../../payment-utils.js'; +import type { AntseedConfig } from '../../../config/types.js'; + +const address = '0x0000000000000000000000000000000000000011'; +const contractAddress = '0x0000000000000000000000000000000000000022'; +const config = { rpcUrl: 'http://127.0.0.1:1', contractAddress, evmChainId: 31337 }; + +test('CLI reads slashing estimates directly without an SDK extension', async () => { + const client = new CliSellerPoolsClient({ ...config, antsTokenAddress: contractAddress }); + const abi = new Interface(['function earlyExitSlashBps(uint256) view returns (uint256)']); + Object.defineProperty(client, '_provider', { value: { + call: async (transaction: { data: string }) => { + const call = abi.parseTransaction(transaction)!; + assert.equal(call.name, 'earlyExitSlashBps'); + assert.equal(call.args[0], 7n); + return abi.encodeFunctionResult(call.name, [2500n]); + }, + } }); + assert.ok(Object.hasOwn(CliSellerPoolsClient.prototype, 'earlyExitSlashBps')); + assert.equal(await client.earlyExitSlashBps(7), 2500); +}); + +test('registration distinguishes legacy fallback, persists explicitly, and is idempotent', async () => { + let legacy = true; + let registered = false; + let writes = 0; + const registry = { + getAgentId: async () => legacy || registered ? 7 : 0, + isRegisteredSeller: async () => registered, + registerSeller: async () => { writes++; registered = true; return 'confirmed'; }, + }; + await assert.rejects(requireSellerBinding(registry, address), /Run: antseed seller register/); + assert.equal(writes, 0); + const hashes: string[] = []; + assert.equal(await registerSellerBinding(registry, {} as AbstractSigner, address, 7, (hash) => hashes.push(hash)), true); + legacy = false; + assert.equal(await requireSellerBinding(registry, address), 7); + assert.equal(await registerSellerBinding(registry, {} as AbstractSigner, address, 7, () => {}), false); + assert.equal(writes, 1); + assert.deepEqual(hashes, ['confirmed']); +}); + +test('registration reports a confirmed transaction before verification failure', async () => { + const hashes: string[] = []; + await assert.rejects(registerSellerBinding({ getAgentId: async () => 7, isRegisteredSeller: async () => false, registerSeller: async () => 'confirmed' }, + {} as AbstractSigner, address, 7, (hash) => hashes.push(hash)), /could not be verified/); + assert.deepEqual(hashes, ['confirmed']); +}); + +test('explicit binding reads the existing agentSeller getter, not just getAgentId', async () => { + const client = new CliSellerRegistryClient(config); + const abi = new Interface(['function agentSeller(uint256 agentId) view returns (address)']); + client.getAgentId = async () => 7; + let bound = ZeroAddress; + Object.defineProperty(client, '_provider', { value: { call: async () => abi.encodeFunctionResult('agentSeller', [bound]) } }); + assert.equal(await client.isRegisteredSeller(address, 7), false); + bound = address; + assert.equal(await client.isRegisteredSeller(address, 7), true); +}); + +test('position pagination includes every page', async () => { + const ids = Array.from({ length: 513 }, (_, index) => index + 1); + assert.deepEqual(await collectPositionIds(async (offset, limit) => ids.slice(offset, offset + limit)), ids); +}); + +test('historical reward discovery includes burned positions and filters old owners', async () => { + const client = new CliSellerPoolsClient({ ...config, antsTokenAddress: contractAddress }); + const active = Array.from({ length: 300 }, (_, index) => index + 1); + client.stakerPositionIds = async (_staker, offset = 0, limit = 256) => active.slice(offset, offset + limit); + client.position = async (id) => ({ id, owner: id === 2 ? contractAddress : address, agentId: 7, amount: 1n, weightAmount: 1n, stakeStartEpoch: 1, stakeEndEpoch: 4, closedAtEpoch: id === 301 ? 3 : 0, withdrawn: id === 301 }); + const abi = new Interface(['event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)']); + const log = abi.encodeEventLog(abi.getEvent('Transfer')!, [address, ZeroAddress, 301]); + Object.defineProperty(client, '_provider', { value: { + getBlockNumber: async () => 16, + getCode: async (_target: string, block: number) => block < 4 ? '0x' : '0x6000', + getLogs: async (filter: { fromBlock: number }) => { assert.equal(filter.fromBlock, 4); return [{ ...log, address: contractAddress }]; }, + } }); + const positions = await client.rewardPositions(address); + assert.equal(positions.length, 300); + assert.equal(positions.at(-1)!.id, 301); + assert.ok(!positions.some((position) => position.id === 2)); +}); + +test('preview uses only existing view selectors at one block, including unindexed epochs', async () => { + const client = new CliSellerPoolsRewardsClient(config); + const abi = new Interface([ + 'function sellerPools() view returns (address)', 'function usageAccounting() view returns (address)', + 'function positions(uint256) view returns (address,uint256,uint256,uint256,uint64,uint64,uint64,bool)', + 'function currentEpoch() view returns (uint256)', 'function positionClaimCursor(uint256) view returns (uint256)', + 'function poolRewardIndexNextEpoch(uint256) view returns (uint256)', 'function initialIndexEpoch() view returns (uint256)', + 'function positionPowerSegmentAt(uint256,uint256) view returns (uint256,uint256,uint256)', + 'function poolCumulativeRewardPerWeightAt(uint256,uint256) view returns (uint256)', + 'function poolCumulativeEpochRewardPerWeightAt(uint256,uint256) view returns (uint256)', + 'function poolWeightAtEpoch(uint256,uint256) view returns (uint256)', 'function poolEpochEmissions(uint256,uint256) view returns (bool,uint256)', + 'function weightedPoolPointsByEpoch(uint256,uint256) view returns (uint256)', 'function totalWeightedPoolPointsByEpoch(uint256) view returns (uint256)', + 'function stakerEpochBudget(uint256) view returns (uint256)', + ]); + let reads = 0; + Object.defineProperty(client, '_provider', { value: { + getBlockNumber: async () => 123, + call: async (transaction: { data: string; blockTag: number }) => { + assert.equal(transaction.blockTag, 123); + reads++; + const call = abi.parseTransaction(transaction)!; + let result: unknown[]; + switch (call.name) { + case 'sellerPools': case 'usageAccounting': result = [contractAddress]; break; + case 'positions': result = [address, 7, 3, 3, 1, 4, 0, false]; break; + case 'currentEpoch': result = [3]; break; + case 'initialIndexEpoch': result = [1]; break; + case 'positionPowerSegmentAt': result = [4, 0, 100]; break; + case 'poolWeightAtEpoch': result = [call.args[1] === 1n ? 10 : 9]; break; + case 'poolEpochEmissions': result = [false, 0]; break; + case 'weightedPoolPointsByEpoch': case 'totalWeightedPoolPointsByEpoch': result = [1]; break; + case 'stakerEpochBudget': result = [call.args[0] === 1n ? 100 : 101]; break; + default: result = [0]; + } + return abi.encodeFunctionResult(call.name, result); + }, + } }); + assert.equal(await client.previewStakerReward(1), 157n); + const firstReads = reads; + assert.equal(await client.previewStakerReward(1), 157n); + assert.equal(reads, firstReads); +}); + +test('confirmed reward totals count only actual incoming ANTS transfers', async () => { + const client = new CliAntsTokenClient(config); + const abi = new Interface(['event Transfer(address indexed from, address indexed to, uint256 value)']); + const transfer = (target: string, recipient: string, amount: bigint) => ({ address: target, ...abi.encodeEventLog(abi.getEvent('Transfer')!, [ZeroAddress, recipient, amount]) }); + Object.defineProperty(client, '_provider', { value: { getTransactionReceipt: async () => ({ status: 1, logs: [transfer(contractAddress, address, 12n), transfer(address, address, 99n), transfer(contractAddress, contractAddress, 30n)] }) } }); + assert.equal(await client.receivedInTransaction('confirmed', address), 12n); +}); + +test('CLI local defaults use the registry nonce rather than the token nonce and preserve overrides', () => { + const base = { payments: { crypto: { chainId: 'base-local' } } } as AntseedConfig; + assert.equal(requireCryptoConfig(base).registryContractAddress, '0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9'); + assert.equal(requireCryptoConfig({ ...base, payments: { ...base.payments, crypto: { ...base.payments!.crypto!, registryContractAddress: address } } }).registryContractAddress, address); +}); diff --git a/apps/cli/src/cli/commands/seller/index.ts b/apps/cli/src/cli/commands/seller/index.ts index 5b0edd4a4..a4135fcdf 100644 --- a/apps/cli/src/cli/commands/seller/index.ts +++ b/apps/cli/src/cli/commands/seller/index.ts @@ -7,6 +7,7 @@ import { registerSellerStakeCommand } from './stake.js'; import { registerSellerEmissionsCommand } from './emissions.js'; import { registerSellerDoctorCommand } from './doctor.js'; import { registerSellerPoolCommand } from './pool.js'; +import { registerSellerRewardsCommand } from './rewards.js'; export function registerSellerCommands(program: Command): void { const sellerCmd = program @@ -20,5 +21,6 @@ export function registerSellerCommands(program: Command): void { registerSellerStakeCommand(sellerCmd); registerSellerEmissionsCommand(sellerCmd); registerSellerPoolCommand(sellerCmd); + registerSellerRewardsCommand(sellerCmd); registerSellerDoctorCommand(sellerCmd); } diff --git a/apps/cli/src/cli/commands/seller/pool.test.ts b/apps/cli/src/cli/commands/seller/pool.test.ts index fcabf7c3a..7a9b6d878 100644 --- a/apps/cli/src/cli/commands/seller/pool.test.ts +++ b/apps/cli/src/cli/commands/seller/pool.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { positionState, validateStakeEpochs } from './pool.js'; +import { estimateEarlyExit, positionState, validateStakeEpochs } from './pool.js'; const position = { id: 1, owner: '0x1', agentId: 2, amount: 1n, weightAmount: 1n, stakeStartEpoch: 5, stakeEndEpoch: 9, closedAtEpoch: 0, withdrawn: false }; test('positionState covers pending, active, matured, and withdrawn positions', () => { @@ -14,3 +14,12 @@ test('validateStakeEpochs enforces contract bounds', () => { assert.throws(() => validateStakeEpochs(0, 1, 104)); assert.throws(() => validateStakeEpochs(105, 1, 104)); }); +test('estimateEarlyExit reports exact principal loss and return', () => { + assert.deepEqual(estimateEarlyExit({ ...position, amount: 100n * 10n ** 18n }, 2500), { + id: 1, + amount: 100n * 10n ** 18n, + slashBps: 2500, + slashedAmount: 25n * 10n ** 18n, + returnedAmount: 75n * 10n ** 18n, + }); +}); diff --git a/apps/cli/src/cli/commands/seller/pool.ts b/apps/cli/src/cli/commands/seller/pool.ts index 249a12624..1bdcdbf0f 100644 --- a/apps/cli/src/cli/commands/seller/pool.ts +++ b/apps/cli/src/cli/commands/seller/pool.ts @@ -1,4 +1,6 @@ import type { Command } from 'commander'; +import { isAddress, ZeroAddress } from 'ethers'; +import { createInterface } from 'node:readline/promises'; import chalk from 'chalk'; import Table from 'cli-table3'; import ora from 'ora'; @@ -12,11 +14,14 @@ import { createSellerPoolsRewardsClient, createSellerRegistryClient, formatAnts, + formatAntsExact, loadCryptoContext, parseAntsToBaseUnits, resolveCliContractStack, } from '../../payment-utils.js'; import type { SellerPoolPosition } from '@antseed/node/payments'; +import { claimPoolRewards, previewPoolRewards, RewardClaimProgress } from '../reward-actions.js'; +import { requireSellerBinding } from '../../seller-contract-clients.js'; export function positionState(position: SellerPoolPosition, currentEpoch: number): string { if (position.withdrawn) return 'withdrawn'; @@ -35,15 +40,47 @@ export function validateStakeEpochs(epochs: number, min: number, max: number): v async function requirePoolStack(config: Awaited>) { const stack = await resolveCliContractStack(config); if (stack.mode !== 'recognized-usage') { - throw new Error('Seller pool commands require the recognized-usage contract stack. Run them after M001 cutover.'); + throw new Error('Seller pool commands are available after the recognized-usage upgrade.'); } return stack; } +export interface EarlyExitEstimate { + id: number; + amount: bigint; + slashBps: number; + slashedAmount: bigint; + returnedAmount: bigint; +} + +export function estimateEarlyExit(position: SellerPoolPosition, slashBps: number): EarlyExitEstimate { + const slashedAmount = position.amount * BigInt(slashBps) / 10_000n; + return { + id: position.id, + amount: position.amount, + slashBps, + slashedAmount, + returnedAmount: position.amount - slashedAmount, + }; +} + +async function confirmEarlyExit(totalSlashed: bigint): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error('Early withdrawal needs interactive confirmation. Re-run in a terminal, or add --yes after reviewing the slashing estimate.'); + } + const input = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await input.question(chalk.red(`Withdraw with an estimated burn of ${formatAntsExact(totalSlashed)} ANTS? [y/N] `)); + if (!['y', 'yes'].includes(answer.trim().toLowerCase())) throw new Error('Withdrawal cancelled.'); + } finally { + input.close(); + } +} + export function registerSellerPoolCommand(sellerCmd: Command): void { - const pool = sellerCmd.command('pool').description('Manage recognized-usage ANTS seller-pool positions'); + const pool = sellerCmd.command('pool').description('Manage ANTS staking positions'); - pool.command('bootstrap').alias('init').description('Claim the legacy-seller starter ANTS position').action(async () => { + pool.command('claim-starter').aliases(['bootstrap', 'init']).description('Claim the legacy-seller starter ANTS position').action(async () => { const global = getGlobalOptions(pool); const config = await loadConfig(global.config); const spinner = ora('Checking starter position...').start(); @@ -70,14 +107,6 @@ export function registerSellerPoolCommand(sellerCmd: Command): void { } }); - pool.command('stake ') - .description('Stake ANTS into a seller pool (alias of `antseed seller stake`)') - .requiredOption('--epochs ', 'lock duration in epochs', (value) => Number(value)) - .option('--agent-id ', 'seller agent ID', (value) => Number(value)) - .action(async (amount: string, options: PoolStakeOptions) => { - await runPoolStake(getGlobalOptions(pool), amount, options); - }); - pool.command('positions').description('List your seller-pool positions').option('--json', 'output as JSON', false).action(async (options) => { const global = getGlobalOptions(pool); const config = await loadConfig(global.config); @@ -85,7 +114,7 @@ export function registerSellerPoolCommand(sellerCmd: Command): void { const stack = await requirePoolStack(config); const { address } = await loadCryptoContext(global.dataDir); const pools = createSellerPoolsClient(config); - const ids = await pools.stakerPositionIds(address); + const ids = await pools.allStakerPositionIds(address); const positions = await Promise.all(ids.map(async (id) => { const position = await pools.position(id); return { ...position, state: positionState(position, stack.currentEpoch), withdrawableEpoch: await pools.positionWithdrawableEpoch(id) }; @@ -107,19 +136,53 @@ export function registerSellerPoolCommand(sellerCmd: Command): void { } }); - pool.command('withdraw ').description('Withdraw seller-pool positions').option('--force', 'allow early exit and slashing', false).action(async (rawIds: string[], options) => { + pool.command('withdraw ') + .description('Withdraw seller-pool positions') + .option('--accept-slashing', 'allow an early exit after showing the estimated principal loss', false) + .option('-y, --yes', 'skip the interactive confirmation after accepting slashing', false) + .action(async (rawIds: string[], options: { acceptSlashing?: boolean; yes?: boolean }) => { const global = getGlobalOptions(pool); const config = await loadConfig(global.config); const spinner = ora('Checking positions...').start(); try { const stack = await requirePoolStack(config); const ids = rawIds.map((value) => Number(value)); - if (ids.some((id) => !Number.isInteger(id) || id <= 0)) throw new Error('Position IDs must be positive integers.'); - const positions = await Promise.all(ids.map((id) => createSellerPoolsClient(config).position(id))); - const early = positions.filter((position) => !position.withdrawn && position.closedAtEpoch === 0 && stack.currentEpoch < position.stakeEndEpoch); - if (early.length > 0 && !options.force) throw new Error(`Position(s) ${early.map((position) => position.id).join(', ')} are still locked; re-run with --force to accept early-exit slashing.`); - const { wallet } = await loadCryptoContext(global.dataDir); - const txHash = await createSellerPoolsClient(config).withdrawStakes(wallet, ids); + if (ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) throw new Error('Position IDs must be positive safe integers.'); + if (new Set(ids).size !== ids.length) throw new Error('Position IDs must not be repeated.'); + const { wallet, address } = await loadCryptoContext(global.dataDir); + const pools = createSellerPoolsClient(config); + const positions = await Promise.all(ids.map((id) => pools.position(id))); + const notOwned = positions.filter((position) => position.owner.toLowerCase() !== address.toLowerCase()); + if (notOwned.length > 0) throw new Error(`Position(s) ${notOwned.map((position) => position.id).join(', ')} are not owned by this wallet.`); + const unavailable = positions.filter((position) => position.withdrawn || position.closedAtEpoch !== 0); + if (unavailable.length > 0) throw new Error(`Position(s) ${unavailable.map((position) => position.id).join(', ')} are already closed or withdrawn.`); + const withdrawableEpochs = await Promise.all(positions.map((position) => pools.positionWithdrawableEpoch(position.id))); + const pending = positions.filter((_position, index) => stack.currentEpoch < withdrawableEpochs[index]!); + if (pending.length > 0) { + throw new Error(`Position change pending for position(s) ${pending.map((position) => position.id).join(', ')}; try again in the next epoch.`); + } + if (options.yes && !options.acceptSlashing) throw new Error('--yes requires --accept-slashing.'); + const estimates = (await Promise.all(positions + .map(async (position) => estimateEarlyExit(position, await pools.earlyExitSlashBps(position.id))))) + .filter((estimate) => estimate.slashBps > 0); + const totalSlashed = estimates.reduce((total, estimate) => total + estimate.slashedAmount, 0n); + if (estimates.length > 0) { + spinner.stop(); + console.log(chalk.bold('Early-exit slashing estimate:\n')); + for (const estimate of estimates) { + console.log(` Position ${estimate.id}: burn ${chalk.red(`${formatAntsExact(estimate.slashedAmount)} ANTS`)} (${(estimate.slashBps / 100).toFixed(2)}%), return ${formatAntsExact(estimate.returnedAmount)} ANTS`); + } + console.log(chalk.red(`\nEstimated principal burned: ${formatAntsExact(totalSlashed)} ANTS`)); + console.log(chalk.yellow('Final slashing is determined on-chain. Rates may change before the transaction confirms.')); + if (!options.acceptSlashing) throw new Error('Positions are still locked. Re-run with --accept-slashing to proceed.'); + if (!options.yes) await confirmEarlyExit(totalSlashed); + spinner.start('Withdrawing positions...'); + } + const latestQuotes = await Promise.all(positions.map(async (position) => estimateEarlyExit(position, await pools.earlyExitSlashBps(position.id)))); + if (latestQuotes.reduce((total, quote) => total + quote.slashedAmount, 0n) > totalSlashed) { + throw new Error('The slashing estimate increased. Re-run the command to review the new estimate.'); + } + const txHash = await pools.withdrawStakes(wallet, ids); spinner.succeed(chalk.green(`Withdrew ${ids.length} position(s)`)); console.log(chalk.dim(`Transaction: ${txHash}`)); } catch (error) { @@ -128,7 +191,7 @@ export function registerSellerPoolCommand(sellerCmd: Command): void { } }); - const rewards = pool.command('rewards').description('Show or claim indexed seller-pool rewards').option('--json', 'output as JSON', false); + const rewards = pool.command('rewards', { hidden: true }).description('Show or claim pool rewards (use seller rewards for all rewards)').option('--json', 'output as JSON', false); rewards.action(async (options) => { const global = getGlobalOptions(rewards); const config = await loadConfig(global.config); @@ -137,10 +200,9 @@ export function registerSellerPoolCommand(sellerCmd: Command): void { const { address } = await loadCryptoContext(global.dataDir); const pools = createSellerPoolsClient(config); const rewardsClient = createSellerPoolsRewardsClient(config); - const ids = await pools.stakerPositionIds(address); - const pending = await Promise.all(ids.map(async (id) => ({ id, amount: await rewardsClient.pendingIndexedStakerReward(id) }))); + const pending = await previewPoolRewards(pools, rewardsClient, address); const total = pending.reduce((sum, item) => sum + item.amount, 0n); - if (options.json) console.log(JSON.stringify({ positions: pending, total }, (_key, value) => typeof value === 'bigint' ? value.toString() : value, 2)); + if (options.json) console.log(JSON.stringify({ positions: pending.map(({ id, amount }) => ({ id, amount })), total }, (_key, value) => typeof value === 'bigint' ? value.toString() : value, 2)); else { for (const item of pending) console.log(`Position ${item.id}: ${formatAnts(item.amount)} ANTS`); console.log(chalk.bold(`Total: ${formatAnts(total)} ANTS`)); @@ -151,30 +213,32 @@ export function registerSellerPoolCommand(sellerCmd: Command): void { } }); - rewards.command('claim').description('Claim indexed rewards').option('--position ', 'claim one position', (value) => Number(value)).option('--recipient
', 'reward recipient').action(async (options) => { + rewards.command('claim').description('Claim pool rewards').option('--position ', 'claim one position', (value) => Number(value)).option('--recipient
', 'reward recipient').action(async (options) => { const global = getGlobalOptions(rewards); const config = await loadConfig(global.config); const spinner = ora('Checking pending rewards...').start(); + let progress: RewardClaimProgress | undefined; try { + if (options.position !== undefined && (!Number.isSafeInteger(options.position) || options.position <= 0)) { + throw new Error('Position ID must be a positive integer.'); + } await requirePoolStack(config); const { wallet, address } = await loadCryptoContext(global.dataDir); const pools = createSellerPoolsClient(config); const rewardsClient = createSellerPoolsRewardsClient(config); - const ids = options.position ? [options.position] : await pools.stakerPositionIds(address); - const pendingIds = []; - for (const id of ids) if (await rewardsClient.pendingIndexedStakerReward(id) > 0n) pendingIds.push(id); - if (pendingIds.length === 0) { - spinner.succeed(chalk.yellow('No indexed pool rewards pending.')); - return; - } const recipient = options.recipient || address; - const txHash = pendingIds.length === 1 - ? await rewardsClient.claimStakerRewards(wallet, pendingIds[0]!, recipient) - : await rewardsClient.claimStakerRewardsBatch(wallet, pendingIds, recipient); - spinner.succeed(chalk.green(`Claimed rewards for ${pendingIds.length} position(s)`)); - console.log(chalk.dim(`Transaction: ${txHash}`)); + if (!isAddress(recipient) || recipient === ZeroAddress) throw new Error('Reward recipient must be a valid nonzero address.'); + const token = createAntsTokenClient(config); + progress = new RewardClaimProgress( + (hash, kind) => console.log(chalk.dim(`${kind === 'accounting' ? 'Preparation' : 'Claim'} transaction confirmed: ${hash}`)), + (hash) => token.receivedInTransaction(hash, recipient), + ); + await claimPoolRewards(pools, rewardsClient, wallet, address, recipient, progress.record, () => { + spinner.text = 'Updating pool rewards (preparation transactions require gas)...'; + }, options.position); + spinner.succeed(progress.claimed > 0n ? chalk.green(`Claimed ${formatAnts(progress.claimed)} ANTS`) : chalk.yellow('No pool rewards pending.')); } catch (error) { - spinner.fail(chalk.red((error as Error).message)); + spinner.fail(chalk.red(progress?.failure((error as Error).message) ?? (error as Error).message)); process.exitCode = 1; } }); @@ -185,7 +249,7 @@ export interface PoolStakeOptions { agentId?: number; } -/** Stake ANTS into a seller pool. Shared by `seller stake` and `seller pool stake`. */ +/** Stake ANTS into a seller pool through the primary `seller stake` command. */ export async function runPoolStake( global: { config: string; dataDir: string }, amount: string, @@ -201,16 +265,7 @@ export async function runPoolStake( const registry = createSellerRegistryClient(config); const [minEpochs, maxEpochs] = await Promise.all([pools.minStakeEpochs(), pools.maxStakeEpochs()]); validateStakeEpochs(options.epochs, minEpochs, maxEpochs); - let agentId = options.agentId || await registry.getAgentId(address); - if (!agentId) agentId = await createLegacyStakingClient(config).getAgentId(address); - if (!agentId) throw new Error('No seller agent ID found. Pass --agent-id or run antseed seller register.'); - const boundAgentId = await registry.getAgentId(address); - if (boundAgentId === 0) { - spinner.text = 'Binding seller registry...'; - await registry.registerSeller(wallet, agentId); - } else if (boundAgentId !== agentId) { - throw new Error(`Seller is bound to agent ${boundAgentId}, not ${agentId}.`); - } + const agentId = await requireSellerBinding(registry, address, options.agentId); const token = createAntsTokenClient(config); const balance = await token.balanceOf(address); if (balance < amountBaseUnits) throw new Error(`Insufficient ANTS balance: have ${formatAnts(balance)}, need ${formatAnts(amountBaseUnits)}.`); diff --git a/apps/cli/src/cli/commands/seller/register.ts b/apps/cli/src/cli/commands/seller/register.ts index a3e1da082..1d5532793 100644 --- a/apps/cli/src/cli/commands/seller/register.ts +++ b/apps/cli/src/cli/commands/seller/register.ts @@ -3,6 +3,7 @@ import chalk from 'chalk'; import ora from 'ora'; import { getGlobalOptions } from '../types.js'; import { loadConfig } from '../../../config/loader.js'; +import { registerSellerBinding } from '../../seller-contract-clients.js'; import { createIdentityClient, loadCryptoContext, @@ -40,21 +41,18 @@ export function registerSellerRegisterCommand(sellerCmd: Command): void { } else { const sellerRegistry = createSellerRegistryClient(config); agentId = agentId || await sellerRegistry.getAgentId(address); - if (!agentId) agentId = await createLegacyStakingClient(config).getAgentId(address); + if (!agentId && stack.addresses.legacyStakingContractAddress) agentId = await createLegacyStakingClient(config).getAgentId(address); } if (stack.mode === 'recognized-usage') { if (!agentId) throw new Error('Could not determine agent ID. Pass --agent-id .'); const sellerRegistry = createSellerRegistryClient(config); - const boundAgentId = await sellerRegistry.getAgentId(address); - if (boundAgentId === 0) { - spinner.start('Binding seller to recognized-usage registry...'); - const txHash = await sellerRegistry.registerSeller(wallet, agentId); + spinner.start('Checking seller registration...'); + const registered = await registerSellerBinding(sellerRegistry, wallet, address, agentId, + (hash) => console.log(chalk.dim(`Transaction: ${hash}`))); + if (registered) { spinner.succeed(chalk.green('Seller bound to recognized-usage registry')); - console.log(chalk.dim(`Transaction: ${txHash}`)); - } else if (boundAgentId !== agentId) { - throw new Error(`Seller is already bound to agent ${boundAgentId}, not ${agentId}.`); - } else if (alreadyRegistered) { + } else { spinner.succeed(chalk.yellow('Already registered and bound')); } } else if (alreadyRegistered) { diff --git a/apps/cli/src/cli/commands/seller/rewards.test.ts b/apps/cli/src/cli/commands/seller/rewards.test.ts new file mode 100644 index 000000000..0c70ebb97 --- /dev/null +++ b/apps/cli/src/cli/commands/seller/rewards.test.ts @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { totalSellerRewards } from './rewards.js'; +import { previewPositionReward, REWARD_INDEX_SCALE, type RewardPreviewReader } from '../../reward-preview.js'; +import { claimBuyerEpochRewards, claimEpochRewards, claimPoolRewards, previewPoolRewards, RewardClaimProgress } from '../reward-actions.js'; +import type { SellerPoolPosition } from '@antseed/node/payments'; +import type { AbstractSigner } from 'ethers'; + +const position: SellerPoolPosition = { + id: 1, owner: 'seller', agentId: 7, amount: 1n, weightAmount: 1n, + stakeStartEpoch: 1, stakeEndEpoch: 4, closedAtEpoch: 0, withdrawn: false, +}; + +function previewReader(indexedThrough: number, overrides: Partial = {}): RewardPreviewReader { + const rates = new Map([[1, REWARD_INDEX_SCALE / 5n], [2, 3n * REWARD_INDEX_SCALE / 10n]]); + return { + currentEpoch: async () => 3, + claimCursor: async () => 0, + indexCursor: async () => indexedThrough, + segment: async () => ({ normalEnd: 4n, maxLockPower: 0n, nextChange: 2n ** 256n - 1n }), + cumulative: async (_agentId, epoch) => { + let reward = 0n; + let epochReward = 0n; + for (const [rateEpoch, rate] of rates) { + if (rateEpoch < Math.min(epoch, indexedThrough)) { + reward += rate; + epochReward += rate * BigInt(rateEpoch); + } + } + return { reward, epochReward }; + }, + rewardPerWeight: async (_agentId, epoch) => { + assert.ok(epoch < 3, 'must not include the current epoch'); + return rates.get(epoch) ?? 0n; + }, + ...overrides, + }; +} + +test('totalSellerRewards combines legacy, usage, and pool rewards', () => { + assert.equal(totalSellerRewards({ legacy: 1n, usage: 2n, pool: 3n, poolPositions: [] }), 6n); +}); + +test('read-only preview preserves payout rounding before, during, and after indexing', async () => { + for (const indexedThrough of [1, 2, 3]) { + assert.equal(await previewPositionReward(position, previewReader(indexedThrough)), 1n); + } +}); + +test('preview excludes claimed epochs and retains earned rewards after withdrawal', async () => { + const withdrawn = { ...position, withdrawn: true, closedAtEpoch: 3 }; + assert.equal(await previewPositionReward(withdrawn, previewReader(1)), 1n); + assert.equal(await previewPositionReward(withdrawn, previewReader(3, { claimCursor: async () => 3 })), 0n); + assert.equal(await previewPositionReward({ ...position, closedAtEpoch: 2 }, previewReader(1)), 0n); +}); + +test('preview uses separate floor rounding for extended and max-lock segments', async () => { + const reader = previewReader(1, { + segment: async (_id, epoch) => epoch === 1 + ? { normalEnd: 4n, maxLockPower: 0n, nextChange: 2n } + : { normalEnd: 0n, maxLockPower: 5n, nextChange: 99n }, + }); + assert.equal(await previewPositionReward(position, reader), 1n); + assert.equal(await previewPositionReward(position, previewReader(1, { + segment: async (_id, epoch) => ({ normalEnd: epoch === 1 ? 4n : 7n, maxLockPower: 0n, nextChange: epoch === 1 ? 2n : 99n }), + })), 1n); +}); + +test('buyer claims include unpaid epochs older than the last 104', async () => { + const claimed: number[] = []; + await claimBuyerEpochRewards(Array.from({ length: 106 }, (_, epoch) => epoch), + async (epoch) => epoch === 0 || epoch === 105 ? 5n : 0n, + async (epoch) => { claimed.push(epoch); return `tx-${epoch}`; }, async () => {}); + assert.deepEqual(claimed, [0, 105]); +}); + +test('epoch claims use bounded batches without discarding old epochs', async () => { + const batches: number[][] = []; + await claimEpochRewards(Array.from({ length: 105 }, (_, epoch) => epoch), async () => 1n, + async (epochs) => { batches.push(epochs); return 'tx'; }, async () => {}); + assert.deepEqual(batches.map((batch) => batch.length), [32, 32, 32, 9]); + assert.equal(batches.flat().length, 105); +}); + +test('confirmed transactions remain visible if a later claim or receipt read fails', async () => { + const shown: string[] = []; + const progress = new RewardClaimProgress((hash) => shown.push(hash), async (hash) => { + if (hash === 'receipt-failure') throw new Error('RPC timeout'); + return 10n; + }); + await progress.record('legacy-confirmed', 'claim'); + await assert.rejects(progress.record('receipt-failure', 'claim'), /RPC timeout/); + assert.deepEqual(shown, ['legacy-confirmed', 'receipt-failure']); + assert.equal(progress.claimed, 10n); + assert.match(progress.failure('usage claim failed'), /2 transaction\(s\) already confirmed/); +}); + +function poolFixture(closedAtEpoch = 0) { + let cursor = 1; + let wasClaimed = false; + const writes: string[] = []; + const targetEpoch = closedAtEpoch || 35; + const pools = { rewardPositions: async () => [{ ...position, withdrawn: closedAtEpoch !== 0, closedAtEpoch }], position: async () => position, currentEpoch: async () => 35 }; + const rewards = { + previewStakerReward: async () => wasClaimed ? 0n : 12n, + pendingIndexedStakerReward: async () => cursor === targetEpoch && !wasClaimed ? 12n : 0n, + poolRewardIndexNextEpoch: async () => cursor, + initialIndexEpoch: async () => 1, + indexPoolRewards: async (_wallet: AbstractSigner, _agentId: number, maxEpochs: number) => { + assert.ok(maxEpochs <= 16); + cursor += maxEpochs; + writes.push('index'); + return `index-${cursor}`; + }, + claimStakerRewardsBatch: async (_wallet: AbstractSigner, ids: number[]) => { + assert.equal(cursor, targetEpoch); + assert.deepEqual(ids, [1]); + writes.push('claim'); + wasClaimed = true; + return 'claim-confirmed'; + }, + }; + return { pools, rewards, writes }; +} + +test('view previews unindexed historical earnings without writing; claim prepares and pays once', async () => { + const { pools, rewards, writes } = poolFixture(); + const displayed = await previewPoolRewards(pools, rewards, 'seller'); + assert.equal(displayed[0]!.amount, 12n); + assert.deepEqual(writes, []); + const confirmed: string[] = []; + await claimPoolRewards(pools, rewards, {} as AbstractSigner, 'seller', 'seller', async (hash) => { confirmed.push(hash); }, () => {}); + assert.deepEqual(writes, ['index', 'index', 'index', 'claim']); + assert.equal(confirmed.at(-1), 'claim-confirmed'); + await claimPoolRewards(pools, rewards, {} as AbstractSigner, 'seller', 'seller', async () => {}, () => {}); + assert.equal(writes.length, 4); +}); + +test('claim stops if indexing fails instead of claiming an incomplete amount', async () => { + const { pools, rewards, writes } = poolFixture(); + rewards.indexPoolRewards = async () => { throw new Error('gas unavailable'); }; + await assert.rejects(claimPoolRewards(pools, rewards, {} as AbstractSigner, 'seller', 'seller', async () => {}, () => {}), /gas unavailable/); + assert.deepEqual(writes, []); +}); + +test('withdrawn positions only prepare accounting through their closing epoch', async () => { + const { pools, rewards, writes } = poolFixture(3); + await claimPoolRewards(pools, rewards, {} as AbstractSigner, 'seller', 'seller', async () => {}, () => {}); + assert.deepEqual(writes, ['index', 'claim']); +}); diff --git a/apps/cli/src/cli/commands/seller/rewards.ts b/apps/cli/src/cli/commands/seller/rewards.ts new file mode 100644 index 000000000..c699fe149 --- /dev/null +++ b/apps/cli/src/cli/commands/seller/rewards.ts @@ -0,0 +1,134 @@ +import type { Command } from 'commander'; +import chalk from 'chalk'; +import ora from 'ora'; +import { legacyEpochs, newEpochs } from '@antseed/node/payments'; +import { loadConfig } from '../../../config/loader.js'; +import { getGlobalOptions } from '../types.js'; +import { + createEmissionsClient, + createAntsTokenClient, + createLegacyEmissionsClient, + createSellerPoolsClient, + createSellerPoolsRewardsClient, + createUsageAccountingClient, + formatAnts, + loadCryptoContext, + resolveCliContractStack, +} from '../../payment-utils.js'; +import { pastEpochs } from '../emissions.js'; +import { claimEpochRewards, claimPoolRewards, pendingEpochRewards, previewPoolRewards, RewardClaimProgress } from '../reward-actions.js'; + +export interface SellerRewardSummary { + legacy: bigint; + usage: bigint; + pool: bigint; + poolPositions: Array<{ id: number; amount: bigint }>; +} + +export function totalSellerRewards(summary: SellerRewardSummary): bigint { + return summary.legacy + summary.usage + summary.pool; +} + +export function registerSellerRewardsCommand(sellerCmd: Command): void { + const rewards = sellerCmd.command('rewards').description('View or claim all seller ANTS rewards'); + + rewards.option('--json', 'output as JSON', false).action(async (options) => { + const global = getGlobalOptions(rewards); + const config = await loadConfig(global.config); + const spinner = ora('Fetching seller rewards...').start(); + try { + const { address } = await loadCryptoContext(global.dataDir); + const stack = await resolveCliContractStack(config); + const legacyIds = stack.mode === 'legacy' + ? pastEpochs(stack.currentEpoch) + : legacyEpochs(stack.currentEpoch, stack.firstRewardedEpoch!); + const legacyClient = stack.mode === 'legacy' ? createEmissionsClient(config) + : stack.addresses.legacyEmissionsContractAddress ? createLegacyEmissionsClient(config) : undefined; + const legacy = legacyClient + ? await pendingEpochRewards(legacyIds, async (epochs) => (await legacyClient.pendingEmissions(address, epochs)).seller) + : 0n; + let usage = 0n; + let poolPositions: Array<{ id: number; amount: bigint }> = []; + if (stack.mode === 'recognized-usage') { + const recognizedIds = newEpochs(stack.currentEpoch, stack.firstRewardedEpoch!); + const usageClient = createUsageAccountingClient(config); + usage = await pendingEpochRewards(recognizedIds, async (epochs) => (await usageClient.pendingEmissions(address, epochs)).seller); + const pools = createSellerPoolsClient(config); + const poolRewards = createSellerPoolsRewardsClient(config); + poolPositions = await previewPoolRewards(pools, poolRewards, address); + } + const pool = poolPositions.reduce((total, position) => total + position.amount, 0n); + const summary = { legacy, usage, pool, poolPositions }; + spinner.stop(); + if (options.json) { + console.log(JSON.stringify({ + address, + mode: stack.mode, + legacy: formatAnts(legacy), + recognizedUsage: formatAnts(usage), + pool: formatAnts(pool), + total: formatAnts(totalSellerRewards(summary)), + poolPositions: poolPositions.map((position) => ({ id: position.id, amount: formatAnts(position.amount) })), + }, null, 2)); + return; + } + console.log(chalk.bold('Seller Rewards:\n')); + console.log(` Legacy emissions: ${chalk.green(`${formatAnts(legacy)} ANTS`)}`); + if (stack.mode === 'recognized-usage') { + console.log(` Recognized-use emissions: ${chalk.green(`${formatAnts(usage)} ANTS`)}`); + console.log(` Pool staking rewards: ${chalk.green(`${formatAnts(pool)} ANTS`)}`); + } + console.log(` Total: ${chalk.green(`${formatAnts(totalSellerRewards(summary))} ANTS`)}`); + console.log(chalk.dim('\nRun antseed seller rewards claim to collect. Amounts reflect completed epochs at the time of this read.')); + } catch (error) { + spinner.fail(chalk.red(`Failed to fetch rewards: ${(error as Error).message}`)); + process.exitCode = 1; + } + }); + + rewards.command('claim').description('Claim all pending seller ANTS rewards').action(async () => { + const global = getGlobalOptions(rewards); + const config = await loadConfig(global.config); + const spinner = ora('Claiming seller rewards...').start(); + let progress: RewardClaimProgress | undefined; + try { + const { wallet, address } = await loadCryptoContext(global.dataDir); + const stack = await resolveCliContractStack(config); + const token = createAntsTokenClient(config); + progress = new RewardClaimProgress( + (hash, kind) => console.log(chalk.dim(`${kind === 'accounting' ? 'Preparation' : 'Claim'} transaction confirmed: ${hash}`)), + (hash) => token.receivedInTransaction(hash, address), + ); + const legacyIds = stack.mode === 'legacy' + ? pastEpochs(stack.currentEpoch) + : legacyEpochs(stack.currentEpoch, stack.firstRewardedEpoch!); + const legacyClient = stack.mode === 'legacy' ? createEmissionsClient(config) + : stack.addresses.legacyEmissionsContractAddress ? createLegacyEmissionsClient(config) : undefined; + if (legacyClient) { + await claimEpochRewards(legacyIds, + async (epochs) => (await legacyClient.pendingEmissions(address, epochs)).seller, + (epochs) => legacyClient.claimSellerEmissions(wallet, epochs), progress.record); + } + if (stack.mode === 'recognized-usage') { + const recognizedIds = newEpochs(stack.currentEpoch, stack.firstRewardedEpoch!); + const usageClient = createUsageAccountingClient(config); + await claimEpochRewards(recognizedIds, + async (epochs) => (await usageClient.pendingEmissions(address, epochs)).seller, + (epochs) => usageClient.claimSellerEmissions(wallet, epochs), progress.record); + const pools = createSellerPoolsClient(config); + const poolRewards = createSellerPoolsRewardsClient(config); + await claimPoolRewards(pools, poolRewards, wallet, address, address, progress.record, () => { + spinner.text = 'Updating pool rewards (preparation transactions require gas)...'; + }); + } + if (progress.claimed === 0n) { + spinner.succeed(chalk.yellow('No pending seller rewards to claim.')); + return; + } + spinner.succeed(chalk.green(`Claimed ${formatAnts(progress.claimed)} ANTS across ${progress.transactions.length} transaction(s)`)); + } catch (error) { + spinner.fail(chalk.red(progress?.failure((error as Error).message) ?? `Claim failed: ${(error as Error).message}`)); + process.exitCode = 1; + } + }); +} diff --git a/apps/cli/src/cli/commands/seller/stake.test.ts b/apps/cli/src/cli/commands/seller/stake.test.ts index c79c7441e..67d3db810 100644 --- a/apps/cli/src/cli/commands/seller/stake.test.ts +++ b/apps/cli/src/cli/commands/seller/stake.test.ts @@ -17,7 +17,9 @@ test('seller stake is stack-aware and accepts an ANTS lock duration', () => { const stake = findCommand(sellerCommand(), 'stake'); assert.ok(stake); assert.ok(stake!.options.some((option) => option.long === '--epochs')); - assert.ok(stake!.options.some((option) => option.long === '--agent-id')); + const agentId = stake!.options.find((option) => option.long === '--agent-id'); + assert.ok(agentId); + assert.equal(agentId!.hidden, true); }); test('legacy USDC staking lives under seller legacy', () => { @@ -31,10 +33,35 @@ test('seller unstake remains available as a legacy alias', () => { assert.ok(findCommand(sellerCommand(), 'unstake')); }); -test('pool bootstrap replaces pool init but keeps the old name as an alias', () => { +test('primary help shows aggregate rewards while retaining hidden compatibility commands', () => { + const seller = sellerCommand(); + const help = seller.helpInformation(); + assert.match(help, /rewards/); + assert.doesNotMatch(help, /\n\s+emissions\s/); + assert.doesNotMatch(help, /\n\s+unstake\s/); + const pool = findCommand(seller, 'pool')!; + assert.ok(findCommand(pool, 'rewards')); + assert.doesNotMatch(pool.helpInformation(), /\n\s+rewards/); +}); + +test('pool claim-starter keeps bootstrap and init as aliases', () => { + const pool = findCommand(sellerCommand(), 'pool')!; + const claimStarter = findCommand(pool, 'claim-starter'); + assert.ok(claimStarter); + assert.deepEqual(claimStarter!.aliases(), ['bootstrap', 'init']); + assert.equal(findCommand(pool, 'stake'), undefined); +}); + +test('seller rewards provides the minimal aggregate reward surface', () => { + const rewards = findCommand(sellerCommand(), 'rewards'); + assert.ok(rewards); + assert.ok(findCommand(rewards!, 'claim')); +}); + +test('pool withdrawal requires explicit slashing consent instead of force', () => { const pool = findCommand(sellerCommand(), 'pool')!; - const bootstrap = findCommand(pool, 'bootstrap'); - assert.ok(bootstrap); - assert.ok(bootstrap!.aliases().includes('init')); - assert.ok(findCommand(pool, 'stake')); + const withdraw = findCommand(pool, 'withdraw')!; + assert.ok(withdraw.options.some((option) => option.long === '--accept-slashing')); + assert.ok(withdraw.options.some((option) => option.long === '--yes')); + assert.ok(!withdraw.options.some((option) => option.long === '--force')); }); diff --git a/apps/cli/src/cli/commands/seller/stake.ts b/apps/cli/src/cli/commands/seller/stake.ts index 04f62ade1..76cf91b7e 100644 --- a/apps/cli/src/cli/commands/seller/stake.ts +++ b/apps/cli/src/cli/commands/seller/stake.ts @@ -1,4 +1,4 @@ -import type { Command } from 'commander'; +import { Option, type Command } from 'commander'; import chalk from 'chalk'; import ora from 'ora'; import { getGlobalOptions } from '../types.js'; @@ -36,7 +36,7 @@ async function runLegacyStake( try { const stack = await resolveCliContractStack(config); if (stack.mode === 'recognized-usage') { - spinner.fail(chalk.red('Legacy USDC staking is closed after cutover. Use: antseed seller stake --epochs ')); + spinner.fail(chalk.red('New USDC stakes are closed after the recognized-usage upgrade. Use: antseed seller stake --epochs ')); process.exit(1); } const { wallet, address } = await loadCryptoContext(global.dataDir); @@ -85,7 +85,7 @@ async function runLegacyUnstake(global: GlobalOptions): Promise { : createStakingClient(config); console.log(chalk.dim(`Wallet: ${address}`)); if (stack.mode === 'recognized-usage') { - console.log(chalk.yellow('⚠ Withdrawing legacy USDC stake may remove temporary post-cutover eligibility until your ANTS pool is active.')); + console.log(chalk.yellow('⚠ Withdrawing legacy USDC stake may remove temporary eligibility until your ANTS position becomes active.')); } const stake = await stakingClient.getStake(address); if (stake === 0n) { @@ -108,9 +108,9 @@ async function runLegacyUnstake(global: GlobalOptions): Promise { export function registerSellerStakeCommand(sellerCmd: Command): void { sellerCmd .command('stake ') - .description('Stake as a provider — ANTS into your seller pool after cutover, USDC before it') - .option('--epochs ', 'ANTS lock duration in epochs (recognized-usage stack)', (value) => Number(value)) - .option('--agent-id ', 'seller agent ID (from antseed seller register output)', parseInt) + .description('Stake as a provider — ANTS after the recognized-usage upgrade, USDC before it') + .option('--epochs ', 'ANTS lock duration in epochs after the recognized-usage upgrade', (value) => Number(value)) + .addOption(new Option('--agent-id ', 'seller agent ID fallback').argParser(Number).hideHelp()) .action(async (amount: string, options: { epochs?: number; agentId?: number }) => { const globalOpts = getGlobalOptions(sellerCmd); const config = await loadConfig(globalOpts.config); @@ -133,7 +133,7 @@ export function registerSellerStakeCommand(sellerCmd: Command): void { }); sellerCmd - .command('unstake') + .command('unstake', { hidden: true }) .description('Withdraw legacy USDC stake (alias of `antseed seller legacy unstake`)') .action(async () => { await runLegacyUnstake(getGlobalOptions(sellerCmd)); @@ -143,7 +143,7 @@ export function registerSellerStakeCommand(sellerCmd: Command): void { legacy .command('stake ') - .description('Stake USDC as a provider (legacy stack only, e.g. "10" = 10 USDC)') + .description('Stake USDC before the recognized-usage upgrade (e.g. "10" = 10 USDC)') .option('--agent-id ', 'ERC-8004 agent ID (from antseed seller register output)', parseInt) .action(async (amount: string, options: { agentId?: number }) => { await runLegacyStake(getGlobalOptions(legacy), amount, options); diff --git a/apps/cli/src/cli/commands/seller/status.ts b/apps/cli/src/cli/commands/seller/status.ts index d96c98fc9..d0756af87 100644 --- a/apps/cli/src/cli/commands/seller/status.ts +++ b/apps/cli/src/cli/commands/seller/status.ts @@ -61,7 +61,7 @@ export function registerSellerStatusCommand(sellerCmd: Command): void { const pools = createSellerPoolsClient(config); const agentId = await registry.getAgentId(walletAddress); const [legacyStake, activePoolStake, eligible, positionCount] = await Promise.all([ - createLegacyStakingClient(config).getStake(walletAddress), + stack.addresses.legacyStakingContractAddress ? createLegacyStakingClient(config).getStake(walletAddress) : 0n, agentId ? pools.poolActiveStakeAtEpoch(agentId, stack.currentEpoch) : 0n, registry.isStakedAboveMin(walletAddress), pools.stakerPositionCount(walletAddress), diff --git a/apps/cli/src/cli/payment-utils.ts b/apps/cli/src/cli/payment-utils.ts index 4524331fb..2dae062ac 100644 --- a/apps/cli/src/cli/payment-utils.ts +++ b/apps/cli/src/cli/payment-utils.ts @@ -4,7 +4,6 @@ import { ChannelsClient, StakingClient, DepositRelayClient, - ANTSTokenClient, loadOrCreateIdentity, resolveChainConfig, resolveContractStack, @@ -15,15 +14,18 @@ import { EmissionsClient, UsageAccountingClient, UsageRewardsClient, - SellerPoolsClient, - SellerPoolsRewardsClient, - SellerRegistryClient, PositionInitClient, EmissionsGateClient, ChannelStore, } from '@antseed/node/payments'; import type { Identity } from '@antseed/node'; import type { AntseedConfig } from '../config/types.js'; +import { + CliAntsTokenClient as ANTSTokenClient, + CliSellerPoolsClient as SellerPoolsClient, + CliSellerPoolsRewardsClient as SellerPoolsRewardsClient, + CliSellerRegistryClient as SellerRegistryClient, +} from './seller-contract-clients.js'; export const ANTSEED_BASE_RPC_URL_ENV = 'ANTSEED_BASE_RPC_URL'; @@ -81,6 +83,12 @@ export function formatAnts(baseUnits: bigint): string { return `${whole}.${fracStr}`; } +export function formatAntsExact(baseUnits: bigint): string { + const whole = baseUnits / 10n ** 18n; + const fraction = (baseUnits % 10n ** 18n).toString().padStart(18, '0').replace(/0+$/, '') || '0'; + return `${whole}.${fraction}`; +} + /** Parse a positive human-readable ANTS amount into 18-decimal base units. */ export function parseAntsToBaseUnits(amount: string): bigint { const match = amount.trim().match(/^(\d+)(?:\.(\d{1,18}))?$/); @@ -190,8 +198,8 @@ export function requireCryptoConfig( legacyEmissionsContractAddress: crypto.legacyEmissionsContractAddress || resolved.legacyEmissionsContractAddress, legacyStakingContractAddress: crypto.legacyStakingContractAddress || resolved.legacyStakingContractAddress, legacyEmissionsV1ContractAddress: crypto.legacyEmissionsV1ContractAddress || resolved.legacyEmissionsV1ContractAddress, - antsTokenAddress: crypto.antsTokenAddress || resolved.antsTokenAddress, - registryContractAddress: crypto.registryContractAddress || resolved.registryContractAddress, + antsTokenAddress: crypto.antsTokenAddress || (crypto.chainId === 'base-local' ? '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0' : resolved.antsTokenAddress), + registryContractAddress: crypto.registryContractAddress || (crypto.chainId === 'base-local' ? '0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9' : resolved.registryContractAddress), emissionsGateAddress: crypto.emissionsGateAddress || resolved.emissionsGateAddress, sellerPoolsAddress: crypto.sellerPoolsAddress || resolved.sellerPoolsAddress, sellerRegistryAddress: crypto.sellerRegistryAddress || resolved.sellerRegistryAddress, diff --git a/apps/cli/src/cli/reward-preview.ts b/apps/cli/src/cli/reward-preview.ts new file mode 100644 index 000000000..d04711d4f --- /dev/null +++ b/apps/cli/src/cli/reward-preview.ts @@ -0,0 +1,45 @@ +import type { SellerPoolPosition } from '@antseed/node/payments'; + +export const REWARD_INDEX_SCALE = 10n ** 30n; + +export interface RewardPreviewReader { + currentEpoch(): Promise; + claimCursor(positionId: number): Promise; + indexCursor(agentId: number): Promise; + segment(positionId: number, epoch: number): Promise<{ normalEnd: bigint; maxLockPower: bigint; nextChange: bigint }>; + cumulative(agentId: number, epoch: number): Promise<{ reward: bigint; epochReward: bigint }>; + rewardPerWeight(agentId: number, epoch: number): Promise; +} + +export async function previewPositionReward(position: SellerPoolPosition, reader: RewardPreviewReader): Promise { + const [currentEpoch, claimedThrough, indexedThrough] = await Promise.all([ + reader.currentEpoch(), reader.claimCursor(position.id), reader.indexCursor(position.agentId), + ]); + const toEpoch = position.closedAtEpoch ? Math.min(currentEpoch, position.closedAtEpoch) : currentEpoch; + let cursor = Math.max(claimedThrough, position.stakeStartEpoch); + let amount = 0n; + while (cursor < toEpoch) { + const segment = await reader.segment(position.id, cursor); + let end = Number(segment.nextChange < BigInt(toEpoch) ? segment.nextChange : BigInt(toEpoch)); + if (end <= cursor) throw new Error(`Invalid reward segment for position ${position.id}`); + const normal = segment.maxLockPower === 0n && segment.normalEnd > BigInt(cursor); + if (normal && segment.normalEnd < BigInt(end)) end = Number(segment.normalEnd); + if (normal || segment.maxLockPower > 0n) { + const [fromIndex, toIndex] = await Promise.all([ + reader.cumulative(position.agentId, cursor), reader.cumulative(position.agentId, end), + ]); + let rewardDelta = toIndex.reward - fromIndex.reward; + let epochRewardDelta = toIndex.epochReward - fromIndex.epochReward; + for (let epoch = Math.max(cursor, indexedThrough); epoch < end; epoch++) { + const rewardPerWeight = await reader.rewardPerWeight(position.agentId, epoch); + rewardDelta += rewardPerWeight; + epochRewardDelta += rewardPerWeight * BigInt(epoch); + } + amount += segment.maxLockPower > 0n + ? segment.maxLockPower * rewardDelta / REWARD_INDEX_SCALE + : position.weightAmount * (segment.normalEnd * rewardDelta - epochRewardDelta) / REWARD_INDEX_SCALE; + } + cursor = end; + } + return amount; +} diff --git a/apps/cli/src/cli/seller-contract-clients.ts b/apps/cli/src/cli/seller-contract-clients.ts new file mode 100644 index 000000000..1704ce9ea --- /dev/null +++ b/apps/cli/src/cli/seller-contract-clients.ts @@ -0,0 +1,208 @@ +import { Contract, Interface, zeroPadValue, ZeroAddress, type AbstractSigner, type Log } from 'ethers'; +import { ANTSTokenClient, SellerPoolsClient, SellerPoolsRewardsClient, SellerRegistryClient, type SellerPoolPosition } from '@antseed/node/payments'; +import { previewPositionReward, REWARD_INDEX_SCALE } from './reward-preview.js'; + +const POOLS_ABI = [ + 'event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)', + 'function earlyExitSlashBps(uint256 positionId) view returns (uint256)', + 'function positionPowerSegmentAt(uint256 positionId, uint256 epoch) view returns (uint256, uint256, uint256)', + 'function poolWeightAtEpoch(uint256 agentId, uint256 epoch) view returns (uint256)', + 'function currentEpoch() view returns (uint256)', + 'function positions(uint256 positionId) view returns (address, uint256, uint256, uint256, uint64, uint64, uint64, bool)', +]; +const REWARDS_ABI = [ + 'function sellerPools() view returns (address)', + 'function usageAccounting() view returns (address)', + 'function positionClaimCursor(uint256 positionId) view returns (uint256)', + 'function poolRewardIndexNextEpoch(uint256 agentId) view returns (uint256)', + 'function initialIndexEpoch() view returns (uint256)', + 'function poolCumulativeRewardPerWeightAt(uint256 agentId, uint256 epoch) view returns (uint256)', + 'function poolCumulativeEpochRewardPerWeightAt(uint256 agentId, uint256 epoch) view returns (uint256)', + 'function poolEpochEmissions(uint256 epoch, uint256 agentId) view returns (bool, uint256)', + 'function stakerEpochBudget(uint256 epoch) view returns (uint256)', + 'function indexPoolRewards(uint256 agentId, uint256 maxEpochs) returns (uint256)', +]; +const ACCOUNTING_ABI = [ + 'function weightedPoolPointsByEpoch(uint256 epoch, uint256 agentId) view returns (uint256)', + 'function totalWeightedPoolPointsByEpoch(uint256 epoch) view returns (uint256)', +]; + +export class CliSellerRegistryClient extends SellerRegistryClient { + async isRegisteredSeller(seller: string, agentId: number): Promise { + if (!agentId) return false; + const registry = new Contract(this.contractAddress, ['function agentSeller(uint256 agentId) view returns (address)'], this.provider); + const [resolvedId, boundSeller] = await Promise.all([this.getAgentId(seller), registry.getFunction('agentSeller')(agentId)]); + return resolvedId === agentId && boundSeller.toLowerCase() === seller.toLowerCase(); + } +} + +export async function registerSellerBinding( + registry: Pick, + wallet: AbstractSigner, + address: string, + agentId: number, + confirmed: (hash: string) => void, +): Promise { + const boundAgentId = await registry.getAgentId(address); + if (boundAgentId !== 0 && boundAgentId !== agentId) { + throw new Error(`Seller is already bound to agent ${boundAgentId}, not ${agentId}.`); + } + if (await registry.isRegisteredSeller(address, agentId)) return false; + confirmed(await registry.registerSeller(wallet, agentId)); + if (!await registry.isRegisteredSeller(address, agentId)) { + throw new Error('Registration could not be verified. Re-run: antseed seller register'); + } + return true; +} + +export async function requireSellerBinding( + registry: Pick, + address: string, + requestedAgentId?: number, +): Promise { + const agentId = await registry.getAgentId(address); + if (!agentId || !await registry.isRegisteredSeller(address, agentId)) { + throw new Error('Registration needs updating before you can stake. Run: antseed seller register. Your existing identity will be kept.'); + } + if (requestedAgentId !== undefined && requestedAgentId !== agentId) { + throw new Error(`Seller is bound to agent ${agentId}, not ${requestedAgentId}.`); + } + return agentId; +} + +export async function collectPositionIds(readPage: (offset: number, limit: number) => Promise): Promise { + const ids: number[] = []; + for (let offset = 0; ; offset += 256) { + const page = await readPage(offset, 256); + ids.push(...page); + if (page.length < 256) return ids; + } +} + +export class CliSellerPoolsClient extends SellerPoolsClient { + async earlyExitSlashBps(positionId: number): Promise { + const pools = new Contract(this.contractAddress, POOLS_ABI, this.provider); + return Number(await pools.getFunction('earlyExitSlashBps')(positionId)); + } + + allStakerPositionIds(staker: string): Promise { + return collectPositionIds((offset, limit) => this.stakerPositionIds(staker, offset, limit)); + } + + async rewardPositions(staker: string): Promise { + const ids = new Set(await this.allStakerPositionIds(staker)); + const tokenInterface = new Interface(POOLS_ABI); + const topics = tokenInterface.encodeFilterTopics('Transfer', [staker, ZeroAddress]); + const latest = await this.provider.getBlockNumber(); + let first = 0; + let last = latest; + while (first < last) { + const middle = Math.floor((first + last) / 2); + if (await this.provider.getCode(this.contractAddress, middle) === '0x') first = middle + 1; + else last = middle; + } + const ranges: Array<[number, number]> = [[first, latest]]; + let requests = 0; + while (ranges.length > 0) { + const [fromBlock, toBlock] = ranges.pop()!; + let logs: Log[]; + try { + if (++requests > 512) throw new Error('Historical position discovery exceeded the RPC request limit. Use an RPC with larger log ranges.'); + logs = await this.provider.getLogs({ address: this.contractAddress, topics, fromBlock, toBlock }); + } catch (error) { + if (requests > 512 || fromBlock === toBlock || !/range|too many|response.*(size|large)|limit exceeded/i.test(String(error))) throw error; + const middle = Math.floor((fromBlock + toBlock) / 2); + ranges.push([fromBlock, middle], [middle + 1, toBlock]); + continue; + } + for (const log of logs) { + const event = tokenInterface.parseLog(log); + if (event) ids.add(Number(event.args.tokenId)); + } + } + const positions: SellerPoolPosition[] = []; + const positionIds = [...ids]; + for (let offset = 0; offset < positionIds.length; offset += 16) { + const page = await Promise.all(positionIds.slice(offset, offset + 16).map((id) => this.position(id))); + positions.push(...page.filter((position) => position.owner.toLowerCase() === staker.toLowerCase())); + } + return positions; + } +} + +export class CliSellerPoolsRewardsClient extends SellerPoolsRewardsClient { + private previewBlock?: Promise; + private readonly previewReads = new Map>(); + + private read(contract: Contract, method: string, ...args: (number | bigint)[]): Promise { + const key = `${contract.target}:${method}:${args.join(',')}`; + let result = this.previewReads.get(key); + if (!result) { + this.previewBlock ??= this.provider.getBlockNumber(); + result = this.previewBlock.then((blockTag) => contract.getFunction(method)(...args, { blockTag })); + this.previewReads.set(key, result); + } + return result as Promise; + } + + async previewStakerReward(positionId: number): Promise { + const rewards = new Contract(this.contractAddress, REWARDS_ABI, this.provider); + const poolAddress = await this.read(rewards, 'sellerPools'); + const pools = new Contract(poolAddress, POOLS_ABI, this.provider); + const accounting = new Contract(await this.read(rewards, 'usageAccounting'), ACCOUNTING_ABI, this.provider); + const position = await this.read<[string, bigint, bigint, bigint, bigint, bigint, bigint, boolean]>(pools, 'positions', positionId); + if (position[0] === ZeroAddress) throw new Error(`Unknown position ${positionId}`); + return previewPositionReward({ + id: positionId, owner: position[0], agentId: Number(position[1]), amount: position[2], weightAmount: position[3], + stakeStartEpoch: Number(position[4]), stakeEndEpoch: Number(position[5]), closedAtEpoch: Number(position[6]), withdrawn: position[7], + }, { + currentEpoch: async () => Number(await this.read(pools, 'currentEpoch')), + claimCursor: async (id) => Number(await this.read(rewards, 'positionClaimCursor', id)), + indexCursor: async (agentId) => Number(await this.read(rewards, 'poolRewardIndexNextEpoch', agentId) || await this.read(rewards, 'initialIndexEpoch')), + segment: async (id, epoch) => { + const [normalEnd, maxLockPower, nextChange] = await this.read<[bigint, bigint, bigint]>(pools, 'positionPowerSegmentAt', id, epoch); + return { normalEnd, maxLockPower, nextChange }; + }, + cumulative: async (agentId, epoch) => ({ + reward: await this.read(rewards, 'poolCumulativeRewardPerWeightAt', agentId, epoch), + epochReward: await this.read(rewards, 'poolCumulativeEpochRewardPerWeightAt', agentId, epoch), + }), + rewardPerWeight: async (agentId, epoch) => { + const weight = await this.read(pools, 'poolWeightAtEpoch', agentId, epoch); + if (weight === 0n) return 0n; + const [settled, amount] = await this.read<[boolean, bigint]>(rewards, 'poolEpochEmissions', epoch, agentId); + if (settled) return amount * REWARD_INDEX_SCALE / weight; + const [points, total] = await Promise.all([ + this.read(accounting, 'weightedPoolPointsByEpoch', epoch, agentId), + this.read(accounting, 'totalWeightedPoolPointsByEpoch', epoch), + ]); + if (points === 0n || total === 0n) return 0n; + const budget = await this.read(rewards, 'stakerEpochBudget', epoch); + return (budget * points / total) * REWARD_INDEX_SCALE / weight; + }, + }); + } + + async poolRewardIndexNextEpoch(agentId: number): Promise { + return Number(await new Contract(this.contractAddress, REWARDS_ABI, this.provider).getFunction('poolRewardIndexNextEpoch')(agentId)); + } + async initialIndexEpoch(): Promise { + return Number(await new Contract(this.contractAddress, REWARDS_ABI, this.provider).getFunction('initialIndexEpoch')()); + } + indexPoolRewards(signer: AbstractSigner, agentId: number, maxEpochs: number): Promise { + return this._execWrite(signer, REWARDS_ABI, 'indexPoolRewards', agentId, maxEpochs); + } +} + +export class CliAntsTokenClient extends ANTSTokenClient { + async receivedInTransaction(transactionHash: string, recipient: string): Promise { + const receipt = await this.provider.getTransactionReceipt(transactionHash); + if (!receipt || receipt.status !== 1) throw new Error(`Confirmed receipt unavailable: ${transactionHash}`); + const tokenInterface = new Interface(['event Transfer(address indexed from, address indexed to, uint256 value)']); + const topics = tokenInterface.encodeFilterTopics('Transfer', [null, recipient]); + return receipt.logs.reduce((received, log) => { + if (log.address.toLowerCase() !== this.contractAddress.toLowerCase() || log.topics[0] !== topics[0] || log.topics[2]?.toLowerCase() !== zeroPadValue(recipient, 32).toLowerCase()) return received; + return received + (tokenInterface.parseLog(log)!.args.value as bigint); + }, 0n); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 44ec67a76..bc1d163a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ importers: dotenv: specifier: ^16.6.1 version: 16.6.1 + ethers: + specifier: ~6.16.0 + version: 6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) open: specifier: ^11.0.0 version: 11.0.0 From 5b725a91597d0710a8cd3864fa0f515127e4ffb0 Mon Sep 17 00:00:00 2001 From: alexanderludwig Date: Sat, 5 Sep 2026 01:33:47 +0200 Subject: [PATCH 3/3] refactor(cli): minimize seller UX and use shared SDK clients --- CHANGELOG.md | 4 +- apps/cli/README.md | 34 ++- apps/cli/package.json | 1 - apps/cli/src/cli/commands/emissions.ts | 3 +- .../cli/src/cli/commands/network/contracts.ts | 59 ----- apps/cli/src/cli/commands/network/index.ts | 2 - apps/cli/src/cli/commands/reward-actions.ts | 89 +------- .../commands/seller/contract-clients.test.ts | 131 ----------- apps/cli/src/cli/commands/seller/emissions.ts | 6 - apps/cli/src/cli/commands/seller/index.ts | 2 - apps/cli/src/cli/commands/seller/pool.test.ts | 11 +- apps/cli/src/cli/commands/seller/pool.ts | 93 +------- apps/cli/src/cli/commands/seller/register.ts | 7 +- .../src/cli/commands/seller/rewards.test.ts | 131 +---------- apps/cli/src/cli/commands/seller/rewards.ts | 3 +- apps/cli/src/cli/commands/seller/setup.ts | 3 +- .../cli/src/cli/commands/seller/stake.test.ts | 67 +++++- apps/cli/src/cli/commands/seller/stake.ts | 40 ++-- apps/cli/src/cli/payment-utils.ts | 10 +- apps/cli/src/cli/seller-contract-clients.ts | 208 ------------------ apps/website/docs/cli/commands.md | 13 +- apps/website/docs/guides/become-a-provider.md | 10 +- apps/website/docs/guides/payments.md | 12 +- e2e/tests/m001-cli.e2e.test.ts | 134 +++++++++-- .../migrations/M001RecognizedUsage/README.md | 6 +- packages/node/README.md | 30 +++ packages/node/src/index.ts | 5 +- packages/node/src/payments/early-exit.test.ts | 14 ++ .../src/payments/evm/ants-token-client.ts | 12 +- .../src/payments/evm/seller-pools-client.ts | 76 ++++++- .../evm/seller-pools-rewards-client.ts | 94 +++++++- .../payments/evm/seller-registry-client.ts | 33 +++ packages/node/src/payments/index.ts | 8 +- .../node/src/payments/reward-claims.test.ts | 155 +++++++++++++ packages/node/src/payments/reward-claims.ts | 88 ++++++++ .../node/src/payments}/reward-preview.ts | 2 +- .../node/src/payments/seller-clients.test.ts | 169 ++++++++++++++ pnpm-lock.yaml | 3 - 38 files changed, 940 insertions(+), 828 deletions(-) delete mode 100644 apps/cli/src/cli/commands/network/contracts.ts delete mode 100644 apps/cli/src/cli/commands/seller/emissions.ts delete mode 100644 apps/cli/src/cli/seller-contract-clients.ts create mode 100644 packages/node/src/payments/early-exit.test.ts create mode 100644 packages/node/src/payments/reward-claims.test.ts create mode 100644 packages/node/src/payments/reward-claims.ts rename {apps/cli/src/cli => packages/node/src/payments}/reward-preview.ts (97%) create mode 100644 packages/node/src/payments/seller-clients.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 35749f94c..b8e47d992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ This project uses selective package publishing. Each release entry lists the pub ### Added -- CLI/Node: added registry-verified support for the M001 recognized-usage contract stack, including dual-stack seller and buyer emissions claims, a stack-aware `antseed seller stake` (ANTS pool staking with `--epochs` after cutover, legacy USDC before it) with explicit `antseed seller legacy stake` / `antseed seller legacy unstake` commands, `antseed seller pool bootstrap` for the legacy-seller starter ANTS position, seller pool position/reward/withdrawal commands, post-cutover seller binding, `antseed network contracts`, generated M001 address overrides, a persistent Anvil fork rehearsal via `pnpm m001:sandbox`, and a 10-second EVM request timeout for storage-heavy reward reads. +- Node SDK: reusable seller-binding verification, historical reward-position discovery, read-only reward previews, bounded reward claims, slashing estimates, and confirmed ANTS receipt totals. Pool previews use a fresh shared block snapshot per operation. The CLI delegates to these SDK APIs and no longer depends directly on `ethers`; command behavior and contract sources are unchanged by this cleanup. +- CLI/Node: added registry-verified support for the M001 recognized-usage contracts, with ANTS-only `antseed seller stake --epochs `, USDC staking and withdrawal under `seller legacy`, `seller legacy claim-starter`, aggregate `seller rewards [claim]`, pool position management, early-exit slashing estimates with explicit confirmation, explicit seller registration, generated M001 address overrides, a persistent Anvil fork rehearsal via `pnpm m001:sandbox`, and a 10-second EVM request timeout for storage-heavy reward reads. Buyer emissions retain dual-era reads and claims. +- CLI breaking changes: `seller stake` no longer stakes USDC or accepts `--agent-id`; use `seller legacy stake` for USDC and `seller register --agent-id` for identity binding. Removed `seller unstake`, `seller emissions`, `seller pool rewards`, the pool `bootstrap`/`init` aliases, and `network contracts`. The starter claim is now `seller legacy claim-starter`. Use `seller rewards [claim]` for all seller earnings; position-specific and alternate-recipient claims are no longer exposed. Automatic contract validation remains in place. - 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`). diff --git a/apps/cli/README.md b/apps/cli/README.md index a9c6a1319..c23304b56 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -13,10 +13,10 @@ Command-line interface and web dashboard for the AntSeed Network — a P2P netwo | **Providing** | | | `antseed seller start` | Start providing AI services on the P2P network | | `antseed seller register` | Register peer identity on-chain (ERC-8004) | -| `antseed seller stake [--epochs ]` | Stake as a provider: ANTS after the recognized-usage upgrade, USDC before it | +| `antseed seller stake --epochs ` | Stake ANTS into your seller pool; never stakes USDC | | `antseed seller legacy stake ` | Stake USDC as a provider before cutover (min $10) | | `antseed seller legacy unstake` | Withdraw legacy USDC stake | -| `antseed seller pool claim-starter` | Claim the legacy-seller starter ANTS position after the recognized-usage upgrade | +| `antseed seller legacy claim-starter` | Claim the legacy-seller starter ANTS position after the recognized-usage upgrade | | `antseed seller pool positions` | List seller-pool positions and lifecycle state | | `antseed seller pool withdraw [--accept-slashing]` | Withdraw positions, with a slashing estimate and confirmation for early exits | | `antseed seller rewards [claim]` | View or claim all seller rewards | @@ -44,8 +44,6 @@ Command-line interface and web dashboard for the AntSeed Network — a P2P netwo | `antseed dashboard` | Start the web dashboard | | `antseed metrics serve` | Serve Prometheus metrics for buyers and sellers | | `antseed buyer channels` | List payment channels | -| `antseed seller emissions info` | View ANTS emissions and epoch info | -| `antseed network contracts [--json]` | Verify configured stack addresses against the on-chain registry | | `antseed dev` | Run seller + buyer locally for testing | | `antseed network bootstrap` | Run a dedicated DHT bootstrap node | @@ -356,20 +354,36 @@ After the M001 recognized-usage cutover, new seller stake moves from legacy USDC ```bash antseed seller register -antseed seller pool claim-starter +antseed seller legacy claim-starter antseed seller stake 100 --epochs 4 antseed seller pool positions antseed seller rewards antseed seller rewards claim ``` -`antseed seller stake` automatically uses ANTS after the recognized-usage upgrade (`--epochs` required) and legacy USDC before it. `antseed seller legacy stake` is explicit legacy USDC staking and refuses to run after the upgrade. `antseed seller legacy unstake` (also available as `antseed seller unstake`) withdraws legacy stake and warns that doing so can remove temporary eligibility before an ANTS position becomes active. `antseed seller rewards` combines legacy emissions, recognized-use emissions, and pool-staking rewards. The specialized `seller emissions` and `seller pool rewards` commands remain available for advanced use. +`antseed seller stake --epochs ` always stakes ANTS and requires the recognized-usage upgrade. On older networks it stops without sending a transaction and directs you to `antseed seller legacy stake `. New legacy USDC stakes are rejected after the upgrade. `antseed seller legacy unstake` withdraws legacy stake and warns that doing so can remove temporary eligibility before an ANTS position becomes active. `antseed seller legacy claim-starter` claims the starter position for an eligible legacy seller. `antseed seller rewards` combines legacy emissions, recognized-use emissions, and pool-staking rewards. `seller register` explicitly binds your existing agent identity to the current seller registry, independently of legacy stake. Repeating it when already bound sends no transaction. If registration needs updating, `seller stake` stops and asks you to run `antseed seller register`; staking never registers you silently. `seller rewards` is read-only: it calculates unclaimed rewards from completed epochs using existing contract getters, including pool earnings that have not yet been indexed. It does not sign transactions or spend gas. Pool previews use the same reward-index and position-segment rounding as the payout calculation. The pool contribution is read at a single block; amounts can change before a claim confirms. Historical position discovery includes withdrawn and closed positions using receipt burn events and may require an archive-capable RPC with historical log support. Read failures are reported rather than treated as zero rewards. -`seller rewards claim` prepares pool accounting in bounded transactions when necessary, then claims the rewards. Preparation and claims require gas. Confirmed transaction hashes are printed immediately, and received amounts are read from ANTS transfer receipts. If a later step fails, the CLI reports partial completion; rerun the command to collect remaining rewards. Compatibility commands `seller emissions`, `seller pool rewards`, and `seller unstake` remain callable but are hidden from the primary help listing. +`seller rewards claim` prepares pool accounting in bounded transactions when necessary, then claims all eligible seller rewards to the current wallet. Preparation and claims require gas. Confirmed transaction hashes are printed immediately, and received amounts are read from ANTS transfer receipts. If a later step fails, the CLI reports partial completion; rerun the command to collect remaining rewards. Position-specific claims and alternate reward recipients are not exposed by this minimal command. + +#### Command migration + +These are intentional command-surface breaks, not hidden aliases: + +| Removed command or behavior | Replacement | +|---|---| +| `seller stake ` staking USDC | `seller legacy stake ` | +| `seller stake --agent-id ` | Bind the identity with `seller register --agent-id ` first, then stake ANTS without an identity override | +| `seller unstake` | `seller legacy unstake` | +| `seller pool claim-starter`, `seller pool bootstrap`, `seller pool init` | `seller legacy claim-starter` | +| `seller emissions info`, `seller pool rewards` | `seller rewards` | +| `seller emissions claim`, `seller pool rewards claim` | `seller rewards claim` (all eligible rewards to the current wallet; no era, position, or recipient flags) | +| `network contracts` | No replacement command; registry/address validation remains automatic inside payment commands | + +Buyer emissions commands and their `--legacy-only` / `--new-only` filters are unchanged. Early withdrawal requires `--accept-slashing` and interactive confirmation; add `--yes` for automation. The CLI rechecks the estimate before submitting. Existing contracts determine slashing at execution and do not accept a maximum-loss bound, so the displayed estimate is not a guaranteed cap if rates change before confirmation. @@ -465,6 +479,12 @@ See [Metrics](../../apps/website/docs/guides/metrics.md) for metric names, label ## Development +Blockchain access and reward calculations live in `@antseed/node/payments`. +The CLI uses those SDK clients for registration, position discovery, reward +previews/claims, slashing estimates, and confirmed token receipts. Command +parsing, output, actionable instructions, and withdrawal confirmation stay in +the CLI; it has no direct `ethers` dependency. + ```bash npm install npm run build diff --git a/apps/cli/package.json b/apps/cli/package.json index d612b120e..0611a7de9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -35,7 +35,6 @@ "cli-table3": "^0.6.5", "commander": "^14.0.3", "dotenv": "^16.6.1", - "ethers": "~6.16.0", "open": "^11.0.0", "ora": "^9.3.0", "qrcode": "^1.5.4" diff --git a/apps/cli/src/cli/commands/emissions.ts b/apps/cli/src/cli/commands/emissions.ts index 37cb46c1b..3c00bd1a8 100644 --- a/apps/cli/src/cli/commands/emissions.ts +++ b/apps/cli/src/cli/commands/emissions.ts @@ -15,7 +15,8 @@ import { resolveCliContractStack, } from '../payment-utils.js'; import { legacyEpochs, newEpochs } from '@antseed/node/payments'; -import { claimBuyerEpochRewards, claimEpochRewards, pendingEpochRewards, RewardClaimProgress } from './reward-actions.js'; +import { claimBuyerEpochRewards, claimEpochRewards, pendingEpochRewards } from '@antseed/node/payments'; +import { RewardClaimProgress } from './reward-actions.js'; export type EmissionsRole = 'seller' | 'buyer'; diff --git a/apps/cli/src/cli/commands/network/contracts.ts b/apps/cli/src/cli/commands/network/contracts.ts deleted file mode 100644 index a37dbed15..000000000 --- a/apps/cli/src/cli/commands/network/contracts.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Command } from 'commander'; -import chalk from 'chalk'; -import Table from 'cli-table3'; -import { getGlobalOptions } from '../types.js'; -import { loadConfig } from '../../../config/loader.js'; -import { requireCryptoConfig, resolveCliContractStack } from '../../payment-utils.js'; - -function sameAddress(left: string | undefined, right: string | undefined): boolean { - return !!left && !!right && left.toLowerCase() === right.toLowerCase(); -} - -export function registerNetworkContractsCommand(networkCmd: Command): void { - networkCmd.command('contracts') - .description('Verify configured contract addresses against AntseedRegistry') - .option('--json', 'output as JSON', false) - .action(async (options) => { - try { - const global = getGlobalOptions(networkCmd); - const config = await loadConfig(global.config); - const crypto = requireCryptoConfig(config); - const stack = await resolveCliContractStack(config); - const expectedEmissions = stack.mode === 'legacy' ? crypto.emissionsContractAddress : crypto.usageAccountingAddress; - const expectedStaking = stack.mode === 'legacy' ? crypto.stakingContractAddress : crypto.sellerRegistryAddress; - const matches = { - emissions: sameAddress(stack.registryPointers.emissions, expectedEmissions), - staking: sameAddress(stack.registryPointers.staking, expectedStaking), - }; - const addresses = Object.fromEntries(Object.entries(crypto).filter(([key, value]) => key.endsWith('Address') && typeof value === 'string')); - if (options.json) { - console.log(JSON.stringify({ - chainId: crypto.chainId, - mode: stack.mode, - currentEpoch: stack.currentEpoch, - firstRewardedEpoch: stack.firstRewardedEpoch ?? null, - addresses, - registryPointers: stack.registryPointers, - matches, - }, null, 2)); - return; - } - console.log(chalk.bold(`Contracts (${crypto.chainId})\n`)); - console.log(`Mode: ${chalk.cyan(stack.mode)}`); - console.log(`Current epoch: ${stack.currentEpoch}`); - if (stack.firstRewardedEpoch !== undefined) console.log(`First rewarded epoch: ${stack.firstRewardedEpoch}`); - console.log(''); - const pointers = new Table({ head: ['Registry pointer', 'On-chain', 'Configured', 'Match'] }); - pointers.push( - ['emissions', stack.registryPointers.emissions, expectedEmissions ?? 'missing', matches.emissions ? chalk.green('✓') : chalk.red('✗')], - ['staking', stack.registryPointers.staking, expectedStaking ?? 'missing', matches.staking ? chalk.green('✓') : chalk.red('✗')], - ); - console.log(pointers.toString()); - console.log(chalk.bold('\nConfigured addresses')); - for (const [key, value] of Object.entries(addresses)) console.log(` ${key}: ${value}`); - } catch (error) { - console.error(chalk.red(`${(error as Error).name}: ${(error as Error).message}`)); - process.exitCode = 1; - } - }); -} diff --git a/apps/cli/src/cli/commands/network/index.ts b/apps/cli/src/cli/commands/network/index.ts index ed5746325..7c081d7c1 100644 --- a/apps/cli/src/cli/commands/network/index.ts +++ b/apps/cli/src/cli/commands/network/index.ts @@ -2,7 +2,6 @@ import type { Command } from 'commander'; import { registerNetworkBrowseCommand } from './browse.js'; import { registerNetworkPeerCommand } from './peer.js'; import { registerNetworkBootstrapCommand } from './bootstrap.js'; -import { registerNetworkContractsCommand } from './contracts.js'; export function registerNetworkCommands(program: Command): void { const networkCmd = program @@ -12,5 +11,4 @@ export function registerNetworkCommands(program: Command): void { registerNetworkBrowseCommand(networkCmd); registerNetworkPeerCommand(networkCmd); registerNetworkBootstrapCommand(networkCmd); - registerNetworkContractsCommand(networkCmd); } diff --git a/apps/cli/src/cli/commands/reward-actions.ts b/apps/cli/src/cli/commands/reward-actions.ts index 2d51c6bb8..458c1aae5 100644 --- a/apps/cli/src/cli/commands/reward-actions.ts +++ b/apps/cli/src/cli/commands/reward-actions.ts @@ -1,91 +1,4 @@ -import type { CliSellerPoolsClient as SellerPoolsClient, CliSellerPoolsRewardsClient as SellerPoolsRewardsClient } from '../seller-contract-clients.js'; - -export type RewardTransactionRecorder = (hash: string, kind: 'claim' | 'accounting') => Promise; - -export async function pendingEpochRewards( - epochs: number[], - readPending: (epochs: number[]) => Promise, -): Promise { - let amount = 0n; - for (let offset = 0; offset < epochs.length; offset += 32) { - amount += await readPending(epochs.slice(offset, offset + 32)); - } - return amount; -} - -export async function claimEpochRewards( - epochs: number[], - readPending: (epochs: number[]) => Promise, - claim: (epochs: number[]) => Promise, - record: RewardTransactionRecorder, -): Promise { - for (let offset = 0; offset < epochs.length; offset += 32) { - const batch = epochs.slice(offset, offset + 32); - if (await readPending(batch) > 0n) await record(await claim(batch), 'claim'); - } -} - -export async function claimBuyerEpochRewards( - epochs: number[], - readPending: (epoch: number) => Promise, - claim: (epoch: number) => Promise, - record: RewardTransactionRecorder, -): Promise { - for (const epoch of epochs) { - if (await readPending(epoch) > 0n) await record(await claim(epoch), 'claim'); - } -} - -type PoolReader = Pick; -type PoolRewards = Pick; -type RewardSigner = Parameters[0]; - -export async function previewPoolRewards(pools: PoolReader, rewards: PoolRewards, address: string, positionId?: number) { - const positions = positionId === undefined ? await pools.rewardPositions(address) : [await pools.position(positionId)]; - const pending: Array<{ id: number; agentId: number; amount: bigint; closedAtEpoch: number }> = []; - for (const position of positions) { - if (position.owner.toLowerCase() !== address.toLowerCase()) throw new Error(`Position ${position.id} is not owned by this wallet.`); - pending.push({ id: position.id, agentId: position.agentId, amount: await rewards.previewStakerReward(position.id), closedAtEpoch: position.closedAtEpoch }); - } - return pending; -} - -export async function claimPoolRewards( - pools: PoolReader, - rewards: PoolRewards, - wallet: RewardSigner, - address: string, - recipient: string, - record: RewardTransactionRecorder, - preparing: () => void, - positionId?: number, -): Promise { - const pending = await previewPoolRewards(pools, rewards, address, positionId); - const rewardedPositions = pending.filter((position) => position.amount > 0n); - if (rewardedPositions.length === 0) return; - const currentEpoch = await pools.currentEpoch(); - for (const agentId of new Set(rewardedPositions.map((position) => position.agentId))) { - const targetEpoch = rewardedPositions.filter((position) => position.agentId === agentId) - .reduce((latest, position) => Math.max(latest, Math.min(currentEpoch, position.closedAtEpoch || currentEpoch)), 0); - let cursor = await rewards.poolRewardIndexNextEpoch(agentId) || await rewards.initialIndexEpoch(); - if (cursor < targetEpoch) preparing(); - while (cursor < targetEpoch) { - await record(await rewards.indexPoolRewards(wallet, agentId, Math.min(16, targetEpoch - cursor)), 'accounting'); - const next = await rewards.poolRewardIndexNextEpoch(agentId); - if (next <= cursor) throw new Error('Reward preparation made no progress. Retry the claim later.'); - cursor = next; - } - } - const ids: number[] = []; - for (const position of rewardedPositions) { - if (await rewards.pendingIndexedStakerReward(position.id) > 0n) ids.push(position.id); - } - for (let offset = 0; offset < ids.length; offset += 32) { - await record(await rewards.claimStakerRewardsBatch(wallet, ids.slice(offset, offset + 32), recipient), 'claim'); - } -} +import type { RewardTransactionRecorder } from '@antseed/node/payments'; export class RewardClaimProgress { claimed = 0n; diff --git a/apps/cli/src/cli/commands/seller/contract-clients.test.ts b/apps/cli/src/cli/commands/seller/contract-clients.test.ts index 04f99df37..46c80e4b3 100644 --- a/apps/cli/src/cli/commands/seller/contract-clients.test.ts +++ b/apps/cli/src/cli/commands/seller/contract-clients.test.ts @@ -1,140 +1,9 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { Interface, ZeroAddress, type AbstractSigner } from 'ethers'; -import { CliSellerPoolsClient, CliSellerPoolsRewardsClient, CliSellerRegistryClient, CliAntsTokenClient, collectPositionIds, registerSellerBinding, requireSellerBinding } from '../../seller-contract-clients.js'; import { requireCryptoConfig } from '../../payment-utils.js'; import type { AntseedConfig } from '../../../config/types.js'; const address = '0x0000000000000000000000000000000000000011'; -const contractAddress = '0x0000000000000000000000000000000000000022'; -const config = { rpcUrl: 'http://127.0.0.1:1', contractAddress, evmChainId: 31337 }; - -test('CLI reads slashing estimates directly without an SDK extension', async () => { - const client = new CliSellerPoolsClient({ ...config, antsTokenAddress: contractAddress }); - const abi = new Interface(['function earlyExitSlashBps(uint256) view returns (uint256)']); - Object.defineProperty(client, '_provider', { value: { - call: async (transaction: { data: string }) => { - const call = abi.parseTransaction(transaction)!; - assert.equal(call.name, 'earlyExitSlashBps'); - assert.equal(call.args[0], 7n); - return abi.encodeFunctionResult(call.name, [2500n]); - }, - } }); - assert.ok(Object.hasOwn(CliSellerPoolsClient.prototype, 'earlyExitSlashBps')); - assert.equal(await client.earlyExitSlashBps(7), 2500); -}); - -test('registration distinguishes legacy fallback, persists explicitly, and is idempotent', async () => { - let legacy = true; - let registered = false; - let writes = 0; - const registry = { - getAgentId: async () => legacy || registered ? 7 : 0, - isRegisteredSeller: async () => registered, - registerSeller: async () => { writes++; registered = true; return 'confirmed'; }, - }; - await assert.rejects(requireSellerBinding(registry, address), /Run: antseed seller register/); - assert.equal(writes, 0); - const hashes: string[] = []; - assert.equal(await registerSellerBinding(registry, {} as AbstractSigner, address, 7, (hash) => hashes.push(hash)), true); - legacy = false; - assert.equal(await requireSellerBinding(registry, address), 7); - assert.equal(await registerSellerBinding(registry, {} as AbstractSigner, address, 7, () => {}), false); - assert.equal(writes, 1); - assert.deepEqual(hashes, ['confirmed']); -}); - -test('registration reports a confirmed transaction before verification failure', async () => { - const hashes: string[] = []; - await assert.rejects(registerSellerBinding({ getAgentId: async () => 7, isRegisteredSeller: async () => false, registerSeller: async () => 'confirmed' }, - {} as AbstractSigner, address, 7, (hash) => hashes.push(hash)), /could not be verified/); - assert.deepEqual(hashes, ['confirmed']); -}); - -test('explicit binding reads the existing agentSeller getter, not just getAgentId', async () => { - const client = new CliSellerRegistryClient(config); - const abi = new Interface(['function agentSeller(uint256 agentId) view returns (address)']); - client.getAgentId = async () => 7; - let bound = ZeroAddress; - Object.defineProperty(client, '_provider', { value: { call: async () => abi.encodeFunctionResult('agentSeller', [bound]) } }); - assert.equal(await client.isRegisteredSeller(address, 7), false); - bound = address; - assert.equal(await client.isRegisteredSeller(address, 7), true); -}); - -test('position pagination includes every page', async () => { - const ids = Array.from({ length: 513 }, (_, index) => index + 1); - assert.deepEqual(await collectPositionIds(async (offset, limit) => ids.slice(offset, offset + limit)), ids); -}); - -test('historical reward discovery includes burned positions and filters old owners', async () => { - const client = new CliSellerPoolsClient({ ...config, antsTokenAddress: contractAddress }); - const active = Array.from({ length: 300 }, (_, index) => index + 1); - client.stakerPositionIds = async (_staker, offset = 0, limit = 256) => active.slice(offset, offset + limit); - client.position = async (id) => ({ id, owner: id === 2 ? contractAddress : address, agentId: 7, amount: 1n, weightAmount: 1n, stakeStartEpoch: 1, stakeEndEpoch: 4, closedAtEpoch: id === 301 ? 3 : 0, withdrawn: id === 301 }); - const abi = new Interface(['event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)']); - const log = abi.encodeEventLog(abi.getEvent('Transfer')!, [address, ZeroAddress, 301]); - Object.defineProperty(client, '_provider', { value: { - getBlockNumber: async () => 16, - getCode: async (_target: string, block: number) => block < 4 ? '0x' : '0x6000', - getLogs: async (filter: { fromBlock: number }) => { assert.equal(filter.fromBlock, 4); return [{ ...log, address: contractAddress }]; }, - } }); - const positions = await client.rewardPositions(address); - assert.equal(positions.length, 300); - assert.equal(positions.at(-1)!.id, 301); - assert.ok(!positions.some((position) => position.id === 2)); -}); - -test('preview uses only existing view selectors at one block, including unindexed epochs', async () => { - const client = new CliSellerPoolsRewardsClient(config); - const abi = new Interface([ - 'function sellerPools() view returns (address)', 'function usageAccounting() view returns (address)', - 'function positions(uint256) view returns (address,uint256,uint256,uint256,uint64,uint64,uint64,bool)', - 'function currentEpoch() view returns (uint256)', 'function positionClaimCursor(uint256) view returns (uint256)', - 'function poolRewardIndexNextEpoch(uint256) view returns (uint256)', 'function initialIndexEpoch() view returns (uint256)', - 'function positionPowerSegmentAt(uint256,uint256) view returns (uint256,uint256,uint256)', - 'function poolCumulativeRewardPerWeightAt(uint256,uint256) view returns (uint256)', - 'function poolCumulativeEpochRewardPerWeightAt(uint256,uint256) view returns (uint256)', - 'function poolWeightAtEpoch(uint256,uint256) view returns (uint256)', 'function poolEpochEmissions(uint256,uint256) view returns (bool,uint256)', - 'function weightedPoolPointsByEpoch(uint256,uint256) view returns (uint256)', 'function totalWeightedPoolPointsByEpoch(uint256) view returns (uint256)', - 'function stakerEpochBudget(uint256) view returns (uint256)', - ]); - let reads = 0; - Object.defineProperty(client, '_provider', { value: { - getBlockNumber: async () => 123, - call: async (transaction: { data: string; blockTag: number }) => { - assert.equal(transaction.blockTag, 123); - reads++; - const call = abi.parseTransaction(transaction)!; - let result: unknown[]; - switch (call.name) { - case 'sellerPools': case 'usageAccounting': result = [contractAddress]; break; - case 'positions': result = [address, 7, 3, 3, 1, 4, 0, false]; break; - case 'currentEpoch': result = [3]; break; - case 'initialIndexEpoch': result = [1]; break; - case 'positionPowerSegmentAt': result = [4, 0, 100]; break; - case 'poolWeightAtEpoch': result = [call.args[1] === 1n ? 10 : 9]; break; - case 'poolEpochEmissions': result = [false, 0]; break; - case 'weightedPoolPointsByEpoch': case 'totalWeightedPoolPointsByEpoch': result = [1]; break; - case 'stakerEpochBudget': result = [call.args[0] === 1n ? 100 : 101]; break; - default: result = [0]; - } - return abi.encodeFunctionResult(call.name, result); - }, - } }); - assert.equal(await client.previewStakerReward(1), 157n); - const firstReads = reads; - assert.equal(await client.previewStakerReward(1), 157n); - assert.equal(reads, firstReads); -}); - -test('confirmed reward totals count only actual incoming ANTS transfers', async () => { - const client = new CliAntsTokenClient(config); - const abi = new Interface(['event Transfer(address indexed from, address indexed to, uint256 value)']); - const transfer = (target: string, recipient: string, amount: bigint) => ({ address: target, ...abi.encodeEventLog(abi.getEvent('Transfer')!, [ZeroAddress, recipient, amount]) }); - Object.defineProperty(client, '_provider', { value: { getTransactionReceipt: async () => ({ status: 1, logs: [transfer(contractAddress, address, 12n), transfer(address, address, 99n), transfer(contractAddress, contractAddress, 30n)] }) } }); - assert.equal(await client.receivedInTransaction('confirmed', address), 12n); -}); test('CLI local defaults use the registry nonce rather than the token nonce and preserve overrides', () => { const base = { payments: { crypto: { chainId: 'base-local' } } } as AntseedConfig; diff --git a/apps/cli/src/cli/commands/seller/emissions.ts b/apps/cli/src/cli/commands/seller/emissions.ts deleted file mode 100644 index 88898d1f0..000000000 --- a/apps/cli/src/cli/commands/seller/emissions.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Command } from 'commander'; -import { registerEmissionsCommand } from '../emissions.js'; - -export function registerSellerEmissionsCommand(sellerCmd: Command): void { - registerEmissionsCommand(sellerCmd, 'seller'); -} diff --git a/apps/cli/src/cli/commands/seller/index.ts b/apps/cli/src/cli/commands/seller/index.ts index a4135fcdf..75f0baad9 100644 --- a/apps/cli/src/cli/commands/seller/index.ts +++ b/apps/cli/src/cli/commands/seller/index.ts @@ -4,7 +4,6 @@ import { registerSellerSetupCommand } from './setup.js'; import { registerSellerStatusCommand } from './status.js'; import { registerSellerRegisterCommand } from './register.js'; import { registerSellerStakeCommand } from './stake.js'; -import { registerSellerEmissionsCommand } from './emissions.js'; import { registerSellerDoctorCommand } from './doctor.js'; import { registerSellerPoolCommand } from './pool.js'; import { registerSellerRewardsCommand } from './rewards.js'; @@ -19,7 +18,6 @@ export function registerSellerCommands(program: Command): void { registerSellerStatusCommand(sellerCmd); registerSellerRegisterCommand(sellerCmd); registerSellerStakeCommand(sellerCmd); - registerSellerEmissionsCommand(sellerCmd); registerSellerPoolCommand(sellerCmd); registerSellerRewardsCommand(sellerCmd); registerSellerDoctorCommand(sellerCmd); diff --git a/apps/cli/src/cli/commands/seller/pool.test.ts b/apps/cli/src/cli/commands/seller/pool.test.ts index 7a9b6d878..fcabf7c3a 100644 --- a/apps/cli/src/cli/commands/seller/pool.test.ts +++ b/apps/cli/src/cli/commands/seller/pool.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { estimateEarlyExit, positionState, validateStakeEpochs } from './pool.js'; +import { positionState, validateStakeEpochs } from './pool.js'; const position = { id: 1, owner: '0x1', agentId: 2, amount: 1n, weightAmount: 1n, stakeStartEpoch: 5, stakeEndEpoch: 9, closedAtEpoch: 0, withdrawn: false }; test('positionState covers pending, active, matured, and withdrawn positions', () => { @@ -14,12 +14,3 @@ test('validateStakeEpochs enforces contract bounds', () => { assert.throws(() => validateStakeEpochs(0, 1, 104)); assert.throws(() => validateStakeEpochs(105, 1, 104)); }); -test('estimateEarlyExit reports exact principal loss and return', () => { - assert.deepEqual(estimateEarlyExit({ ...position, amount: 100n * 10n ** 18n }, 2500), { - id: 1, - amount: 100n * 10n ** 18n, - slashBps: 2500, - slashedAmount: 25n * 10n ** 18n, - returnedAmount: 75n * 10n ** 18n, - }); -}); diff --git a/apps/cli/src/cli/commands/seller/pool.ts b/apps/cli/src/cli/commands/seller/pool.ts index 1bdcdbf0f..195c01306 100644 --- a/apps/cli/src/cli/commands/seller/pool.ts +++ b/apps/cli/src/cli/commands/seller/pool.ts @@ -1,5 +1,4 @@ import type { Command } from 'commander'; -import { isAddress, ZeroAddress } from 'ethers'; import { createInterface } from 'node:readline/promises'; import chalk from 'chalk'; import Table from 'cli-table3'; @@ -11,7 +10,6 @@ import { createLegacyStakingClient, createPositionInitClient, createSellerPoolsClient, - createSellerPoolsRewardsClient, createSellerRegistryClient, formatAnts, formatAntsExact, @@ -19,9 +17,7 @@ import { parseAntsToBaseUnits, resolveCliContractStack, } from '../../payment-utils.js'; -import type { SellerPoolPosition } from '@antseed/node/payments'; -import { claimPoolRewards, previewPoolRewards, RewardClaimProgress } from '../reward-actions.js'; -import { requireSellerBinding } from '../../seller-contract-clients.js'; +import { estimateEarlyExit, type SellerPoolPosition } from '@antseed/node/payments'; export function positionState(position: SellerPoolPosition, currentEpoch: number): string { if (position.withdrawn) return 'withdrawn'; @@ -45,25 +41,6 @@ async function requirePoolStack(config: Awaited>) return stack; } -export interface EarlyExitEstimate { - id: number; - amount: bigint; - slashBps: number; - slashedAmount: bigint; - returnedAmount: bigint; -} - -export function estimateEarlyExit(position: SellerPoolPosition, slashBps: number): EarlyExitEstimate { - const slashedAmount = position.amount * BigInt(slashBps) / 10_000n; - return { - id: position.id, - amount: position.amount, - slashBps, - slashedAmount, - returnedAmount: position.amount - slashedAmount, - }; -} - async function confirmEarlyExit(totalSlashed: bigint): Promise { if (!process.stdin.isTTY || !process.stdout.isTTY) { throw new Error('Early withdrawal needs interactive confirmation. Re-run in a terminal, or add --yes after reviewing the slashing estimate.'); @@ -77,11 +54,9 @@ async function confirmEarlyExit(totalSlashed: bigint): Promise { } } -export function registerSellerPoolCommand(sellerCmd: Command): void { - const pool = sellerCmd.command('pool').description('Manage ANTS staking positions'); - - pool.command('claim-starter').aliases(['bootstrap', 'init']).description('Claim the legacy-seller starter ANTS position').action(async () => { - const global = getGlobalOptions(pool); +export function registerSellerStarterCommand(legacyCmd: Command): void { + legacyCmd.command('claim-starter').description('Claim the legacy-seller starter ANTS position').action(async () => { + const global = getGlobalOptions(legacyCmd); const config = await loadConfig(global.config); const spinner = ora('Checking starter position...').start(); try { @@ -106,6 +81,10 @@ export function registerSellerPoolCommand(sellerCmd: Command): void { process.exitCode = 1; } }); +} + +export function registerSellerPoolCommand(sellerCmd: Command): void { + const pool = sellerCmd.command('pool').description('Manage ANTS staking positions'); pool.command('positions').description('List your seller-pool positions').option('--json', 'output as JSON', false).action(async (options) => { const global = getGlobalOptions(pool); @@ -190,63 +169,10 @@ export function registerSellerPoolCommand(sellerCmd: Command): void { process.exitCode = 1; } }); - - const rewards = pool.command('rewards', { hidden: true }).description('Show or claim pool rewards (use seller rewards for all rewards)').option('--json', 'output as JSON', false); - rewards.action(async (options) => { - const global = getGlobalOptions(rewards); - const config = await loadConfig(global.config); - try { - await requirePoolStack(config); - const { address } = await loadCryptoContext(global.dataDir); - const pools = createSellerPoolsClient(config); - const rewardsClient = createSellerPoolsRewardsClient(config); - const pending = await previewPoolRewards(pools, rewardsClient, address); - const total = pending.reduce((sum, item) => sum + item.amount, 0n); - if (options.json) console.log(JSON.stringify({ positions: pending.map(({ id, amount }) => ({ id, amount })), total }, (_key, value) => typeof value === 'bigint' ? value.toString() : value, 2)); - else { - for (const item of pending) console.log(`Position ${item.id}: ${formatAnts(item.amount)} ANTS`); - console.log(chalk.bold(`Total: ${formatAnts(total)} ANTS`)); - } - } catch (error) { - console.error(chalk.red((error as Error).message)); - process.exitCode = 1; - } - }); - - rewards.command('claim').description('Claim pool rewards').option('--position ', 'claim one position', (value) => Number(value)).option('--recipient
', 'reward recipient').action(async (options) => { - const global = getGlobalOptions(rewards); - const config = await loadConfig(global.config); - const spinner = ora('Checking pending rewards...').start(); - let progress: RewardClaimProgress | undefined; - try { - if (options.position !== undefined && (!Number.isSafeInteger(options.position) || options.position <= 0)) { - throw new Error('Position ID must be a positive integer.'); - } - await requirePoolStack(config); - const { wallet, address } = await loadCryptoContext(global.dataDir); - const pools = createSellerPoolsClient(config); - const rewardsClient = createSellerPoolsRewardsClient(config); - const recipient = options.recipient || address; - if (!isAddress(recipient) || recipient === ZeroAddress) throw new Error('Reward recipient must be a valid nonzero address.'); - const token = createAntsTokenClient(config); - progress = new RewardClaimProgress( - (hash, kind) => console.log(chalk.dim(`${kind === 'accounting' ? 'Preparation' : 'Claim'} transaction confirmed: ${hash}`)), - (hash) => token.receivedInTransaction(hash, recipient), - ); - await claimPoolRewards(pools, rewardsClient, wallet, address, recipient, progress.record, () => { - spinner.text = 'Updating pool rewards (preparation transactions require gas)...'; - }, options.position); - spinner.succeed(progress.claimed > 0n ? chalk.green(`Claimed ${formatAnts(progress.claimed)} ANTS`) : chalk.yellow('No pool rewards pending.')); - } catch (error) { - spinner.fail(chalk.red(progress?.failure((error as Error).message) ?? (error as Error).message)); - process.exitCode = 1; - } - }); } export interface PoolStakeOptions { epochs: number; - agentId?: number; } /** Stake ANTS into a seller pool through the primary `seller stake` command. */ @@ -265,7 +191,8 @@ export async function runPoolStake( const registry = createSellerRegistryClient(config); const [minEpochs, maxEpochs] = await Promise.all([pools.minStakeEpochs(), pools.maxStakeEpochs()]); validateStakeEpochs(options.epochs, minEpochs, maxEpochs); - const agentId = await requireSellerBinding(registry, address, options.agentId); + const agentId = await registry.getRegisteredAgentId(address); + if (!agentId) throw new Error('Registration needs updating before you can stake. Run: antseed seller register. Your existing identity will be kept.'); const token = createAntsTokenClient(config); const balance = await token.balanceOf(address); if (balance < amountBaseUnits) throw new Error(`Insufficient ANTS balance: have ${formatAnts(balance)}, need ${formatAnts(amountBaseUnits)}.`); diff --git a/apps/cli/src/cli/commands/seller/register.ts b/apps/cli/src/cli/commands/seller/register.ts index 1d5532793..31dcd3457 100644 --- a/apps/cli/src/cli/commands/seller/register.ts +++ b/apps/cli/src/cli/commands/seller/register.ts @@ -3,7 +3,7 @@ import chalk from 'chalk'; import ora from 'ora'; import { getGlobalOptions } from '../types.js'; import { loadConfig } from '../../../config/loader.js'; -import { registerSellerBinding } from '../../seller-contract-clients.js'; +import { SellerRegistrationVerificationError } from '@antseed/node/payments'; import { createIdentityClient, loadCryptoContext, @@ -48,7 +48,7 @@ export function registerSellerRegisterCommand(sellerCmd: Command): void { if (!agentId) throw new Error('Could not determine agent ID. Pass --agent-id .'); const sellerRegistry = createSellerRegistryClient(config); spinner.start('Checking seller registration...'); - const registered = await registerSellerBinding(sellerRegistry, wallet, address, agentId, + const registered = await sellerRegistry.registerSellerBinding(wallet, agentId, (hash) => console.log(chalk.dim(`Transaction: ${hash}`))); if (registered) { spinner.succeed(chalk.green('Seller bound to recognized-usage registry')); @@ -61,7 +61,8 @@ export function registerSellerRegisterCommand(sellerCmd: Command): void { if (agentId) console.log(chalk.dim(`Agent ID: ${agentId}`)); } catch (err) { - spinner.fail(chalk.red(`Registration failed: ${(err as Error).message}`)); + const guidance = err instanceof SellerRegistrationVerificationError ? ' Re-run: antseed seller register' : ''; + spinner.fail(chalk.red(`Registration failed: ${(err as Error).message}${guidance}`)); process.exit(1); } }); diff --git a/apps/cli/src/cli/commands/seller/rewards.test.ts b/apps/cli/src/cli/commands/seller/rewards.test.ts index 0c70ebb97..a929ac468 100644 --- a/apps/cli/src/cli/commands/seller/rewards.test.ts +++ b/apps/cli/src/cli/commands/seller/rewards.test.ts @@ -1,87 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { totalSellerRewards } from './rewards.js'; -import { previewPositionReward, REWARD_INDEX_SCALE, type RewardPreviewReader } from '../../reward-preview.js'; -import { claimBuyerEpochRewards, claimEpochRewards, claimPoolRewards, previewPoolRewards, RewardClaimProgress } from '../reward-actions.js'; -import type { SellerPoolPosition } from '@antseed/node/payments'; -import type { AbstractSigner } from 'ethers'; - -const position: SellerPoolPosition = { - id: 1, owner: 'seller', agentId: 7, amount: 1n, weightAmount: 1n, - stakeStartEpoch: 1, stakeEndEpoch: 4, closedAtEpoch: 0, withdrawn: false, -}; - -function previewReader(indexedThrough: number, overrides: Partial = {}): RewardPreviewReader { - const rates = new Map([[1, REWARD_INDEX_SCALE / 5n], [2, 3n * REWARD_INDEX_SCALE / 10n]]); - return { - currentEpoch: async () => 3, - claimCursor: async () => 0, - indexCursor: async () => indexedThrough, - segment: async () => ({ normalEnd: 4n, maxLockPower: 0n, nextChange: 2n ** 256n - 1n }), - cumulative: async (_agentId, epoch) => { - let reward = 0n; - let epochReward = 0n; - for (const [rateEpoch, rate] of rates) { - if (rateEpoch < Math.min(epoch, indexedThrough)) { - reward += rate; - epochReward += rate * BigInt(rateEpoch); - } - } - return { reward, epochReward }; - }, - rewardPerWeight: async (_agentId, epoch) => { - assert.ok(epoch < 3, 'must not include the current epoch'); - return rates.get(epoch) ?? 0n; - }, - ...overrides, - }; -} +import { RewardClaimProgress } from '../reward-actions.js'; test('totalSellerRewards combines legacy, usage, and pool rewards', () => { assert.equal(totalSellerRewards({ legacy: 1n, usage: 2n, pool: 3n, poolPositions: [] }), 6n); }); -test('read-only preview preserves payout rounding before, during, and after indexing', async () => { - for (const indexedThrough of [1, 2, 3]) { - assert.equal(await previewPositionReward(position, previewReader(indexedThrough)), 1n); - } -}); - -test('preview excludes claimed epochs and retains earned rewards after withdrawal', async () => { - const withdrawn = { ...position, withdrawn: true, closedAtEpoch: 3 }; - assert.equal(await previewPositionReward(withdrawn, previewReader(1)), 1n); - assert.equal(await previewPositionReward(withdrawn, previewReader(3, { claimCursor: async () => 3 })), 0n); - assert.equal(await previewPositionReward({ ...position, closedAtEpoch: 2 }, previewReader(1)), 0n); -}); - -test('preview uses separate floor rounding for extended and max-lock segments', async () => { - const reader = previewReader(1, { - segment: async (_id, epoch) => epoch === 1 - ? { normalEnd: 4n, maxLockPower: 0n, nextChange: 2n } - : { normalEnd: 0n, maxLockPower: 5n, nextChange: 99n }, - }); - assert.equal(await previewPositionReward(position, reader), 1n); - assert.equal(await previewPositionReward(position, previewReader(1, { - segment: async (_id, epoch) => ({ normalEnd: epoch === 1 ? 4n : 7n, maxLockPower: 0n, nextChange: epoch === 1 ? 2n : 99n }), - })), 1n); -}); - -test('buyer claims include unpaid epochs older than the last 104', async () => { - const claimed: number[] = []; - await claimBuyerEpochRewards(Array.from({ length: 106 }, (_, epoch) => epoch), - async (epoch) => epoch === 0 || epoch === 105 ? 5n : 0n, - async (epoch) => { claimed.push(epoch); return `tx-${epoch}`; }, async () => {}); - assert.deepEqual(claimed, [0, 105]); -}); - -test('epoch claims use bounded batches without discarding old epochs', async () => { - const batches: number[][] = []; - await claimEpochRewards(Array.from({ length: 105 }, (_, epoch) => epoch), async () => 1n, - async (epochs) => { batches.push(epochs); return 'tx'; }, async () => {}); - assert.deepEqual(batches.map((batch) => batch.length), [32, 32, 32, 9]); - assert.equal(batches.flat().length, 105); -}); - test('confirmed transactions remain visible if a later claim or receipt read fails', async () => { const shown: string[] = []; const progress = new RewardClaimProgress((hash) => shown.push(hash), async (hash) => { @@ -94,57 +19,3 @@ test('confirmed transactions remain visible if a later claim or receipt read fai assert.equal(progress.claimed, 10n); assert.match(progress.failure('usage claim failed'), /2 transaction\(s\) already confirmed/); }); - -function poolFixture(closedAtEpoch = 0) { - let cursor = 1; - let wasClaimed = false; - const writes: string[] = []; - const targetEpoch = closedAtEpoch || 35; - const pools = { rewardPositions: async () => [{ ...position, withdrawn: closedAtEpoch !== 0, closedAtEpoch }], position: async () => position, currentEpoch: async () => 35 }; - const rewards = { - previewStakerReward: async () => wasClaimed ? 0n : 12n, - pendingIndexedStakerReward: async () => cursor === targetEpoch && !wasClaimed ? 12n : 0n, - poolRewardIndexNextEpoch: async () => cursor, - initialIndexEpoch: async () => 1, - indexPoolRewards: async (_wallet: AbstractSigner, _agentId: number, maxEpochs: number) => { - assert.ok(maxEpochs <= 16); - cursor += maxEpochs; - writes.push('index'); - return `index-${cursor}`; - }, - claimStakerRewardsBatch: async (_wallet: AbstractSigner, ids: number[]) => { - assert.equal(cursor, targetEpoch); - assert.deepEqual(ids, [1]); - writes.push('claim'); - wasClaimed = true; - return 'claim-confirmed'; - }, - }; - return { pools, rewards, writes }; -} - -test('view previews unindexed historical earnings without writing; claim prepares and pays once', async () => { - const { pools, rewards, writes } = poolFixture(); - const displayed = await previewPoolRewards(pools, rewards, 'seller'); - assert.equal(displayed[0]!.amount, 12n); - assert.deepEqual(writes, []); - const confirmed: string[] = []; - await claimPoolRewards(pools, rewards, {} as AbstractSigner, 'seller', 'seller', async (hash) => { confirmed.push(hash); }, () => {}); - assert.deepEqual(writes, ['index', 'index', 'index', 'claim']); - assert.equal(confirmed.at(-1), 'claim-confirmed'); - await claimPoolRewards(pools, rewards, {} as AbstractSigner, 'seller', 'seller', async () => {}, () => {}); - assert.equal(writes.length, 4); -}); - -test('claim stops if indexing fails instead of claiming an incomplete amount', async () => { - const { pools, rewards, writes } = poolFixture(); - rewards.indexPoolRewards = async () => { throw new Error('gas unavailable'); }; - await assert.rejects(claimPoolRewards(pools, rewards, {} as AbstractSigner, 'seller', 'seller', async () => {}, () => {}), /gas unavailable/); - assert.deepEqual(writes, []); -}); - -test('withdrawn positions only prepare accounting through their closing epoch', async () => { - const { pools, rewards, writes } = poolFixture(3); - await claimPoolRewards(pools, rewards, {} as AbstractSigner, 'seller', 'seller', async () => {}, () => {}); - assert.deepEqual(writes, ['index', 'claim']); -}); diff --git a/apps/cli/src/cli/commands/seller/rewards.ts b/apps/cli/src/cli/commands/seller/rewards.ts index c699fe149..8253531ab 100644 --- a/apps/cli/src/cli/commands/seller/rewards.ts +++ b/apps/cli/src/cli/commands/seller/rewards.ts @@ -16,7 +16,8 @@ import { resolveCliContractStack, } from '../../payment-utils.js'; import { pastEpochs } from '../emissions.js'; -import { claimEpochRewards, claimPoolRewards, pendingEpochRewards, previewPoolRewards, RewardClaimProgress } from '../reward-actions.js'; +import { claimEpochRewards, claimPoolRewards, pendingEpochRewards, previewPoolRewards } from '@antseed/node/payments'; +import { RewardClaimProgress } from '../reward-actions.js'; export interface SellerRewardSummary { legacy: bigint; diff --git a/apps/cli/src/cli/commands/seller/setup.ts b/apps/cli/src/cli/commands/seller/setup.ts index f663e442d..123bcf07a 100644 --- a/apps/cli/src/cli/commands/seller/setup.ts +++ b/apps/cli/src/cli/commands/seller/setup.ts @@ -259,7 +259,8 @@ export function registerSellerSetupCommand(sellerCmd: Command): void { console.log(chalk.bold('\nNext steps:\n')); console.log(` ${chalk.cyan('1.')} Set credentials: ${chalk.dim(getSellerSetupCredentialHint(pluginName))}`); console.log(` ${chalk.cyan('2.')} Register on-chain: ${chalk.dim('antseed seller register')}`); - console.log(` ${chalk.cyan('3.')} Stake: ${chalk.dim('antseed seller stake 10')} ${chalk.dim('(add --epochs on recognized-usage networks)')}`); + console.log(` ${chalk.cyan('3.')} Stake ANTS: ${chalk.dim('antseed seller stake --epochs ')}`); + console.log(chalk.dim(' Before the upgrade, use: antseed seller legacy stake ')); console.log(` ${chalk.cyan('4.')} Start selling: ${chalk.dim('antseed seller start')}`); console.log(''); diff --git a/apps/cli/src/cli/commands/seller/stake.test.ts b/apps/cli/src/cli/commands/seller/stake.test.ts index 67d3db810..7d4456f33 100644 --- a/apps/cli/src/cli/commands/seller/stake.test.ts +++ b/apps/cli/src/cli/commands/seller/stake.test.ts @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { Command } from 'commander'; import { registerSellerCommands } from './index.js'; +import { registerNetworkCommands } from '../network/index.js'; +import { registerBuyerCommands } from '../buyer/index.js'; function sellerCommand(): Command { const program = new Command(); @@ -13,13 +15,13 @@ function findCommand(parent: Command, name: string): Command | undefined { return parent.commands.find((command) => command.name() === name); } -test('seller stake is stack-aware and accepts an ANTS lock duration', () => { +test('seller stake only accepts ANTS and a lock duration, not an identity override', () => { const stake = findCommand(sellerCommand(), 'stake'); assert.ok(stake); assert.ok(stake!.options.some((option) => option.long === '--epochs')); - const agentId = stake!.options.find((option) => option.long === '--agent-id'); - assert.ok(agentId); - assert.equal(agentId!.hidden, true); + assert.match(stake.description(), /ANTS/); + assert.doesNotMatch(stake.description(), /USDC/); + assert.equal(stake.options.find((option) => option.long === '--agent-id'), undefined); }); test('legacy USDC staking lives under seller legacy', () => { @@ -29,26 +31,30 @@ test('legacy USDC staking lives under seller legacy', () => { assert.ok(findCommand(legacy!, 'unstake')); }); -test('seller unstake remains available as a legacy alias', () => { - assert.ok(findCommand(sellerCommand(), 'unstake')); +test('seller unstake is only available under legacy', () => { + assert.equal(findCommand(sellerCommand(), 'unstake'), undefined); }); -test('primary help shows aggregate rewards while retaining hidden compatibility commands', () => { +test('aggregate rewards is the only seller reward command', () => { const seller = sellerCommand(); const help = seller.helpInformation(); assert.match(help, /rewards/); assert.doesNotMatch(help, /\n\s+emissions\s/); assert.doesNotMatch(help, /\n\s+unstake\s/); const pool = findCommand(seller, 'pool')!; - assert.ok(findCommand(pool, 'rewards')); + assert.equal(findCommand(seller, 'emissions'), undefined); + assert.equal(findCommand(pool, 'rewards'), undefined); assert.doesNotMatch(pool.helpInformation(), /\n\s+rewards/); }); -test('pool claim-starter keeps bootstrap and init as aliases', () => { - const pool = findCommand(sellerCommand(), 'pool')!; - const claimStarter = findCommand(pool, 'claim-starter'); +test('claim-starter lives under legacy without aliases', () => { + const seller = sellerCommand(); + const pool = findCommand(seller, 'pool')!; + const legacy = findCommand(seller, 'legacy')!; + const claimStarter = findCommand(legacy, 'claim-starter'); assert.ok(claimStarter); - assert.deepEqual(claimStarter!.aliases(), ['bootstrap', 'init']); + assert.deepEqual(claimStarter.aliases(), []); + assert.equal(findCommand(pool, 'claim-starter'), undefined); assert.equal(findCommand(pool, 'stake'), undefined); }); @@ -56,6 +62,43 @@ test('seller rewards provides the minimal aggregate reward surface', () => { const rewards = findCommand(sellerCommand(), 'rewards'); assert.ok(rewards); assert.ok(findCommand(rewards!, 'claim')); + assert.deepEqual(findCommand(rewards!, 'claim')!.options, []); +}); + +for (const args of [ + ['seller', 'unstake'], + ['seller', 'emissions', 'info'], + ['seller', 'pool', 'bootstrap'], + ['seller', 'pool', 'init'], + ['seller', 'pool', 'claim-starter'], + ['seller', 'pool', 'rewards'], + ['seller', 'pool', 'rewards', 'claim'], + ['seller', 'legacy', 'bootstrap'], + ['seller', 'legacy', 'init'], + ['seller', 'stake', '100', '--epochs', '4', '--agent-id', '7'], + ['seller', 'rewards', 'claim', '--position', '1'], + ['seller', 'rewards', 'claim', '--recipient', '0x0000000000000000000000000000000000000001'], + ['network', 'contracts'], +]) { + test(`removed command or option is rejected before execution: ${args.join(' ')}`, async () => { + const program = new Command().exitOverride().configureOutput({ writeErr: () => {} }); + registerSellerCommands(program); + registerNetworkCommands(program); + await assert.rejects(program.parseAsync(args, { from: 'user' }), (error: { code?: string }) => + error.code === 'commander.unknownCommand' || error.code === 'commander.unknownOption'); + }); +} + +test('buyer emissions and its era filters remain available', () => { + const program = new Command(); + registerBuyerCommands(program); + const buyer = findCommand(program, 'buyer')!; + const emissions = findCommand(buyer, 'emissions')!; + for (const command of ['info', 'claim']) { + const options = findCommand(emissions, command)!.options; + assert.ok(options.some((option) => option.long === '--legacy-only')); + assert.ok(options.some((option) => option.long === '--new-only')); + } }); test('pool withdrawal requires explicit slashing consent instead of force', () => { diff --git a/apps/cli/src/cli/commands/seller/stake.ts b/apps/cli/src/cli/commands/seller/stake.ts index 76cf91b7e..220041193 100644 --- a/apps/cli/src/cli/commands/seller/stake.ts +++ b/apps/cli/src/cli/commands/seller/stake.ts @@ -1,4 +1,4 @@ -import { Option, type Command } from 'commander'; +import type { Command } from 'commander'; import chalk from 'chalk'; import ora from 'ora'; import { getGlobalOptions } from '../types.js'; @@ -12,7 +12,7 @@ import { resolveCliContractStack, createLegacyStakingClient, } from '../../payment-utils.js'; -import { runPoolStake } from './pool.js'; +import { registerSellerStarterCommand, runPoolStake } from './pool.js'; type GlobalOptions = ReturnType; @@ -108,38 +108,28 @@ async function runLegacyUnstake(global: GlobalOptions): Promise { export function registerSellerStakeCommand(sellerCmd: Command): void { sellerCmd .command('stake ') - .description('Stake as a provider — ANTS after the recognized-usage upgrade, USDC before it') - .option('--epochs ', 'ANTS lock duration in epochs after the recognized-usage upgrade', (value) => Number(value)) - .addOption(new Option('--agent-id ', 'seller agent ID fallback').argParser(Number).hideHelp()) - .action(async (amount: string, options: { epochs?: number; agentId?: number }) => { + .description('Stake ANTS into your seller pool') + .option('--epochs ', 'required ANTS lock duration in epochs', (value) => Number(value)) + .action(async (amount: string, options: { epochs?: number }) => { const globalOpts = getGlobalOptions(sellerCmd); const config = await loadConfig(globalOpts.config); const stack = await resolveCliContractStack(config); - if (stack.mode === 'recognized-usage') { - if (options.epochs === undefined) { - console.error(chalk.red('ANTS pool staking requires a lock duration. Use: antseed seller stake --epochs ')); - process.exit(1); - } - await runPoolStake(globalOpts, amount, { epochs: options.epochs, agentId: options.agentId }); + if (stack.mode !== 'recognized-usage') { + console.error(chalk.red('ANTS staking is not available on this network. For legacy USDC staking, use: antseed seller legacy stake ')); + process.exitCode = 1; return; } - - if (options.epochs !== undefined) { - console.error(chalk.red('--epochs applies to ANTS pool staking, which is only available after the recognized-usage cutover.')); - process.exit(1); + if (options.epochs === undefined) { + console.error(chalk.red('ANTS pool staking requires a lock duration. Use: antseed seller stake --epochs ')); + process.exitCode = 1; + return; } - await runLegacyStake(globalOpts, amount, { agentId: options.agentId }); - }); - - sellerCmd - .command('unstake', { hidden: true }) - .description('Withdraw legacy USDC stake (alias of `antseed seller legacy unstake`)') - .action(async () => { - await runLegacyUnstake(getGlobalOptions(sellerCmd)); + await runPoolStake(globalOpts, amount, { epochs: options.epochs }); }); - const legacy = sellerCmd.command('legacy').description('Legacy USDC staking commands'); + const legacy = sellerCmd.command('legacy').description('Legacy seller stake and migration commands'); + registerSellerStarterCommand(legacy); legacy .command('stake ') diff --git a/apps/cli/src/cli/payment-utils.ts b/apps/cli/src/cli/payment-utils.ts index 2dae062ac..e84d80c72 100644 --- a/apps/cli/src/cli/payment-utils.ts +++ b/apps/cli/src/cli/payment-utils.ts @@ -17,15 +17,13 @@ import { PositionInitClient, EmissionsGateClient, ChannelStore, + ANTSTokenClient, + SellerPoolsClient, + SellerPoolsRewardsClient, + SellerRegistryClient, } from '@antseed/node/payments'; import type { Identity } from '@antseed/node'; import type { AntseedConfig } from '../config/types.js'; -import { - CliAntsTokenClient as ANTSTokenClient, - CliSellerPoolsClient as SellerPoolsClient, - CliSellerPoolsRewardsClient as SellerPoolsRewardsClient, - CliSellerRegistryClient as SellerRegistryClient, -} from './seller-contract-clients.js'; export const ANTSEED_BASE_RPC_URL_ENV = 'ANTSEED_BASE_RPC_URL'; diff --git a/apps/cli/src/cli/seller-contract-clients.ts b/apps/cli/src/cli/seller-contract-clients.ts deleted file mode 100644 index 1704ce9ea..000000000 --- a/apps/cli/src/cli/seller-contract-clients.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { Contract, Interface, zeroPadValue, ZeroAddress, type AbstractSigner, type Log } from 'ethers'; -import { ANTSTokenClient, SellerPoolsClient, SellerPoolsRewardsClient, SellerRegistryClient, type SellerPoolPosition } from '@antseed/node/payments'; -import { previewPositionReward, REWARD_INDEX_SCALE } from './reward-preview.js'; - -const POOLS_ABI = [ - 'event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)', - 'function earlyExitSlashBps(uint256 positionId) view returns (uint256)', - 'function positionPowerSegmentAt(uint256 positionId, uint256 epoch) view returns (uint256, uint256, uint256)', - 'function poolWeightAtEpoch(uint256 agentId, uint256 epoch) view returns (uint256)', - 'function currentEpoch() view returns (uint256)', - 'function positions(uint256 positionId) view returns (address, uint256, uint256, uint256, uint64, uint64, uint64, bool)', -]; -const REWARDS_ABI = [ - 'function sellerPools() view returns (address)', - 'function usageAccounting() view returns (address)', - 'function positionClaimCursor(uint256 positionId) view returns (uint256)', - 'function poolRewardIndexNextEpoch(uint256 agentId) view returns (uint256)', - 'function initialIndexEpoch() view returns (uint256)', - 'function poolCumulativeRewardPerWeightAt(uint256 agentId, uint256 epoch) view returns (uint256)', - 'function poolCumulativeEpochRewardPerWeightAt(uint256 agentId, uint256 epoch) view returns (uint256)', - 'function poolEpochEmissions(uint256 epoch, uint256 agentId) view returns (bool, uint256)', - 'function stakerEpochBudget(uint256 epoch) view returns (uint256)', - 'function indexPoolRewards(uint256 agentId, uint256 maxEpochs) returns (uint256)', -]; -const ACCOUNTING_ABI = [ - 'function weightedPoolPointsByEpoch(uint256 epoch, uint256 agentId) view returns (uint256)', - 'function totalWeightedPoolPointsByEpoch(uint256 epoch) view returns (uint256)', -]; - -export class CliSellerRegistryClient extends SellerRegistryClient { - async isRegisteredSeller(seller: string, agentId: number): Promise { - if (!agentId) return false; - const registry = new Contract(this.contractAddress, ['function agentSeller(uint256 agentId) view returns (address)'], this.provider); - const [resolvedId, boundSeller] = await Promise.all([this.getAgentId(seller), registry.getFunction('agentSeller')(agentId)]); - return resolvedId === agentId && boundSeller.toLowerCase() === seller.toLowerCase(); - } -} - -export async function registerSellerBinding( - registry: Pick, - wallet: AbstractSigner, - address: string, - agentId: number, - confirmed: (hash: string) => void, -): Promise { - const boundAgentId = await registry.getAgentId(address); - if (boundAgentId !== 0 && boundAgentId !== agentId) { - throw new Error(`Seller is already bound to agent ${boundAgentId}, not ${agentId}.`); - } - if (await registry.isRegisteredSeller(address, agentId)) return false; - confirmed(await registry.registerSeller(wallet, agentId)); - if (!await registry.isRegisteredSeller(address, agentId)) { - throw new Error('Registration could not be verified. Re-run: antseed seller register'); - } - return true; -} - -export async function requireSellerBinding( - registry: Pick, - address: string, - requestedAgentId?: number, -): Promise { - const agentId = await registry.getAgentId(address); - if (!agentId || !await registry.isRegisteredSeller(address, agentId)) { - throw new Error('Registration needs updating before you can stake. Run: antseed seller register. Your existing identity will be kept.'); - } - if (requestedAgentId !== undefined && requestedAgentId !== agentId) { - throw new Error(`Seller is bound to agent ${agentId}, not ${requestedAgentId}.`); - } - return agentId; -} - -export async function collectPositionIds(readPage: (offset: number, limit: number) => Promise): Promise { - const ids: number[] = []; - for (let offset = 0; ; offset += 256) { - const page = await readPage(offset, 256); - ids.push(...page); - if (page.length < 256) return ids; - } -} - -export class CliSellerPoolsClient extends SellerPoolsClient { - async earlyExitSlashBps(positionId: number): Promise { - const pools = new Contract(this.contractAddress, POOLS_ABI, this.provider); - return Number(await pools.getFunction('earlyExitSlashBps')(positionId)); - } - - allStakerPositionIds(staker: string): Promise { - return collectPositionIds((offset, limit) => this.stakerPositionIds(staker, offset, limit)); - } - - async rewardPositions(staker: string): Promise { - const ids = new Set(await this.allStakerPositionIds(staker)); - const tokenInterface = new Interface(POOLS_ABI); - const topics = tokenInterface.encodeFilterTopics('Transfer', [staker, ZeroAddress]); - const latest = await this.provider.getBlockNumber(); - let first = 0; - let last = latest; - while (first < last) { - const middle = Math.floor((first + last) / 2); - if (await this.provider.getCode(this.contractAddress, middle) === '0x') first = middle + 1; - else last = middle; - } - const ranges: Array<[number, number]> = [[first, latest]]; - let requests = 0; - while (ranges.length > 0) { - const [fromBlock, toBlock] = ranges.pop()!; - let logs: Log[]; - try { - if (++requests > 512) throw new Error('Historical position discovery exceeded the RPC request limit. Use an RPC with larger log ranges.'); - logs = await this.provider.getLogs({ address: this.contractAddress, topics, fromBlock, toBlock }); - } catch (error) { - if (requests > 512 || fromBlock === toBlock || !/range|too many|response.*(size|large)|limit exceeded/i.test(String(error))) throw error; - const middle = Math.floor((fromBlock + toBlock) / 2); - ranges.push([fromBlock, middle], [middle + 1, toBlock]); - continue; - } - for (const log of logs) { - const event = tokenInterface.parseLog(log); - if (event) ids.add(Number(event.args.tokenId)); - } - } - const positions: SellerPoolPosition[] = []; - const positionIds = [...ids]; - for (let offset = 0; offset < positionIds.length; offset += 16) { - const page = await Promise.all(positionIds.slice(offset, offset + 16).map((id) => this.position(id))); - positions.push(...page.filter((position) => position.owner.toLowerCase() === staker.toLowerCase())); - } - return positions; - } -} - -export class CliSellerPoolsRewardsClient extends SellerPoolsRewardsClient { - private previewBlock?: Promise; - private readonly previewReads = new Map>(); - - private read(contract: Contract, method: string, ...args: (number | bigint)[]): Promise { - const key = `${contract.target}:${method}:${args.join(',')}`; - let result = this.previewReads.get(key); - if (!result) { - this.previewBlock ??= this.provider.getBlockNumber(); - result = this.previewBlock.then((blockTag) => contract.getFunction(method)(...args, { blockTag })); - this.previewReads.set(key, result); - } - return result as Promise; - } - - async previewStakerReward(positionId: number): Promise { - const rewards = new Contract(this.contractAddress, REWARDS_ABI, this.provider); - const poolAddress = await this.read(rewards, 'sellerPools'); - const pools = new Contract(poolAddress, POOLS_ABI, this.provider); - const accounting = new Contract(await this.read(rewards, 'usageAccounting'), ACCOUNTING_ABI, this.provider); - const position = await this.read<[string, bigint, bigint, bigint, bigint, bigint, bigint, boolean]>(pools, 'positions', positionId); - if (position[0] === ZeroAddress) throw new Error(`Unknown position ${positionId}`); - return previewPositionReward({ - id: positionId, owner: position[0], agentId: Number(position[1]), amount: position[2], weightAmount: position[3], - stakeStartEpoch: Number(position[4]), stakeEndEpoch: Number(position[5]), closedAtEpoch: Number(position[6]), withdrawn: position[7], - }, { - currentEpoch: async () => Number(await this.read(pools, 'currentEpoch')), - claimCursor: async (id) => Number(await this.read(rewards, 'positionClaimCursor', id)), - indexCursor: async (agentId) => Number(await this.read(rewards, 'poolRewardIndexNextEpoch', agentId) || await this.read(rewards, 'initialIndexEpoch')), - segment: async (id, epoch) => { - const [normalEnd, maxLockPower, nextChange] = await this.read<[bigint, bigint, bigint]>(pools, 'positionPowerSegmentAt', id, epoch); - return { normalEnd, maxLockPower, nextChange }; - }, - cumulative: async (agentId, epoch) => ({ - reward: await this.read(rewards, 'poolCumulativeRewardPerWeightAt', agentId, epoch), - epochReward: await this.read(rewards, 'poolCumulativeEpochRewardPerWeightAt', agentId, epoch), - }), - rewardPerWeight: async (agentId, epoch) => { - const weight = await this.read(pools, 'poolWeightAtEpoch', agentId, epoch); - if (weight === 0n) return 0n; - const [settled, amount] = await this.read<[boolean, bigint]>(rewards, 'poolEpochEmissions', epoch, agentId); - if (settled) return amount * REWARD_INDEX_SCALE / weight; - const [points, total] = await Promise.all([ - this.read(accounting, 'weightedPoolPointsByEpoch', epoch, agentId), - this.read(accounting, 'totalWeightedPoolPointsByEpoch', epoch), - ]); - if (points === 0n || total === 0n) return 0n; - const budget = await this.read(rewards, 'stakerEpochBudget', epoch); - return (budget * points / total) * REWARD_INDEX_SCALE / weight; - }, - }); - } - - async poolRewardIndexNextEpoch(agentId: number): Promise { - return Number(await new Contract(this.contractAddress, REWARDS_ABI, this.provider).getFunction('poolRewardIndexNextEpoch')(agentId)); - } - async initialIndexEpoch(): Promise { - return Number(await new Contract(this.contractAddress, REWARDS_ABI, this.provider).getFunction('initialIndexEpoch')()); - } - indexPoolRewards(signer: AbstractSigner, agentId: number, maxEpochs: number): Promise { - return this._execWrite(signer, REWARDS_ABI, 'indexPoolRewards', agentId, maxEpochs); - } -} - -export class CliAntsTokenClient extends ANTSTokenClient { - async receivedInTransaction(transactionHash: string, recipient: string): Promise { - const receipt = await this.provider.getTransactionReceipt(transactionHash); - if (!receipt || receipt.status !== 1) throw new Error(`Confirmed receipt unavailable: ${transactionHash}`); - const tokenInterface = new Interface(['event Transfer(address indexed from, address indexed to, uint256 value)']); - const topics = tokenInterface.encodeFilterTopics('Transfer', [null, recipient]); - return receipt.logs.reduce((received, log) => { - if (log.address.toLowerCase() !== this.contractAddress.toLowerCase() || log.topics[0] !== topics[0] || log.topics[2]?.toLowerCase() !== zeroPadValue(recipient, 32).toLowerCase()) return received; - return received + (tokenInterface.parseLog(log)!.args.value as bigint); - }, 0n); - } -} diff --git a/apps/website/docs/cli/commands.md b/apps/website/docs/cli/commands.md index 3f4b3002e..46070bfe8 100644 --- a/apps/website/docs/cli/commands.md +++ b/apps/website/docs/cli/commands.md @@ -24,15 +24,14 @@ antseed seller start Start providing AI services antseed seller start --base-rpc-url Use a custom Base RPC URL for this run antseed seller register Register peer identity on-chain (ERC-8004) -antseed seller stake --epochs - Stake ANTS into your seller pool (post-cutover) +antseed seller stake --epochs + Stake ANTS only (requires the upgrade) antseed seller legacy stake Stake USDC as a provider (pre-cutover, min $10) antseed seller legacy unstake Withdraw legacy USDC stake -antseed seller pool bootstrap Claim the legacy-seller starter ANTS position +antseed seller legacy claim-starter Claim the legacy-seller starter ANTS position antseed seller pool positions List pool positions -antseed seller pool rewards [claim] View or claim indexed pool rewards -antseed seller pool withdraw Withdraw pool positions (`--force` for early exit) -antseed seller emissions claim Claim accumulated seller payouts +antseed seller pool withdraw Withdraw positions (`--accept-slashing` for early exit) +antseed seller rewards [claim] View or claim all seller rewards ``` ### Buying (consuming) @@ -60,8 +59,6 @@ antseed config Manage config file antseed peer View a peer's profile antseed profile Manage your peer profile antseed buyer channels List payment channels -antseed seller emissions info View epoch info and ANTS emissions -antseed network contracts Verify configured contracts against the registry antseed network bootstrap Run a dedicated DHT bootstrap node antseed buyer connection Manage connection settings antseed dev Run seller + buyer locally for testing diff --git a/apps/website/docs/guides/become-a-provider.md b/apps/website/docs/guides/become-a-provider.md index 068ef5070..fd1b4c41c 100644 --- a/apps/website/docs/guides/become-a-provider.md +++ b/apps/website/docs/guides/become-a-provider.md @@ -14,7 +14,7 @@ AntSeed is designed for providers who build differentiated services — such as ::: :::info Seller ANTS emissions -The CLI verifies the active contract stack through `AntseedRegistry`. On the legacy stack, seller rewards follow the existing emissions path. After the recognized-usage cutover, finalized legacy rewards remain claimable and new seller/operator plus pool-staker rewards use the recognized-usage contracts. +The CLI checks `AntseedRegistry` to determine whether the recognized-usage upgrade is active. Before the upgrade, seller rewards follow the existing emissions path. After it, finalized earlier rewards remain claimable and new seller/operator plus pool-staker rewards use the recognized-usage contracts. ::: ## Prerequisites @@ -213,7 +213,7 @@ antseed seller status # Register your identity on-chain (ERC-8004) antseed seller register -# Stake USDC (minimum $10, legacy stack) +# Stake USDC before the recognized-usage upgrade (minimum $10) antseed seller legacy stake 10 # Verify everything is ready @@ -224,12 +224,14 @@ On networks that have completed the recognized-usage cutover, use an ANTS seller ```bash antseed seller register -antseed seller pool bootstrap +antseed seller legacy claim-starter antseed seller stake 100 --epochs 4 antseed seller pool positions ``` -Pool stake activates after the contract's activation delay. The CLI reports positions as pending, active, matured, closed, or withdrawn and refuses an early withdrawal unless `--force` is supplied to acknowledge slashing. +`seller stake` always means ANTS; use `seller legacy stake` for USDC on networks that have not upgraded. The starter claim is only for eligible legacy sellers; new sellers register and stake ANTS without that step. + +Pool stake activates after the contract's activation delay. The CLI reports positions as pending, active, matured, closed, or withdrawn. For an early withdrawal, `--accept-slashing` first prints the estimated principal loss and then requires confirmation; add `--yes` only for non-interactive automation after reviewing that estimate. The rate can change before the transaction executes, so the estimate is not a guaranteed maximum loss. ## 7. Add Your Services diff --git a/apps/website/docs/guides/payments.md b/apps/website/docs/guides/payments.md index 2d036cd89..3ed63bdb3 100644 --- a/apps/website/docs/guides/payments.md +++ b/apps/website/docs/guides/payments.md @@ -121,18 +121,18 @@ Providers must stake a minimum of $10 USDC to participate: antseed seller legacy stake 10 ``` -When the configured network registry has moved to the recognized-usage stack, the CLI rejects new legacy USDC stakes and directs sellers to ANTS pools instead: +After the configured network completes the recognized-usage upgrade, the CLI rejects new legacy USDC stakes and directs sellers to ANTS positions instead: ```bash antseed seller register -antseed seller pool bootstrap +antseed seller legacy claim-starter antseed seller stake 100 --epochs 4 antseed seller pool positions ``` -The CLI verifies `AntseedRegistry.emissions()` and `staking()` before stack-aware commands. A mismatch between the registry and `payments.crypto` address overrides fails loudly instead of silently selecting another contract stack. `antseed network contracts` shows the active mode and pointer matches. +`seller stake` always stakes ANTS and never falls back to USDC. The CLI verifies `AntseedRegistry.emissions()` and `staking()` before commands that depend on the upgrade state. A mismatch between the registry and `payments.crypto` address overrides fails loudly instead of silently selecting the wrong contracts. -Staking binds your wallet to an on-chain agent identity (ERC-8004). To withdraw your stake: +After the upgrade, `antseed seller register` explicitly binds your wallet to its on-chain agent identity (ERC-8004); staking requires this registration and never performs it silently. To withdraw your legacy USDC stake: ```bash antseed seller legacy unstake @@ -149,10 +149,10 @@ Providers and buyers earn ANTS tokens based on eligible USDC volume. Emissions a Check your pending emissions: ```bash -antseed seller emissions info +antseed seller rewards ``` -After cutover, emissions commands include finalized rewards from both stacks. `--legacy-only` and `--new-only` restrict reads or claims when an operator wants to process the stacks separately. +After cutover, `seller rewards` includes finalized legacy, recognized-usage, and pool-staking rewards. Reading rewards does not send transactions; `seller rewards claim` collects all eligible seller rewards into the current wallet. Buyer emissions commands retain their `--legacy-only` and `--new-only` filters. ## Contract Addresses (Base Mainnet) diff --git a/e2e/tests/m001-cli.e2e.test.ts b/e2e/tests/m001-cli.e2e.test.ts index c950640d2..3f053f63b 100644 --- a/e2e/tests/m001-cli.e2e.test.ts +++ b/e2e/tests/m001-cli.e2e.test.ts @@ -5,6 +5,7 @@ import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { loadOrCreateIdentity } from '../../packages/node/src/p2p/identity.js'; +import { SellerPoolsRewardsClient } from '@antseed/node/payments'; const execFile = promisify(execFileCallback); const existingSandboxOut = process.env.M001_SANDBOX_OUT; @@ -21,21 +22,41 @@ describe.skipIf(!enabled)('M001 CLI sandbox', () => { let dataDir: string; let config: string; let walletAddress: string; + let rpcUrl: string; let ownsSandbox = false; async function run(command: string[], expectFailure = false): Promise { + let result; try { - const result = await execFile(process.execPath, [cli, '--config', config, '--data-dir', dataDir, ...command], { + result = await execFile(process.execPath, [cli, '--config', config, '--data-dir', dataDir, ...command], { cwd: root, env: process.env, }); - if (expectFailure) throw new Error(`Expected failure: ${command.join(' ')}`); - return `${result.stdout}${result.stderr}`; } catch (error) { if (!expectFailure) throw error; const failure = error as Error & { stdout?: string; stderr?: string }; return `${failure.stdout ?? ''}${failure.stderr ?? ''}`; } + if (expectFailure) throw new Error(`Expected failure: ${command.join(' ')}`); + return `${result.stdout}${result.stderr}`; + } + + async function rpc(method: string, params: unknown[] = []): Promise { + const response = await fetch(rpcUrl, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(30_000), + }); + const payload = await response.json() as { result: string; error?: { message: string } }; + if (payload.error) throw new Error(payload.error.message); + return payload.result; + } + + const nonce = () => rpc('eth_getTransactionCount', [walletAddress, 'latest']); + + async function contractUint(address: string, signature: string, args: string[] = []): Promise { + const result = await execFile('cast', ['call', address, signature, ...args, '--rpc-url', rpcUrl]); + return BigInt(result.stdout.trim().split(/\s/)[0]); } async function runJson(command: string[]): Promise { @@ -62,6 +83,11 @@ describe.skipIf(!enabled)('M001 CLI sandbox', () => { const port = new URL(process.env.M001_SANDBOX_RPC!).port || '8545'; await execFile('pnpm', ['m001:sandbox', 'up', '--port', port, '--out', out], { cwd: root, env: process.env }); } + const settings = JSON.parse(await readFile(config, 'utf8')); + rpcUrl = settings.payments.crypto.rpcUrl; + expect(rpcUrl).toBe(process.env.M001_SANDBOX_RPC); + expect(new URL(rpcUrl).hostname).toBe('127.0.0.1'); + expect(await rpc('web3_clientVersion')).toMatch(/^anvil\//); await sandbox(['fund-seller', walletAddress]); }, 900_000); @@ -73,38 +99,101 @@ describe.skipIf(!enabled)('M001 CLI sandbox', () => { await rm(dataDir, { recursive: true, force: true }); }); - it('rehearses legacy, cutover, pools, dual emissions, and mismatch rejection', async () => { + it('rehearses minimal commands, explicit legacy staking, rewards, withdrawal safety, and mismatch rejection', async () => { + const initialNonce = await nonce(); + for (const command of [ + ['seller', 'pool', 'bootstrap'], ['seller', 'pool', 'init'], ['seller', 'pool', 'claim-starter'], + ['seller', 'pool', 'rewards'], ['seller', 'pool', 'rewards', 'claim'], + ['seller', 'unstake'], ['seller', 'emissions', 'info'], ['network', 'contracts'], + ['seller', 'stake', '10', '--epochs', '4', '--agent-id', '1'], + ]) { + expect(await run(command, true)).toMatch(/unknown (command|option)/); + } + expect(await run(['seller', 'stake', '10'], true)).toContain('antseed seller legacy stake'); + expect(await run(['seller', 'stake', '10', '--epochs', '4'], true)).toContain('antseed seller legacy stake'); + expect(await nonce()).toBe(initialNonce); const registration = await run(['seller', 'register']); const agentId = registration.match(/Agent ID:\s*(\d+)/)?.[1]; expect(agentId).toBeTruthy(); await run(['seller', 'legacy', 'stake', '10', '--agent-id', agentId!]); - expect(await run(['seller', 'emissions', 'info'])).toContain('legacy'); - expect(await run(['seller', 'pool', 'bootstrap'], true)).toContain('recognized-usage'); - expect(await run(['network', 'contracts'])).toContain('✓'); + expect(await runJson(['seller', 'rewards', '--json'])).toMatchObject({ mode: 'legacy' }); + expect(await run(['seller', 'legacy', 'claim-starter'], true)).toContain('recognized-usage'); await sandbox(['cutover']); - expect(await run(['network', 'contracts'])).toContain('recognized-usage'); + expect(await runJson(['seller', 'rewards', '--json'])).toMatchObject({ mode: 'recognized-usage' }); + const beforeRejections = await nonce(); expect(await run(['seller', 'legacy', 'stake', '10'], true)).toContain('antseed seller stake'); expect(await run(['seller', 'stake', '10'], true)).toContain('--epochs'); + expect(await run(['seller', 'stake', '10', '--epochs', '4'], true)).toContain('antseed seller register'); + expect(await nonce()).toBe(beforeRejections); await sandbox(['fund-position-init', '5']); - await run(['seller', 'pool', 'bootstrap']); + await run(['seller', 'legacy', 'claim-starter']); expect(await run(['seller', 'pool', 'positions'])).toContain('pending'); await run(['seller', 'register', '--agent-id', agentId!]); + const registeredNonce = await nonce(); await run(['seller', 'register', '--agent-id', agentId!]); + expect(await nonce()).toBe(registeredNonce); await sandbox(['advance-epoch', '2']); await sandbox(['fund-ants', walletAddress, '100']); - await run(['seller', 'stake', '100', '--epochs', '4', '--agent-id', agentId!]); + await run(['seller', 'stake', '100', '--epochs', '4']); const positionsJson = await runJson(['seller', 'pool', 'positions', '--json']) as Array<{ id: number }>; expect(positionsJson.length).toBeGreaterThanOrEqual(2); - await run(['seller', 'pool', 'rewards', '--json']); - await run(['seller', 'pool', 'rewards', 'claim']); const newestId = String(positionsJson.at(-1).id); - expect(await run(['seller', 'pool', 'withdraw', newestId], true)).toContain('--force'); await sandbox(['advance-epoch', '1']); - await run(['seller', 'pool', 'withdraw', newestId, '--force']); + await run(['seller', 'legacy', 'unstake']); + expect(await runJson(['seller', 'status', '--json'])).toMatchObject({ onChain: { eligible: true, agentId: Number(agentId) }, onChainError: null }); + + const settings = JSON.parse(await readFile(config, 'utf8')).payments.crypto; + const tokenBalance = () => contractUint(settings.antsTokenAddress, 'balanceOf(address)(uint256)', [walletAddress]); + const indexCursor = () => contractUint(settings.sellerPoolsRewardsAddress, 'poolRewardIndexNextEpoch(uint256)(uint256)', [agentId!]); + const recordUsage = async () => { + const recorder = settings.channelsContractAddress; + await rpc('anvil_impersonateAccount', [recorder]); + await rpc('anvil_setBalance', [recorder, '0x3635C9ADC5DEA00000']); + try { + await execFile('cast', ['send', settings.usageAccountingAddress, + 'accruePoints(bytes32,address,address,uint256)', `0x${'11'.repeat(32)}`, + '0x000000000000000000000000000000000000bEEF', walletAddress, '1000000', + '--from', recorder, '--unlocked', '--rpc-url', rpcUrl]); + } finally { await rpc('anvil_stopImpersonatingAccount', [recorder]); } + }; + const expectedRewards = async () => { + const currentEpoch = Number(await contractUint(settings.usageAccountingAddress, 'currentEpoch()(uint256)')); + const epochs = `[${Array.from({ length: currentEpoch }, (_, epoch) => epoch).join(',')}]`; + const usage = await contractUint(settings.usageAccountingAddress, 'pendingEmissions(address,uint256[])(uint256,uint256)', [walletAddress, epochs]); + const poolRewards = new SellerPoolsRewardsClient({ rpcUrl, contractAddress: settings.sellerPoolsRewardsAddress, evmChainId: 8453 }); + const amounts = await Promise.all(positionsJson.map(position => poolRewards.previewStakerReward(position.id))); + return usage + amounts.reduce((total, amount) => total + amount, 0n); + }; + + await recordUsage(); + expect(await runJson(['seller', 'rewards', '--json'])).toMatchObject({ total: '0.0' }); + await sandbox(['advance-epoch', '1']); + const beforeRead = [await nonce(), await tokenBalance(), await indexCursor(), await rpc('eth_blockNumber')]; + const preview = await runJson(['seller', 'rewards', '--json']) as { legacy: string; recognizedUsage: string; pool: string }; + expect(preview.legacy).toBe('0.0'); + expect(Number(preview.recognizedUsage)).toBeGreaterThan(0); + expect(Number(preview.pool)).toBeGreaterThan(0); + expect([await nonce(), await tokenBalance(), await indexCursor(), await rpc('eth_blockNumber')]).toEqual(beforeRead); + const expected = await expectedRewards(); + const beforeClaim = await tokenBalance(); + expect(await run(['seller', 'rewards', 'claim'])).toContain('Claimed'); + expect(await tokenBalance() - beforeClaim).toBe(expected); + expect(await runJson(['seller', 'rewards', '--json'])).toMatchObject({ total: '0.0' }); + const beforeRepeat = await nonce(); + expect(await run(['seller', 'rewards', 'claim'])).toContain('No pending seller rewards'); + expect(await nonce()).toBe(beforeRepeat); + + await recordUsage(); + await sandbox(['advance-epoch', '1']); + const beforeWithdrawal = await nonce(); + expect(await run(['seller', 'pool', 'withdraw', newestId], true)).toContain('--accept-slashing'); + expect(await run(['seller', 'pool', 'withdraw', newestId, '--accept-slashing'], true)).toContain('--yes'); + expect(await nonce()).toBe(beforeWithdrawal); + await run(['seller', 'pool', 'withdraw', newestId, '--accept-slashing', '--yes']); - const sellerInfo = await runJson(['seller', 'emissions', 'info', '--json']) as { + const sellerInfo = await runJson(['seller', 'rewards', '--json']) as { mode: string; legacy: unknown; recognizedUsage: unknown; @@ -112,8 +201,14 @@ describe.skipIf(!enabled)('M001 CLI sandbox', () => { expect(sellerInfo.mode).toBe('recognized-usage'); expect(sellerInfo).toHaveProperty('legacy'); expect(sellerInfo).toHaveProperty('recognizedUsage'); - await run(['seller', 'emissions', 'claim', '--legacy-only']); - await run(['seller', 'emissions', 'claim', '--new-only']); + const rewards = await runJson(['seller', 'rewards', '--json']) as { total: string; poolPositions: Array<{ id: number; amount: string }> }; + expect(rewards).toHaveProperty('total'); + expect(Number(rewards.poolPositions.find(position => position.id === Number(newestId))?.amount)).toBeGreaterThan(0); + const withdrawnExpected = await expectedRewards(); + const beforeWithdrawnClaim = await tokenBalance(); + await run(['seller', 'rewards', 'claim']); + expect(await tokenBalance() - beforeWithdrawnClaim).toBe(withdrawnExpected); + expect(await runJson(['seller', 'rewards', '--json'])).toMatchObject({ total: '0.0' }); await run(['buyer', 'emissions', 'info']); const broken = JSON.parse(await readFile(config, 'utf8')); @@ -122,7 +217,10 @@ describe.skipIf(!enabled)('M001 CLI sandbox', () => { await writeFile(brokenConfig, JSON.stringify(broken, null, 2)); const original = config; config = brokenConfig; - expect(await run(['network', 'contracts'], true)).toContain('ContractStackMismatchError'); + const beforeMismatch = await nonce(); + expect(await run(['seller', 'rewards'], true)).toContain('Contract stack mismatch'); + expect(await run(['seller', 'rewards', 'claim'], true)).toContain('Contract stack mismatch'); + expect(await nonce()).toBe(beforeMismatch); config = original; }, 900_000); }); diff --git a/packages/contracts/script/migrations/M001RecognizedUsage/README.md b/packages/contracts/script/migrations/M001RecognizedUsage/README.md index a3be5e037..cabc10df9 100644 --- a/packages/contracts/script/migrations/M001RecognizedUsage/README.md +++ b/packages/contracts/script/migrations/M001RecognizedUsage/README.md @@ -145,15 +145,15 @@ pnpm m001:sandbox status --out .m001-sandbox The deploy command writes a copied deployment ledger and `.m001-sandbox/cli-config.json`. Point CLI commands at that file to verify legacy mode, then cut over and reuse the refreshed file: ```bash -antseed --config .m001-sandbox/cli-config.json --data-dir /tmp/antseed-m001 seller emissions info -antseed --config .m001-sandbox/cli-config.json --data-dir /tmp/antseed-m001 network contracts +antseed --config .m001-sandbox/cli-config.json --data-dir /tmp/antseed-m001 seller rewards pnpm m001:sandbox cutover --out .m001-sandbox pnpm m001:sandbox fund-position-init 5 --out .m001-sandbox pnpm m001:sandbox advance-epoch 2 --out .m001-sandbox pnpm m001:sandbox fund-ants 0xYourCliWallet 100 --out .m001-sandbox -antseed --config .m001-sandbox/cli-config.json --data-dir /tmp/antseed-m001 seller pool bootstrap +antseed --config .m001-sandbox/cli-config.json --data-dir /tmp/antseed-m001 seller register +antseed --config .m001-sandbox/cli-config.json --data-dir /tmp/antseed-m001 seller legacy claim-starter antseed --config .m001-sandbox/cli-config.json --data-dir /tmp/antseed-m001 seller stake 100 --epochs 4 antseed --config .m001-sandbox/cli-config.json --data-dir /tmp/antseed-m001 seller pool positions diff --git a/packages/node/README.md b/packages/node/README.md index 599aff229..5e6a6358a 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -278,6 +278,36 @@ Smart contract source and deployment notes: `node/contracts/README.md`. ## Key Exports +### Recognized-usage payment helpers + +Import these APIs from `@antseed/node/payments`: + +- `SellerRegistryClient.getRegisteredAgentId(seller)` returns the explicitly + bound identity, or `0` when only a legacy fallback exists. + `registerSellerBinding(signer, agentId, onConfirmed?)` binds and verifies it, + returning `false` without a transaction when already bound. The optional + callback receives the confirmed hash before verification; verification + failure throws `SellerRegistrationVerificationError`. +- `SellerPoolsClient.allStakerPositionIds(seller)` paginates active positions; + `rewardPositions(seller)` also discovers withdrawn positions from burn logs. + Historical discovery needs an RPC with historical code and log access. + `earlyExitSlashBps(id)` and `estimateEarlyExit(position, slashBps)` provide + an estimate, not a guaranteed maximum loss at transaction execution. +- `SellerPoolsRewardsClient.previewStakerRewards(positionIds)` returns ANTS + base-unit amounts in input order, including unindexed completed epochs. + Every call uses its own single-block snapshot and read cache; reuse of a + client does not preserve stale previews. `previewStakerReward(id)` is the + single-position equivalent. Neither method sends transactions. +- `previewPoolRewards`, `pendingEpochRewards`, `claimPoolRewards`, + `claimEpochRewards`, and `claimBuyerEpochRewards` provide shared discovery, + previews, and bounded claim orchestration. Pool claims index missing epochs + before claiming. Callbacks expose confirmed transactions without CLI output. +- `ANTSTokenClient.receivedInTransaction(hash, recipient)` sums incoming ANTS + transfers from a successful transaction receipt, rather than using estimates + as the claimed total. + +### Core API + ```ts // Main class import { AntseedNode, type NodeConfig } from '@antseed/node'; diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index a9157a856..50925497a 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -158,9 +158,10 @@ export { EmissionsClient, type EmissionsClientConfig, type EmissionsEpochParams export { RegistryClient, type RegistryClientConfig } from './payments/evm/registry-client.js'; export { UsageAccountingClient, type UsageAccountingClientConfig } from './payments/evm/usage-accounting-client.js'; export { UsageRewardsClient, type UsageRewardsClientConfig } from './payments/evm/usage-rewards-client.js'; -export { SellerPoolsClient, type SellerPoolsClientConfig, type SellerPoolPosition } from './payments/evm/seller-pools-client.js'; +export { SellerPoolsClient, estimateEarlyExit, type SellerPoolsClientConfig, type SellerPoolPosition, type EarlyExitEstimate } from './payments/evm/seller-pools-client.js'; export { SellerPoolsRewardsClient, type SellerPoolsRewardsClientConfig } from './payments/evm/seller-pools-rewards-client.js'; -export { SellerRegistryClient, type SellerRegistryClientConfig } from './payments/evm/seller-registry-client.js'; +export { SellerRegistryClient, SellerRegistrationVerificationError, type SellerRegistryClientConfig } from './payments/evm/seller-registry-client.js'; +export { pendingEpochRewards, claimEpochRewards, claimBuyerEpochRewards, previewPoolRewards, claimPoolRewards, type RewardTransactionRecorder } from './payments/reward-claims.js'; export { PositionInitClient, type PositionInitClientConfig } from './payments/evm/position-init-client.js'; export { EmissionsGateClient, type EmissionsGateClientConfig } from './payments/evm/emissions-gate-client.js'; export { diff --git a/packages/node/src/payments/early-exit.test.ts b/packages/node/src/payments/early-exit.test.ts new file mode 100644 index 000000000..467c65bba --- /dev/null +++ b/packages/node/src/payments/early-exit.test.ts @@ -0,0 +1,14 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { estimateEarlyExit } from './evm/seller-pools-client.js'; + +const position = { id: 1, owner: '0x1', agentId: 2, amount: 1n, weightAmount: 1n, stakeStartEpoch: 5, stakeEndEpoch: 9, closedAtEpoch: 0, withdrawn: false }; +test('estimateEarlyExit reports exact principal loss and return', () => { + assert.deepEqual(estimateEarlyExit({ ...position, amount: 100n * 10n ** 18n }, 2500), { + id: 1, + amount: 100n * 10n ** 18n, + slashBps: 2500, + slashedAmount: 25n * 10n ** 18n, + returnedAmount: 75n * 10n ** 18n, + }); +}); diff --git a/packages/node/src/payments/evm/ants-token-client.ts b/packages/node/src/payments/evm/ants-token-client.ts index d49fd1855..6c9a52116 100644 --- a/packages/node/src/payments/evm/ants-token-client.ts +++ b/packages/node/src/payments/evm/ants-token-client.ts @@ -1,4 +1,4 @@ -import { Contract, type AbstractSigner } from 'ethers'; +import { Contract, Interface, zeroPadValue, type AbstractSigner } from 'ethers'; import { BaseEvmClient } from './base-evm-client.js'; export interface ANTSTokenClientConfig { @@ -79,4 +79,14 @@ export class ANTSTokenClient extends BaseEvmClient { if (!receipt) throw new Error('Transaction was dropped or replaced'); return receipt.hash; } + async receivedInTransaction(transactionHash: string, recipient: string): Promise { + const receipt = await this.provider.getTransactionReceipt(transactionHash); + if (!receipt || receipt.status !== 1) throw new Error(`Confirmed receipt unavailable: ${transactionHash}`); + const tokenInterface = new Interface(['event Transfer(address indexed from, address indexed to, uint256 value)']); + const topics = tokenInterface.encodeFilterTopics('Transfer', [null, recipient]); + return receipt.logs.reduce((received, log) => { + if (log.address.toLowerCase() !== this.contractAddress.toLowerCase() || log.topics[0] !== topics[0] || log.topics[2]?.toLowerCase() !== zeroPadValue(recipient, 32).toLowerCase()) return received; + return received + (tokenInterface.parseLog(log)!.args.value as bigint); + }, 0n); + } } diff --git a/packages/node/src/payments/evm/seller-pools-client.ts b/packages/node/src/payments/evm/seller-pools-client.ts index 346b201fa..962662875 100644 --- a/packages/node/src/payments/evm/seller-pools-client.ts +++ b/packages/node/src/payments/evm/seller-pools-client.ts @@ -1,4 +1,4 @@ -import { Contract, type AbstractSigner } from 'ethers'; +import { Contract, Interface, ZeroAddress, type AbstractSigner, type Log } from 'ethers'; import { BaseEvmClient } from './base-evm-client.js'; export interface SellerPoolsClientConfig { @@ -22,11 +22,13 @@ export interface SellerPoolPosition { } const SELLER_POOLS_ABI = [ + 'event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)', 'function stake(uint256 agentId, uint256 amount, uint256 stakeEpochs) external returns (uint256 positionId)', 'function stakerPositionCount(address staker) external view returns (uint256)', 'function stakerPositionIds(address staker, uint256 offset, uint256 limit) external view returns (uint256[])', 'function positions(uint256 positionId) external view returns (address owner, uint256 agentId, uint256 amount, uint256 weightAmount, uint64 stakeStartEpoch, uint64 stakeEndEpoch, uint64 closedAtEpoch, bool withdrawn)', 'function positionWithdrawableEpoch(uint256 positionId) external view returns (uint64)', + 'function earlyExitSlashBps(uint256 positionId) external view returns (uint256)', 'function withdrawStakes(uint256[] positionIds) external returns (uint256 returnedAmount, uint256 slashedAmount)', 'function agentIdForSeller(address seller) external view returns (uint256)', 'function currentEpoch() external view returns (uint256)', @@ -57,6 +59,7 @@ export class SellerPoolsClient extends BaseEvmClient { return { id, owner: result[0], agentId: Number(result[1]), amount: result[2], weightAmount: result[3], stakeStartEpoch: Number(result[4]), stakeEndEpoch: Number(result[5]), closedAtEpoch: Number(result[6]), withdrawn: result[7] }; } async positionWithdrawableEpoch(id: number): Promise { return Number(await this.contract().getFunction('positionWithdrawableEpoch')(id)); } + async earlyExitSlashBps(id: number): Promise { return Number(await this.contract().getFunction('earlyExitSlashBps')(id)); } withdrawStakes(signer: AbstractSigner, ids: number[]): Promise { return this._execWrite(signer, SELLER_POOLS_ABI, 'withdrawStakes', ids); } async agentIdForSeller(seller: string): Promise { return Number(await this.contract().getFunction('agentIdForSeller')(seller)); } async currentEpoch(): Promise { return Number(await this.contract().getFunction('currentEpoch')()); } @@ -65,4 +68,75 @@ export class SellerPoolsClient extends BaseEvmClient { async maxStakeEpochs(): Promise { return Number(await this.contract().getFunction('MAX_STAKE_EPOCHS')()); } hasPoolAtEpoch(agentId: number, epoch: number): Promise { return this.contract().getFunction('hasPoolAtEpoch(uint256,uint256)')(agentId, epoch); } poolActiveStakeAtEpoch(agentId: number, epoch: number): Promise { return this.contract().getFunction('poolActiveStakeAtEpoch(uint256,uint256)')(agentId, epoch); } + allStakerPositionIds(staker: string): Promise { + return collectPositionIds((offset, limit) => this.stakerPositionIds(staker, offset, limit)); + } + + async rewardPositions(staker: string): Promise { + const ids = new Set(await this.allStakerPositionIds(staker)); + const tokenInterface = new Interface(SELLER_POOLS_ABI); + const topics = tokenInterface.encodeFilterTopics('Transfer', [staker, ZeroAddress]); + const latest = await this.provider.getBlockNumber(); + let first = 0; + let last = latest; + while (first < last) { + const middle = Math.floor((first + last) / 2); + if (await this.provider.getCode(this.contractAddress, middle) === '0x') first = middle + 1; + else last = middle; + } + const ranges: Array<[number, number]> = [[first, latest]]; + let requests = 0; + while (ranges.length > 0) { + const [fromBlock, toBlock] = ranges.pop()!; + let logs: Log[]; + try { + if (++requests > 512) throw new Error('Historical position discovery exceeded the RPC request limit. Use an RPC with larger log ranges.'); + logs = await this.provider.getLogs({ address: this.contractAddress, topics, fromBlock, toBlock }); + } catch (error) { + if (requests > 512 || fromBlock === toBlock || !/range|too many|response.*(size|large)|limit exceeded/i.test(String(error))) throw error; + const middle = Math.floor((fromBlock + toBlock) / 2); + ranges.push([fromBlock, middle], [middle + 1, toBlock]); + continue; + } + for (const log of logs) { + const event = tokenInterface.parseLog(log); + if (event) ids.add(Number(event.args.tokenId)); + } + } + const positions: SellerPoolPosition[] = []; + const positionIds = [...ids]; + for (let offset = 0; offset < positionIds.length; offset += 16) { + const page = await Promise.all(positionIds.slice(offset, offset + 16).map((id) => this.position(id))); + positions.push(...page.filter((position) => position.owner.toLowerCase() === staker.toLowerCase())); + } + return positions; + } +} + +async function collectPositionIds(readPage: (offset: number, limit: number) => Promise): Promise { + const ids: number[] = []; + for (let offset = 0; ; offset += 256) { + const page = await readPage(offset, 256); + ids.push(...page); + if (page.length < 256) return ids; + } +} + +export interface EarlyExitEstimate { + id: number; + amount: bigint; + slashBps: number; + slashedAmount: bigint; + returnedAmount: bigint; +} + +export function estimateEarlyExit(position: SellerPoolPosition, slashBps: number): EarlyExitEstimate { + const slashedAmount = position.amount * BigInt(slashBps) / 10_000n; + return { + id: position.id, + amount: position.amount, + slashBps, + slashedAmount, + returnedAmount: position.amount - slashedAmount, + }; } diff --git a/packages/node/src/payments/evm/seller-pools-rewards-client.ts b/packages/node/src/payments/evm/seller-pools-rewards-client.ts index ae5b5ab41..64e975673 100644 --- a/packages/node/src/payments/evm/seller-pools-rewards-client.ts +++ b/packages/node/src/payments/evm/seller-pools-rewards-client.ts @@ -1,15 +1,107 @@ -import { Contract, type AbstractSigner } from 'ethers'; +import { Contract, ZeroAddress, type AbstractSigner } from 'ethers'; import { BaseEvmClient } from './base-evm-client.js'; +import { previewPositionReward, REWARD_INDEX_SCALE } from '../reward-preview.js'; export interface SellerPoolsRewardsClientConfig { rpcUrl: string; fallbackRpcUrls?: string[]; contractAddress: string; evmChainId?: number; } const ABI = [ + 'function sellerPools() view returns (address)', + 'function usageAccounting() view returns (address)', + 'function positionClaimCursor(uint256 positionId) view returns (uint256)', + 'function poolRewardIndexNextEpoch(uint256 agentId) view returns (uint256)', + 'function initialIndexEpoch() view returns (uint256)', + 'function poolCumulativeRewardPerWeightAt(uint256 agentId, uint256 epoch) view returns (uint256)', + 'function poolCumulativeEpochRewardPerWeightAt(uint256 agentId, uint256 epoch) view returns (uint256)', + 'function poolEpochEmissions(uint256 epoch, uint256 agentId) view returns (bool, uint256)', + 'function stakerEpochBudget(uint256 epoch) view returns (uint256)', + 'function indexPoolRewards(uint256 agentId, uint256 maxEpochs) returns (uint256)', 'function pendingIndexedStakerReward(uint256 positionId) external view returns (uint256)', 'function claimStakerRewards(uint256 positionId, address recipient) external', 'function claimStakerRewardsBatch(uint256[] positionIds, address recipient) external', ] as const; +const POOLS_ABI = [ + 'function positionPowerSegmentAt(uint256 positionId, uint256 epoch) view returns (uint256, uint256, uint256)', + 'function poolWeightAtEpoch(uint256 agentId, uint256 epoch) view returns (uint256)', + 'function currentEpoch() view returns (uint256)', + 'function positions(uint256 positionId) view returns (address, uint256, uint256, uint256, uint64, uint64, uint64, bool)', +]; +const ACCOUNTING_ABI = [ + 'function weightedPoolPointsByEpoch(uint256 epoch, uint256 agentId) view returns (uint256)', + 'function totalWeightedPoolPointsByEpoch(uint256 epoch) view returns (uint256)', +]; + export class SellerPoolsRewardsClient extends BaseEvmClient { constructor(config: SellerPoolsRewardsClientConfig) { super(config.rpcUrl, config.contractAddress, config.fallbackRpcUrls, config.evmChainId); } pendingIndexedStakerReward(positionId: number): Promise { return new Contract(this._contractAddress, ABI, this._provider).getFunction('pendingIndexedStakerReward')(positionId); } claimStakerRewards(signer: AbstractSigner, positionId: number, recipient: string): Promise { return this._execWrite(signer, ABI, 'claimStakerRewards', positionId, recipient); } claimStakerRewardsBatch(signer: AbstractSigner, positionIds: number[], recipient: string): Promise { return this._execWrite(signer, ABI, 'claimStakerRewardsBatch', positionIds, recipient); } + async previewStakerReward(positionId: number): Promise { + return (await this.previewStakerRewards([positionId]))[0]!; + } + + async previewStakerRewards(positionIds: number[]): Promise { + if (positionIds.length === 0) return []; + const blockTag = await this.provider.getBlockNumber(); + const reads = new Map>(); + const read = (contract: Contract, method: string, ...args: (number | bigint)[]): Promise => { + const key = `${contract.target}:${method}:${args.join(',')}`; + let result = reads.get(key); + if (!result) { + result = contract.getFunction(method)(...args, { blockTag }); + reads.set(key, result!); + } + return result as Promise; + }; + const rewards = new Contract(this.contractAddress, ABI, this.provider); + const poolAddress = await read(rewards, 'sellerPools'); + const pools = new Contract(poolAddress, POOLS_ABI, this.provider); + const accounting = new Contract(await read(rewards, 'usageAccounting'), ACCOUNTING_ABI, this.provider); + const previewPosition = async (positionId: number): Promise => { + const position = await read<[string, bigint, bigint, bigint, bigint, bigint, bigint, boolean]>(pools, 'positions', positionId); + if (position[0] === ZeroAddress) throw new Error(`Unknown position ${positionId}`); + return previewPositionReward({ + id: positionId, owner: position[0], agentId: Number(position[1]), amount: position[2], weightAmount: position[3], + stakeStartEpoch: Number(position[4]), stakeEndEpoch: Number(position[5]), closedAtEpoch: Number(position[6]), withdrawn: position[7], + }, { + currentEpoch: async () => Number(await read(pools, 'currentEpoch')), + claimCursor: async (id) => Number(await read(rewards, 'positionClaimCursor', id)), + indexCursor: async (agentId) => Number(await read(rewards, 'poolRewardIndexNextEpoch', agentId) || await read(rewards, 'initialIndexEpoch')), + segment: async (id, epoch) => { + const [normalEnd, maxLockPower, nextChange] = await read<[bigint, bigint, bigint]>(pools, 'positionPowerSegmentAt', id, epoch); + return { normalEnd, maxLockPower, nextChange }; + }, + cumulative: async (agentId, epoch) => ({ + reward: await read(rewards, 'poolCumulativeRewardPerWeightAt', agentId, epoch), + epochReward: await read(rewards, 'poolCumulativeEpochRewardPerWeightAt', agentId, epoch), + }), + rewardPerWeight: async (agentId, epoch) => { + const weight = await read(pools, 'poolWeightAtEpoch', agentId, epoch); + if (weight === 0n) return 0n; + const [settled, amount] = await read<[boolean, bigint]>(rewards, 'poolEpochEmissions', epoch, agentId); + if (settled) return amount * REWARD_INDEX_SCALE / weight; + const [points, total] = await Promise.all([ + read(accounting, 'weightedPoolPointsByEpoch', epoch, agentId), + read(accounting, 'totalWeightedPoolPointsByEpoch', epoch), + ]); + if (points === 0n || total === 0n) return 0n; + const budget = await read(rewards, 'stakerEpochBudget', epoch); + return (budget * points / total) * REWARD_INDEX_SCALE / weight; + }, + }); + }; + const amounts: bigint[] = []; + for (let offset = 0; offset < positionIds.length; offset += 16) { + amounts.push(...await Promise.all(positionIds.slice(offset, offset + 16).map(previewPosition))); + } + return amounts; + } + + async poolRewardIndexNextEpoch(agentId: number): Promise { + return Number(await new Contract(this.contractAddress, ABI, this.provider).getFunction('poolRewardIndexNextEpoch')(agentId)); + } + async initialIndexEpoch(): Promise { + return Number(await new Contract(this.contractAddress, ABI, this.provider).getFunction('initialIndexEpoch')()); + } + indexPoolRewards(signer: AbstractSigner, agentId: number, maxEpochs: number): Promise { + return this._execWrite(signer, ABI, 'indexPoolRewards', agentId, maxEpochs); + } } diff --git a/packages/node/src/payments/evm/seller-registry-client.ts b/packages/node/src/payments/evm/seller-registry-client.ts index 76ff9a388..0f4cd3931 100644 --- a/packages/node/src/payments/evm/seller-registry-client.ts +++ b/packages/node/src/payments/evm/seller-registry-client.ts @@ -3,6 +3,7 @@ import { BaseEvmClient } from './base-evm-client.js'; export interface SellerRegistryClientConfig { rpcUrl: string; fallbackRpcUrls?: string[]; contractAddress: string; evmChainId?: number; } const ABI = [ + 'function agentSeller(uint256 agentId) external view returns (address)', 'function registerSeller(uint256 agentId) external', 'function getAgentId(address seller) external view returns (uint256)', 'function getStake(address seller) external view returns (uint256)', @@ -19,4 +20,36 @@ export class SellerRegistryClient extends BaseEvmClient { isStakedAboveMin(seller: string): Promise { return this.contract().getFunction('isStakedAboveMin')(seller); } minSellerPoolStake(): Promise { return this.contract().getFunction('minSellerPoolStake')(); } legacyStakeEligibilityEnabled(): Promise { return this.contract().getFunction('legacyStakeEligibilityEnabled')(); } + async isRegisteredSeller(seller: string, agentId: number): Promise { + if (!agentId) return false; + const registry = this.contract(); + const [resolvedId, boundSeller] = await Promise.all([this.getAgentId(seller), registry.getFunction('agentSeller')(agentId)]); + return resolvedId === agentId && boundSeller.toLowerCase() === seller.toLowerCase(); + } + + async getRegisteredAgentId(seller: string): Promise { + const agentId = await this.getAgentId(seller); + return await this.isRegisteredSeller(seller, agentId) ? agentId : 0; + } + + async registerSellerBinding(signer: AbstractSigner, agentId: number, confirmed: (hash: string) => void | Promise = () => {}): Promise { + const address = await signer.getAddress(); + const boundAgentId = await this.getAgentId(address); + if (boundAgentId !== 0 && boundAgentId !== agentId) { + throw new Error(`Seller is already bound to agent ${boundAgentId}, not ${agentId}.`); + } + if (await this.isRegisteredSeller(address, agentId)) return false; + await confirmed(await this.registerSeller(signer, agentId)); + if (!await this.isRegisteredSeller(address, agentId)) { + throw new SellerRegistrationVerificationError(); + } + return true; + } +} + +export class SellerRegistrationVerificationError extends Error { + constructor() { + super('Registration could not be verified.'); + this.name = 'SellerRegistrationVerificationError'; + } } diff --git a/packages/node/src/payments/index.ts b/packages/node/src/payments/index.ts index 1bb79c027..1345602db 100644 --- a/packages/node/src/payments/index.ts +++ b/packages/node/src/payments/index.ts @@ -90,12 +90,14 @@ export { UsageAccountingClient } from './evm/usage-accounting-client.js'; export type { UsageAccountingClientConfig } from './evm/usage-accounting-client.js'; export { UsageRewardsClient } from './evm/usage-rewards-client.js'; export type { UsageRewardsClientConfig } from './evm/usage-rewards-client.js'; -export { SellerPoolsClient } from './evm/seller-pools-client.js'; -export type { SellerPoolsClientConfig, SellerPoolPosition } from './evm/seller-pools-client.js'; +export { SellerPoolsClient, estimateEarlyExit } from './evm/seller-pools-client.js'; +export type { SellerPoolsClientConfig, SellerPoolPosition, EarlyExitEstimate } from './evm/seller-pools-client.js'; export { SellerPoolsRewardsClient } from './evm/seller-pools-rewards-client.js'; export type { SellerPoolsRewardsClientConfig } from './evm/seller-pools-rewards-client.js'; -export { SellerRegistryClient } from './evm/seller-registry-client.js'; +export { SellerRegistryClient, SellerRegistrationVerificationError } from './evm/seller-registry-client.js'; export type { SellerRegistryClientConfig } from './evm/seller-registry-client.js'; +export { pendingEpochRewards, claimEpochRewards, claimBuyerEpochRewards, previewPoolRewards, claimPoolRewards } from './reward-claims.js'; +export type { RewardTransactionRecorder } from './reward-claims.js'; export { PositionInitClient } from './evm/position-init-client.js'; export type { PositionInitClientConfig } from './evm/position-init-client.js'; export { EmissionsGateClient } from './evm/emissions-gate-client.js'; diff --git a/packages/node/src/payments/reward-claims.test.ts b/packages/node/src/payments/reward-claims.test.ts new file mode 100644 index 000000000..837b0bee7 --- /dev/null +++ b/packages/node/src/payments/reward-claims.test.ts @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { previewPositionReward, REWARD_INDEX_SCALE, type RewardPreviewReader } from './reward-preview.js'; +import { claimBuyerEpochRewards, claimEpochRewards, claimPoolRewards, previewPoolRewards } from './reward-claims.js'; +import type { SellerPoolPosition } from './evm/seller-pools-client.js'; +import type { AbstractSigner } from 'ethers'; + +const position: SellerPoolPosition = { + id: 1, owner: 'seller', agentId: 7, amount: 1n, weightAmount: 1n, + stakeStartEpoch: 1, stakeEndEpoch: 4, closedAtEpoch: 0, withdrawn: false, +}; + +function previewReader(indexedThrough: number, overrides: Partial = {}): RewardPreviewReader { + const rates = new Map([[1, REWARD_INDEX_SCALE / 5n], [2, 3n * REWARD_INDEX_SCALE / 10n]]); + return { + currentEpoch: async () => 3, + claimCursor: async () => 0, + indexCursor: async () => indexedThrough, + segment: async () => ({ normalEnd: 4n, maxLockPower: 0n, nextChange: 2n ** 256n - 1n }), + cumulative: async (_agentId, epoch) => { + let reward = 0n; + let epochReward = 0n; + for (const [rateEpoch, rate] of rates) { + if (rateEpoch < Math.min(epoch, indexedThrough)) { + reward += rate; + epochReward += rate * BigInt(rateEpoch); + } + } + return { reward, epochReward }; + }, + rewardPerWeight: async (_agentId, epoch) => { + assert.ok(epoch < 3, 'must not include the current epoch'); + return rates.get(epoch) ?? 0n; + }, + ...overrides, + }; +} + +test('read-only preview preserves payout rounding before, during, and after indexing', async () => { + for (const indexedThrough of [1, 2, 3]) { + assert.equal(await previewPositionReward(position, previewReader(indexedThrough)), 1n); + } +}); + +test('preview excludes claimed epochs and retains earned rewards after withdrawal', async () => { + const withdrawn = { ...position, withdrawn: true, closedAtEpoch: 3 }; + assert.equal(await previewPositionReward(withdrawn, previewReader(1)), 1n); + assert.equal(await previewPositionReward(withdrawn, previewReader(3, { claimCursor: async () => 3 })), 0n); + assert.equal(await previewPositionReward({ ...position, closedAtEpoch: 2 }, previewReader(1)), 0n); +}); + +test('preview uses separate floor rounding for extended and max-lock segments', async () => { + const reader = previewReader(1, { + segment: async (_id, epoch) => epoch === 1 + ? { normalEnd: 4n, maxLockPower: 0n, nextChange: 2n } + : { normalEnd: 0n, maxLockPower: 5n, nextChange: 99n }, + }); + assert.equal(await previewPositionReward(position, reader), 1n); + assert.equal(await previewPositionReward(position, previewReader(1, { + segment: async (_id, epoch) => ({ normalEnd: epoch === 1 ? 4n : 7n, maxLockPower: 0n, nextChange: epoch === 1 ? 2n : 99n }), + })), 1n); +}); + +test('buyer claims include unpaid epochs older than the last 104', async () => { + const claimed: number[] = []; + await claimBuyerEpochRewards(Array.from({ length: 106 }, (_, epoch) => epoch), + async (epoch) => epoch === 0 || epoch === 105 ? 5n : 0n, + async (epoch) => { claimed.push(epoch); return `tx-${epoch}`; }, async () => {}); + assert.deepEqual(claimed, [0, 105]); +}); + +test('epoch claims use bounded batches without discarding old epochs', async () => { + const batches: number[][] = []; + await claimEpochRewards(Array.from({ length: 105 }, (_, epoch) => epoch), async () => 1n, + async (epochs) => { batches.push(epochs); return 'tx'; }, async () => {}); + assert.deepEqual(batches.map((batch) => batch.length), [32, 32, 32, 9]); + assert.equal(batches.flat().length, 105); +}); + +function poolFixture(closedAtEpoch = 0) { + let cursor = 1; + let wasClaimed = false; + const writes: string[] = []; + const targetEpoch = closedAtEpoch || 35; + const pools = { rewardPositions: async () => [{ ...position, withdrawn: closedAtEpoch !== 0, closedAtEpoch }], position: async () => position, currentEpoch: async () => 35 }; + const rewards = { + previewStakerRewards: async (ids: number[]) => ids.map(() => wasClaimed ? 0n : 12n), + pendingIndexedStakerReward: async () => cursor === targetEpoch && !wasClaimed ? 12n : 0n, + poolRewardIndexNextEpoch: async () => cursor, + initialIndexEpoch: async () => 1, + indexPoolRewards: async (_wallet: AbstractSigner, _agentId: number, maxEpochs: number) => { + assert.ok(maxEpochs <= 16); + cursor += maxEpochs; + writes.push('index'); + return `index-${cursor}`; + }, + claimStakerRewardsBatch: async (_wallet: AbstractSigner, ids: number[]) => { + assert.equal(cursor, targetEpoch); + assert.deepEqual(ids, [1]); + writes.push('claim'); + wasClaimed = true; + return 'claim-confirmed'; + }, + }; + return { pools, rewards, writes }; +} + +test('view previews unindexed historical earnings without writing; claim prepares and pays once', async () => { + const { pools, rewards, writes } = poolFixture(); + const displayed = await previewPoolRewards(pools, rewards, 'seller'); + assert.equal(displayed[0]!.amount, 12n); + assert.deepEqual(writes, []); + const confirmed: string[] = []; + await claimPoolRewards(pools, rewards, {} as AbstractSigner, 'seller', 'seller', async (hash) => { confirmed.push(hash); }, () => {}); + assert.deepEqual(writes, ['index', 'index', 'index', 'claim']); + assert.equal(confirmed.at(-1), 'claim-confirmed'); + await claimPoolRewards(pools, rewards, {} as AbstractSigner, 'seller', 'seller', async () => {}, () => {}); + assert.equal(writes.length, 4); +}); + +test('claim stops if indexing fails instead of claiming an incomplete amount', async () => { + const { pools, rewards, writes } = poolFixture(); + rewards.indexPoolRewards = async () => { throw new Error('gas unavailable'); }; + await assert.rejects(claimPoolRewards(pools, rewards, {} as AbstractSigner, 'seller', 'seller', async () => {}, () => {}), /gas unavailable/); + assert.deepEqual(writes, []); +}); + +test('withdrawn positions only prepare accounting through their closing epoch', async () => { + const { pools, rewards, writes } = poolFixture(3); + await claimPoolRewards(pools, rewards, {} as AbstractSigner, 'seller', 'seller', async () => {}, () => {}); + assert.deepEqual(writes, ['index', 'claim']); +}); + +test('pool preview requests all position amounts in one snapshot operation', async () => { + const { pools, rewards } = poolFixture(); + pools.rewardPositions = async () => [position, { ...position, id: 2 }]; + let calls = 0; + rewards.previewStakerRewards = async (ids) => { + calls++; + assert.deepEqual(ids, [1, 2]); + return [12n, 24n]; + }; + const preview = await previewPoolRewards(pools, rewards, 'seller'); + assert.equal(calls, 1); + assert.deepEqual(preview.map((entry) => entry.amount), [12n, 24n]); +}); + +test('pool claims stop when indexing confirms without advancing the cursor', async () => { + const { pools, rewards, writes } = poolFixture(); + rewards.indexPoolRewards = async () => 'index-confirmed'; + const confirmed: string[] = []; + await assert.rejects(claimPoolRewards(pools, rewards, {} as AbstractSigner, 'seller', 'seller', async (hash) => { confirmed.push(hash); }, () => {}), /no progress/); + assert.deepEqual(confirmed, ['index-confirmed']); + assert.deepEqual(writes, []); +}); diff --git a/packages/node/src/payments/reward-claims.ts b/packages/node/src/payments/reward-claims.ts new file mode 100644 index 000000000..176058be3 --- /dev/null +++ b/packages/node/src/payments/reward-claims.ts @@ -0,0 +1,88 @@ +import type { SellerPoolsClient } from './evm/seller-pools-client.js'; +import type { SellerPoolsRewardsClient } from './evm/seller-pools-rewards-client.js'; + +export type RewardTransactionRecorder = (hash: string, kind: 'claim' | 'accounting') => Promise; + +export async function pendingEpochRewards( + epochs: number[], + readPending: (epochs: number[]) => Promise, +): Promise { + let amount = 0n; + for (let offset = 0; offset < epochs.length; offset += 32) { + amount += await readPending(epochs.slice(offset, offset + 32)); + } + return amount; +} + +export async function claimEpochRewards( + epochs: number[], + readPending: (epochs: number[]) => Promise, + claim: (epochs: number[]) => Promise, + record: RewardTransactionRecorder, +): Promise { + for (let offset = 0; offset < epochs.length; offset += 32) { + const batch = epochs.slice(offset, offset + 32); + if (await readPending(batch) > 0n) await record(await claim(batch), 'claim'); + } +} + +export async function claimBuyerEpochRewards( + epochs: number[], + readPending: (epoch: number) => Promise, + claim: (epoch: number) => Promise, + record: RewardTransactionRecorder, +): Promise { + for (const epoch of epochs) { + if (await readPending(epoch) > 0n) await record(await claim(epoch), 'claim'); + } +} + +type PoolReader = Pick; +type PoolRewards = Pick; +type RewardSigner = Parameters[0]; + +export async function previewPoolRewards(pools: PoolReader, rewards: PoolRewards, address: string, positionId?: number) { + const positions = positionId === undefined ? await pools.rewardPositions(address) : [await pools.position(positionId)]; + for (const position of positions) { + if (position.owner.toLowerCase() !== address.toLowerCase()) throw new Error(`Position ${position.id} is not owned by this wallet.`); + } + const amounts = await rewards.previewStakerRewards(positions.map((position) => position.id)); + return positions.map((position, index) => ({ id: position.id, agentId: position.agentId, amount: amounts[index]!, closedAtEpoch: position.closedAtEpoch })); +} + +export async function claimPoolRewards( + pools: PoolReader, + rewards: PoolRewards, + wallet: RewardSigner, + address: string, + recipient: string, + record: RewardTransactionRecorder, + preparing: () => void, + positionId?: number, +): Promise { + const pending = await previewPoolRewards(pools, rewards, address, positionId); + const rewardedPositions = pending.filter((position) => position.amount > 0n); + if (rewardedPositions.length === 0) return; + const currentEpoch = await pools.currentEpoch(); + for (const agentId of new Set(rewardedPositions.map((position) => position.agentId))) { + const targetEpoch = rewardedPositions.filter((position) => position.agentId === agentId) + .reduce((latest, position) => Math.max(latest, Math.min(currentEpoch, position.closedAtEpoch || currentEpoch)), 0); + let cursor = await rewards.poolRewardIndexNextEpoch(agentId) || await rewards.initialIndexEpoch(); + if (cursor < targetEpoch) preparing(); + while (cursor < targetEpoch) { + await record(await rewards.indexPoolRewards(wallet, agentId, Math.min(16, targetEpoch - cursor)), 'accounting'); + const next = await rewards.poolRewardIndexNextEpoch(agentId); + if (next <= cursor) throw new Error('Reward preparation made no progress. Retry the claim later.'); + cursor = next; + } + } + const ids: number[] = []; + for (const position of rewardedPositions) { + if (await rewards.pendingIndexedStakerReward(position.id) > 0n) ids.push(position.id); + } + for (let offset = 0; offset < ids.length; offset += 32) { + await record(await rewards.claimStakerRewardsBatch(wallet, ids.slice(offset, offset + 32), recipient), 'claim'); + } +} diff --git a/apps/cli/src/cli/reward-preview.ts b/packages/node/src/payments/reward-preview.ts similarity index 97% rename from apps/cli/src/cli/reward-preview.ts rename to packages/node/src/payments/reward-preview.ts index d04711d4f..2382a1ce4 100644 --- a/apps/cli/src/cli/reward-preview.ts +++ b/packages/node/src/payments/reward-preview.ts @@ -1,4 +1,4 @@ -import type { SellerPoolPosition } from '@antseed/node/payments'; +import type { SellerPoolPosition } from './evm/seller-pools-client.js'; export const REWARD_INDEX_SCALE = 10n ** 30n; diff --git a/packages/node/src/payments/seller-clients.test.ts b/packages/node/src/payments/seller-clients.test.ts new file mode 100644 index 000000000..8adb8facc --- /dev/null +++ b/packages/node/src/payments/seller-clients.test.ts @@ -0,0 +1,169 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { Interface, ZeroAddress, type AbstractSigner } from 'ethers'; +import { SellerPoolsClient } from './evm/seller-pools-client.js'; +import { SellerPoolsRewardsClient } from './evm/seller-pools-rewards-client.js'; +import { SellerRegistryClient } from './evm/seller-registry-client.js'; +import { ANTSTokenClient } from './evm/ants-token-client.js'; + +const address = '0x0000000000000000000000000000000000000011'; +const contractAddress = '0x0000000000000000000000000000000000000022'; +const config = { rpcUrl: 'http://127.0.0.1:1', contractAddress, evmChainId: 31337 }; + +test('seller pools client reads the contract slashing estimate', async () => { + const client = new SellerPoolsClient({ ...config, antsTokenAddress: contractAddress }); + const abi = new Interface(['function earlyExitSlashBps(uint256) view returns (uint256)']); + Object.defineProperty(client, '_provider', { value: { + call: async (transaction: { data: string }) => { + const call = abi.parseTransaction(transaction)!; + assert.equal(call.name, 'earlyExitSlashBps'); + assert.equal(call.args[0], 7n); + return abi.encodeFunctionResult(call.name, [2500n]); + }, + } }); + assert.ok(Object.hasOwn(SellerPoolsClient.prototype, 'earlyExitSlashBps')); + assert.equal(await client.earlyExitSlashBps(7), 2500); +}); + +test('registration distinguishes legacy fallback, persists explicitly, and is idempotent', async () => { + let legacy = true; + let registered = false; + let writes = 0; + const registry = Object.assign(new SellerRegistryClient(config), { + getAgentId: async () => legacy || registered ? 7 : 0, + isRegisteredSeller: async () => registered, + registerSeller: async () => { writes++; registered = true; return 'confirmed'; }, + }); + assert.equal(await registry.getRegisteredAgentId(address), 0); + assert.equal(writes, 0); + const hashes: string[] = []; + assert.equal(await registry.registerSellerBinding({ getAddress: async () => address } as AbstractSigner, 7, (hash) => { hashes.push(hash); }), true); + legacy = false; + assert.equal(await registry.getRegisteredAgentId(address), 7); + assert.equal(await registry.registerSellerBinding({ getAddress: async () => address } as AbstractSigner, 7, () => {}), false); + assert.equal(writes, 1); + assert.deepEqual(hashes, ['confirmed']); +}); + +test('registration reports a confirmed transaction before verification failure', async () => { + const hashes: string[] = []; + const registry = Object.assign(new SellerRegistryClient(config), { getAgentId: async () => 7, isRegisteredSeller: async () => false, registerSeller: async () => 'confirmed' }); + await assert.rejects(registry.registerSellerBinding({ getAddress: async () => address } as AbstractSigner, 7, (hash) => { hashes.push(hash); }), /could not be verified/); + assert.deepEqual(hashes, ['confirmed']); +}); + +test('explicit binding reads the existing agentSeller getter, not just getAgentId', async () => { + const client = new SellerRegistryClient(config); + const abi = new Interface(['function agentSeller(uint256 agentId) view returns (address)']); + client.getAgentId = async () => 7; + let bound = ZeroAddress; + Object.defineProperty(client, '_provider', { value: { call: async () => abi.encodeFunctionResult('agentSeller', [bound]) } }); + assert.equal(await client.isRegisteredSeller(address, 7), false); + bound = address; + assert.equal(await client.isRegisteredSeller(address, 7), true); +}); + +test('registration rejects a conflicting identity before submitting a transaction', async () => { + const registry = new SellerRegistryClient(config); + registry.getAgentId = async () => 7; + registry.registerSeller = async () => { throw new Error('unexpected transaction'); }; + await assert.rejects(registry.registerSellerBinding({ getAddress: async () => address } as AbstractSigner, 8), /already bound to agent 7/); +}); + +test('empty reward previews do not contact the RPC', async () => { + const client = new SellerPoolsRewardsClient(config); + Object.defineProperty(client, '_provider', { value: { + getBlockNumber: async () => { throw new Error('unexpected RPC request'); }, + } }); + assert.deepEqual(await client.previewStakerRewards([]), []); +}); + +test('position pagination includes every page', async () => { + const ids = Array.from({ length: 513 }, (_, index) => index + 1); + const client = new SellerPoolsClient({ ...config, antsTokenAddress: contractAddress }); + client.stakerPositionIds = async (_address, offset = 0, limit = 256) => ids.slice(offset, offset + limit); + assert.deepEqual(await client.allStakerPositionIds(address), ids); +}); + +test('historical reward discovery includes burned positions and filters old owners', async () => { + const client = new SellerPoolsClient({ ...config, antsTokenAddress: contractAddress }); + const active = Array.from({ length: 300 }, (_, index) => index + 1); + client.stakerPositionIds = async (_staker, offset = 0, limit = 256) => active.slice(offset, offset + limit); + client.position = async (id) => ({ id, owner: id === 2 ? contractAddress : address, agentId: 7, amount: 1n, weightAmount: 1n, stakeStartEpoch: 1, stakeEndEpoch: 4, closedAtEpoch: id === 301 ? 3 : 0, withdrawn: id === 301 }); + const abi = new Interface(['event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)']); + const log = abi.encodeEventLog(abi.getEvent('Transfer')!, [address, ZeroAddress, 301]); + Object.defineProperty(client, '_provider', { value: { + getBlockNumber: async () => 16, + getCode: async (_target: string, block: number) => block < 4 ? '0x' : '0x6000', + getLogs: async (filter: { fromBlock: number }) => { assert.equal(filter.fromBlock, 4); return [{ ...log, address: contractAddress }]; }, + } }); + const positions = await client.rewardPositions(address); + assert.equal(positions.length, 300); + assert.equal(positions.at(-1)!.id, 301); + assert.ok(!positions.some((position) => position.id === 2)); +}); + +test('preview uses only existing view selectors at one block, including unindexed epochs', async () => { + const client = new SellerPoolsRewardsClient(config); + const abi = new Interface([ + 'function sellerPools() view returns (address)', 'function usageAccounting() view returns (address)', + 'function positions(uint256) view returns (address,uint256,uint256,uint256,uint64,uint64,uint64,bool)', + 'function currentEpoch() view returns (uint256)', 'function positionClaimCursor(uint256) view returns (uint256)', + 'function poolRewardIndexNextEpoch(uint256) view returns (uint256)', 'function initialIndexEpoch() view returns (uint256)', + 'function positionPowerSegmentAt(uint256,uint256) view returns (uint256,uint256,uint256)', + 'function poolCumulativeRewardPerWeightAt(uint256,uint256) view returns (uint256)', + 'function poolCumulativeEpochRewardPerWeightAt(uint256,uint256) view returns (uint256)', + 'function poolWeightAtEpoch(uint256,uint256) view returns (uint256)', 'function poolEpochEmissions(uint256,uint256) view returns (bool,uint256)', + 'function weightedPoolPointsByEpoch(uint256,uint256) view returns (uint256)', 'function totalWeightedPoolPointsByEpoch(uint256) view returns (uint256)', + 'function stakerEpochBudget(uint256) view returns (uint256)', + ]); + let reads = 0; + let block = 123; + Object.defineProperty(client, '_provider', { value: { + getBlockNumber: async () => block, + call: async (transaction: { data: string; blockTag: number }) => { + assert.equal(transaction.blockTag, block); + reads++; + const call = abi.parseTransaction(transaction)!; + let result: unknown[]; + switch (call.name) { + case 'sellerPools': case 'usageAccounting': result = [contractAddress]; break; + case 'positions': result = [address, 7, 3, 3, 1, 4, 0, false]; break; + case 'currentEpoch': result = [block === 123 ? 3 : 2]; break; + case 'initialIndexEpoch': result = [1]; break; + case 'positionPowerSegmentAt': result = [4, 0, 100]; break; + case 'poolWeightAtEpoch': result = [call.args[1] === 1n ? 10 : 9]; break; + case 'poolEpochEmissions': result = [false, 0]; break; + case 'weightedPoolPointsByEpoch': case 'totalWeightedPoolPointsByEpoch': result = [1]; break; + case 'stakerEpochBudget': result = [call.args[0] === 1n ? 100 : 101]; break; + default: result = [0]; + } + return abi.encodeFunctionResult(call.name, result); + }, + } }); + assert.equal(await client.previewStakerReward(1), 157n); + const firstReads = reads; + reads = 0; + assert.deepEqual(await client.previewStakerRewards([1, 1]), [157n, 157n]); + assert.equal(reads, firstReads); + block = 124; + assert.equal(await client.previewStakerReward(1), 90n); + assert.ok(reads > firstReads); +}); + +test('confirmed reward totals count only actual incoming ANTS transfers', async () => { + const client = new ANTSTokenClient(config); + const abi = new Interface(['event Transfer(address indexed from, address indexed to, uint256 value)']); + const transfer = (target: string, recipient: string, amount: bigint) => ({ address: target, ...abi.encodeEventLog(abi.getEvent('Transfer')!, [ZeroAddress, recipient, amount]) }); + Object.defineProperty(client, '_provider', { value: { getTransactionReceipt: async () => ({ status: 1, logs: [transfer(contractAddress, address, 12n), transfer(address, address, 99n), transfer(contractAddress, contractAddress, 30n)] }) } }); + assert.equal(await client.receivedInTransaction('confirmed', address), 12n); +}); + +test('reward receipt reads reject unavailable and failed transactions', async () => { + const client = new ANTSTokenClient(config); + let receipt: { status: number; logs: never[] } | null = null; + Object.defineProperty(client, '_provider', { value: { getTransactionReceipt: async () => receipt } }); + await assert.rejects(client.receivedInTransaction('missing', address), /receipt unavailable/); + receipt = { status: 0, logs: [] }; + await assert.rejects(client.receivedInTransaction('reverted', address), /receipt unavailable/); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc1d163a2..44ec67a76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,9 +49,6 @@ importers: dotenv: specifier: ^16.6.1 version: 16.6.1 - ethers: - specifier: ~6.16.0 - version: 6.16.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) open: specifier: ^11.0.0 version: 11.0.0