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..b8e47d992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ This project uses selective package publishing. Each release entry lists the pub ### Added +- 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 f2f484127..c23304b56 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -13,9 +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 emissions claim` | Claim accumulated seller payouts | +| `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 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 | | **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 | @@ -40,7 +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 dev` | Run seller + buyer locally for testing | | `antseed network bootstrap` | Run a dedicated DHT bootstrap node | @@ -341,12 +344,69 @@ 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 legacy claim-starter +antseed seller stake 100 --epochs 4 +antseed seller pool positions +antseed seller rewards +antseed seller rewards claim +``` + +`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 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. + +### 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 @@ -419,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/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..3c00bd1a8 100644 --- a/apps/cli/src/cli/commands/emissions.ts +++ b/apps/cli/src/cli/commands/emissions.ts @@ -5,9 +5,18 @@ import { getGlobalOptions } from './types.js'; import { loadConfig } from '../../config/loader.js'; import { createEmissionsClient, + createAntsTokenClient, + createLegacyEmissionsClient, + createSellerPoolsClient, + createUsageAccountingClient, + createUsageRewardsClient, loadCryptoContext, formatAnts, + resolveCliContractStack, } from '../payment-utils.js'; +import { legacyEpochs, newEpochs } from '@antseed/node/payments'; +import { claimBuyerEpochRewards, claimEpochRewards, pendingEpochRewards } from '@antseed/node/payments'; +import { RewardClaimProgress } from './reward-actions.js'; export type EmissionsRole = 'seller' | 'buyer'; @@ -24,6 +33,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'; } @@ -34,46 +51,94 @@ 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 rewards earned before the recognized-usage upgrade', 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 && (stack.mode === 'legacy' || stack.addresses.legacyEmissionsContractAddress)) { + const client = stack.mode === 'legacy' ? createEmissionsClient(config) : createLegacyEmissionsClient(config); + const [epochInfo, pending] = await Promise.all([ + client.getEpochInfo(), + pendingEpochRewards(legacyIds, async (epochs) => claimablePendingForRole(await client.pendingEmissions(address, epochs), role)), + ]); + emissionRate = epochInfo.emission; + epochDuration = epochInfo.epochDuration; + legacyPending = pending; + } + + let noCurrentPool = false; + if (selected.recognized && stack.mode === 'recognized-usage') { + if (role === 'seller') { + const usage = createUsageAccountingClient(config); + 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); + 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); + }); + } + } 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,37 +148,63 @@ export function registerEmissionsCommand(parentCmd: Command, role: EmissionsRole emissions .command('claim') .description(`Claim pending ${role} ANTS emissions`) - .action(async () => { + .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); 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(); + let progress: RewardClaimProgress | undefined; 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!) + : []; + const token = createAntsTokenClient(config); + progress = new RewardClaimProgress( + (hash) => console.log(chalk.dim(`Claim transaction confirmed: ${hash}`)), + (hash) => token.receivedInTransaction(hash, address), + ); + + if (selected.legacy && (stack.mode === 'legacy' || stack.addresses.legacyEmissionsContractAddress)) { + const client = stack.mode === 'legacy' ? createEmissionsClient(config) : createLegacyEmissionsClient(config); + 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); } - 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); + await claimEpochRewards(recognizedIds, + async (epochs) => (await usage.pendingEmissions(address, epochs)).seller, + (epochs) => usage.claimSellerEmissions(wallet, epochs), progress.record); + } else { + const rewards = createUsageRewardsClient(config); + await claimBuyerEpochRewards(recognizedIds, + async (epoch) => await rewards.buyerEpochClaimed(address, epoch) ? 0n : rewards.pendingBuyerReward(address, epoch), + (epoch) => rewards.claimBuyerReward(wallet, address, epoch), progress.record); + } + } - const txHash = role === 'seller' - ? await emissionsClient.claimSellerEmissions(wallet, epochs) - : await emissionsClient.claimBuyerEmissions(wallet, address, epochs); + if (progress.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(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/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/reward-actions.ts b/apps/cli/src/cli/commands/reward-actions.ts new file mode 100644 index 000000000..458c1aae5 --- /dev/null +++ b/apps/cli/src/cli/commands/reward-actions.ts @@ -0,0 +1,22 @@ +import type { RewardTransactionRecorder } from '@antseed/node/payments'; + +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..46c80e4b3 --- /dev/null +++ b/apps/cli/src/cli/commands/seller/contract-clients.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { requireCryptoConfig } from '../../payment-utils.js'; +import type { AntseedConfig } from '../../../config/types.js'; + +const address = '0x0000000000000000000000000000000000000011'; + +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/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 20e291a2a..75f0baad9 100644 --- a/apps/cli/src/cli/commands/seller/index.ts +++ b/apps/cli/src/cli/commands/seller/index.ts @@ -4,8 +4,9 @@ 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'; export function registerSellerCommands(program: Command): void { const sellerCmd = program @@ -17,6 +18,7 @@ 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 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..195c01306 --- /dev/null +++ b/apps/cli/src/cli/commands/seller/pool.ts @@ -0,0 +1,207 @@ +import type { Command } from 'commander'; +import { createInterface } from 'node:readline/promises'; +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, + createSellerRegistryClient, + formatAnts, + formatAntsExact, + loadCryptoContext, + parseAntsToBaseUnits, + resolveCliContractStack, +} from '../../payment-utils.js'; +import { estimateEarlyExit, 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 are available after the recognized-usage upgrade.'); + } + return stack; +} + +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 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 { + 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; + } + }); +} + +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); + 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.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) }; + })); + 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('--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.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) { + spinner.fail(chalk.red((error as Error).message)); + process.exitCode = 1; + } + }); +} + +export interface PoolStakeOptions { + epochs: number; +} + +/** Stake ANTS into a seller pool through the primary `seller stake` command. */ +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); + 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)}.`); + 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..31dcd3457 100644 --- a/apps/cli/src/cli/commands/seller/register.ts +++ b/apps/cli/src/cli/commands/seller/register.ts @@ -3,9 +3,14 @@ import chalk from 'chalk'; import ora from 'ora'; import { getGlobalOptions } from '../types.js'; import { loadConfig } from '../../../config/loader.js'; +import { SellerRegistrationVerificationError } from '@antseed/node/payments'; import { createIdentityClient, loadCryptoContext, + resolveCliContractStack, + createSellerRegistryClient, + createLegacyStakingClient, + createStakingClient, } from '../../payment-utils.js'; export function registerSellerRegisterCommand(sellerCmd: Command): void { @@ -13,6 +18,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,21 +27,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 && stack.addresses.legacyStakingContractAddress) 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); + spinner.start('Checking seller registration...'); + 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')); + } else { + 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}`)); + 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 new file mode 100644 index 000000000..a929ac468 --- /dev/null +++ b/apps/cli/src/cli/commands/seller/rewards.test.ts @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { totalSellerRewards } from './rewards.js'; +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('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/); +}); 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..8253531ab --- /dev/null +++ b/apps/cli/src/cli/commands/seller/rewards.ts @@ -0,0 +1,135 @@ +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 } from '@antseed/node/payments'; +import { 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/setup.ts b/apps/cli/src/cli/commands/seller/setup.ts index 57ef74827..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 USDC: ${chalk.dim('antseed seller stake 10')}`); + 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 new file mode 100644 index 000000000..7d4456f33 --- /dev/null +++ b/apps/cli/src/cli/commands/seller/stake.test.ts @@ -0,0 +1,110 @@ +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(); + 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 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')); + 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', () => { + const legacy = findCommand(sellerCommand(), 'legacy'); + assert.ok(legacy); + assert.ok(findCommand(legacy!, 'stake')); + assert.ok(findCommand(legacy!, 'unstake')); +}); + +test('seller unstake is only available under legacy', () => { + assert.equal(findCommand(sellerCommand(), 'unstake'), undefined); +}); + +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.equal(findCommand(seller, 'emissions'), undefined); + assert.equal(findCommand(pool, 'rewards'), undefined); + assert.doesNotMatch(pool.helpInformation(), /\n\s+rewards/); +}); + +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(), []); + assert.equal(findCommand(pool, 'claim-starter'), undefined); + 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')); + 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', () => { + const pool = findCommand(sellerCommand(), 'pool')!; + 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 6eeaf92a7..220041193 100644 --- a/apps/cli/src/cli/commands/seller/stake.ts +++ b/apps/cli/src/cli/commands/seller/stake.ts @@ -9,90 +9,140 @@ import { loadCryptoContext, formatUsdc, parseUsdcToBaseUnits, + resolveCliContractStack, + createLegacyStakingClient, } from '../../payment-utils.js'; +import { registerSellerStarterCommand, 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('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); + 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 eligibility until your ANTS position becomes 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 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); - let amountBaseUnits: bigint; - try { - amountBaseUnits = parseUsdcToBaseUnits(amount); - } catch { - console.error(chalk.red('Error: Amount must be a positive number.')); - process.exit(1); + 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; } - - 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.')); - 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); + 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 runPoolStake(globalOpts, amount, { epochs: options.epochs }); }); - sellerCmd + const legacy = sellerCmd.command('legacy').description('Legacy seller stake and migration commands'); + registerSellerStarterCommand(legacy); + + legacy + .command('stake ') + .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); + }); + + legacy .command('unstake') - .description('Unstake USDC (subject to slash conditions)') + .description('Withdraw legacy USDC stake (subject to slash conditions)') .action(async () => { - const globalOpts = getGlobalOptions(sellerCmd); - const config = await loadConfig(globalOpts.config); - - const spinner = ora('Fetching stake info...').start(); - - 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`)); - - 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); - } + 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..d0756af87 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([ + stack.addresses.legacyStakingContractAddress ? createLegacyStakingClient(config).getStake(walletAddress) : 0n, + 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..e84d80c72 100644 --- a/apps/cli/src/cli/payment-utils.ts +++ b/apps/cli/src/cli/payment-utils.ts @@ -6,11 +6,21 @@ import { DepositRelayClient, loadOrCreateIdentity, resolveChainConfig, + resolveContractStack, + type ContractStackResolution, } from '@antseed/node'; import { IdentityClient, EmissionsClient, + UsageAccountingClient, + UsageRewardsClient, + PositionInitClient, + EmissionsGateClient, ChannelStore, + ANTSTokenClient, + SellerPoolsClient, + SellerPoolsRewardsClient, + SellerRegistryClient, } from '@antseed/node/payments'; import type { Identity } from '@antseed/node'; import type { AntseedConfig } from '../config/types.js'; @@ -71,6 +81,23 @@ 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}))?$/); + 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 +133,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 +193,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 || (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, + 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 +313,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..46070bfe8 100644 --- a/apps/website/docs/cli/commands.md +++ b/apps/website/docs/cli/commands.md @@ -24,9 +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 emissions claim Claim accumulated seller payouts +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 legacy claim-starter Claim the legacy-seller starter ANTS position +antseed seller pool positions List pool positions +antseed seller pool withdraw Withdraw positions (`--accept-slashing` for early exit) +antseed seller rewards [claim] View or claim all seller rewards ``` ### Buying (consuming) @@ -54,7 +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 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..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 -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 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,13 +213,26 @@ antseed seller status # Register your identity on-chain (ERC-8004) antseed seller register -# Stake USDC (minimum $10) -antseed seller stake 10 +# Stake USDC before the recognized-usage upgrade (minimum $10) +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 legacy claim-starter +antseed seller stake 100 --epochs 4 +antseed seller pool positions +``` + +`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 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 +355,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..3ed63bdb3 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 ``` -Staking binds your wallet to an on-chain agent identity (ERC-8004). To withdraw your stake: +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 unstake +antseed seller register +antseed seller legacy claim-starter +antseed seller stake 100 --epochs 4 +antseed seller pool positions +``` + +`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. + +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 ``` ### ANTS Token Emissions @@ -138,9 +149,11 @@ 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, `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) | 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..3f053f63b --- /dev/null +++ b/e2e/tests/m001-cli.e2e.test.ts @@ -0,0 +1,226 @@ +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'; +import { SellerPoolsRewardsClient } from '@antseed/node/payments'; + +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 rpcUrl: string; + let ownsSandbox = false; + + async function run(command: string[], expectFailure = false): Promise { + let result; + try { + result = await execFile(process.execPath, [cli, '--config', config, '--data-dir', dataDir, ...command], { + cwd: root, + env: process.env, + }); + } 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 { + 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 }); + } + 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); + + 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 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 runJson(['seller', 'rewards', '--json'])).toMatchObject({ mode: 'legacy' }); + expect(await run(['seller', 'legacy', 'claim-starter'], true)).toContain('recognized-usage'); + + await sandbox(['cutover']); + 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', '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']); + const positionsJson = await runJson(['seller', 'pool', 'positions', '--json']) as Array<{ id: number }>; + expect(positionsJson.length).toBeGreaterThanOrEqual(2); + const newestId = String(positionsJson.at(-1).id); + await sandbox(['advance-epoch', '1']); + 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', 'rewards', '--json']) as { + mode: string; + legacy: unknown; + recognizedUsage: unknown; + }; + expect(sellerInfo.mode).toBe('recognized-usage'); + expect(sellerInfo).toHaveProperty('legacy'); + expect(sellerInfo).toHaveProperty('recognizedUsage'); + 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')); + 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; + 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/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..cabc10df9 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 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 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 + +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/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 feadd677f..50925497a 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -155,6 +155,25 @@ 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, 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, 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 { + 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/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 b6e5ee509..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 { @@ -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')(); @@ -73,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/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..962662875 --- /dev/null +++ b/packages/node/src/payments/evm/seller-pools-client.ts @@ -0,0 +1,142 @@ +import { Contract, Interface, ZeroAddress, type AbstractSigner, type Log } 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 = [ + '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)', + '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)); } + 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')()); } + 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); } + 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 new file mode 100644 index 000000000..64e975673 --- /dev/null +++ b/packages/node/src/payments/evm/seller-pools-rewards-client.ts @@ -0,0 +1,107 @@ +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 new file mode 100644 index 000000000..0f4cd3931 --- /dev/null +++ b/packages/node/src/payments/evm/seller-registry-client.ts @@ -0,0 +1,55 @@ +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 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)', + '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')(); } + 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/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..1345602db 100644 --- a/packages/node/src/payments/index.ts +++ b/packages/node/src/payments/index.ts @@ -84,6 +84,36 @@ 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, 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, 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'; +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/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/packages/node/src/payments/reward-preview.ts b/packages/node/src/payments/reward-preview.ts new file mode 100644 index 000000000..2382a1ce4 --- /dev/null +++ b/packages/node/src/payments/reward-preview.ts @@ -0,0 +1,45 @@ +import type { SellerPoolPosition } from './evm/seller-pools-client.js'; + +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/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/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; + }); +}