diff --git a/packages/horizon/README.md b/packages/horizon/README.md index 1a81f0743..73e1df607 100644 --- a/packages/horizon/README.md +++ b/packages/horizon/README.md @@ -69,3 +69,130 @@ To verify contracts on a network, run the following commands: npx hardhat ignition verify --network --include-unrelated-contracts ./scripts/post-verify ``` + +## Operational Tasks + +Operational tasks for post-migration maintenance and fund recovery are located in `tasks/ops/`. + +### Configuration + +Set the subgraph API key for querying The Graph Network: + +```bash +npx hardhat vars set SUBGRAPH_API_KEY +``` + +### Legacy Allocations + +Force close legacy allocations that haven't been migrated to Horizon. + +#### Query Legacy Allocations + +Query and report active legacy allocations from the Graph Network subgraph: + +```bash +npx hardhat ops:allocations:query --network arbitrumOne +``` + +Options: +- `--subgraph-api-key`: API key for The Graph Network gateway +- `--excluded-indexers`: Comma-separated indexer addresses to exclude (default: upgrade indexer) +- `--output-dir`: Output directory for reports (default: `./ops-output`) + +#### Close Legacy Allocations + +Force close legacy allocations: + +```bash +# Generate calldata for external execution (Fireblocks, Safe, etc.) +npx hardhat ops:allocations:close --network arbitrumOne --calldata-only + +# Execute directly with secure accounts +npx hardhat ops:allocations:close --network arbitrumOne + +# Dry run to simulate without executing +npx hardhat ops:allocations:close --network arbitrumOne --dry-run +``` + +Options: +- `--input-file`: JSON file from query task (if not provided, queries subgraph) +- `--account-index`: Derivation path index for the account (default: 0) +- `--calldata-only`: Generate calldata without executing +- `--dry-run`: Simulate without executing + +### TAP Escrow Recovery + +Recover GRT funds from the TAP v1 Escrow contract. + +#### Query Escrow Accounts + +Query and report TAP escrow accounts: + +```bash +npx hardhat ops:escrow:query --network arbitrumOne +``` + +Options: +- `--subgraph-api-key`: API key for The Graph Network gateway +- `--sender-addresses`: Comma-separated sender addresses to query +- `--output-dir`: Output directory for reports (default: `./ops-output`) + +#### Thaw Escrow Funds + +Initiate the 30-day thawing period for escrow funds: + +```bash +# Generate calldata for external execution +npx hardhat ops:escrow:thaw --network arbitrumOne --calldata-only + +# Execute directly +npx hardhat ops:escrow:thaw --network arbitrumOne + +# Dry run +npx hardhat ops:escrow:thaw --network arbitrumOne --dry-run +``` + +Options: +- `--input-file`: JSON file from query task +- `--account-index`: Derivation path index for the gateway account (default: 0) +- `--escrow-address`: TAP Escrow contract address +- `--calldata-only`: Generate calldata without executing +- `--dry-run`: Simulate without executing + +#### Withdraw Escrow Funds + +Withdraw thawed funds after the 30-day thawing period: + +```bash +# Generate calldata for external execution +npx hardhat ops:escrow:withdraw --network arbitrumOne --calldata-only + +# Execute directly +npx hardhat ops:escrow:withdraw --network arbitrumOne +``` + +Options: +- `--input-file`: JSON file from query task +- `--account-index`: Derivation path index for the gateway account (default: 0) +- `--escrow-address`: TAP Escrow contract address +- `--calldata-only`: Generate calldata without executing +- `--dry-run`: Simulate without executing + +### Output Files + +All operational task outputs are saved to `ops-output/` (configurable via `--output-dir`): + +``` +ops-output/ +├── allocations-YYYY-MM-DD-HHMMSS.json # Legacy allocation data +├── allocations-YYYY-MM-DD-HHMMSS.csv # CSV for spreadsheet review +├── escrow-accounts-YYYY-MM-DD-HHMMSS.json # Escrow account data +├── escrow-accounts-YYYY-MM-DD-HHMMSS.csv # CSV for spreadsheet review +├── close-allocations-results-*.json # Allocation closing results +├── thaw-escrow-results-*.json # Thaw transaction results +├── withdraw-escrow-results-*.json # Withdraw transaction results +└── calldata/ + ├── close-allocations-*.json # Calldata for allocation closing + ├── thaw-escrow-*.json # Calldata for thawing + └── withdraw-escrow-*.json # Calldata for withdrawal +``` diff --git a/packages/horizon/package.json b/packages/horizon/package.json index ad05c92dd..ff4fb9a34 100644 --- a/packages/horizon/package.json +++ b/packages/horizon/package.json @@ -33,6 +33,7 @@ "test:self": "forge test", "test:deployment": "SECURE_ACCOUNTS_DISABLE_PROVIDER=true hardhat test test/deployment/*.ts", "test:integration": "./scripts/integration", + "test:ops": "./scripts/ops-test", "test:coverage": "pnpm build && pnpm test:coverage:self", "test:coverage:self": "forge coverage", "prepublishOnly": "pnpm run build" diff --git a/packages/horizon/scripts/ops-poc b/packages/horizon/scripts/ops-poc new file mode 100755 index 000000000..f1c59e6ee --- /dev/null +++ b/packages/horizon/scripts/ops-poc @@ -0,0 +1,55 @@ +#!/bin/bash + +set -eo pipefail + +# Add foundry to PATH +export PATH="$HOME/.foundry/bin:$PATH" + +# Proof-of-concept script for fork testing with impersonated accounts +# This validates that we can: +# 1. Fork Arbitrum One +# 2. Impersonate accounts +# 3. Send transactions from impersonated accounts + +export SECURE_ACCOUNTS_DISABLE_PROVIDER=true + +# Alchemy RPC for Arbitrum One +BLOCKCHAIN_RPC=${BLOCKCHAIN_RPC:-"https://arb-mainnet.g.alchemy.com/v2/yRreth_oJQQeP_80z8dn-yR1LoxDKK_G"} + +# Function to cleanup resources +cleanup() { + if [ ! -z "$NODE_PID" ] && [ "$STARTED_NODE" = true ]; then + echo "Cleaning up Hardhat node (PID: $NODE_PID)..." + kill -TERM -- -$NODE_PID 2>/dev/null || true + fi +} + +trap cleanup EXIT + +echo "=== Ops Fork Testing POC ===" +echo "" + +# Check if hardhat node is already running on port 8545 +STARTED_NODE=false +if lsof -i:8545 > /dev/null 2>&1; then + echo "Hardhat node already running on port 8545, using existing node" + NODE_PID=$(lsof -t -i:8545) +else + echo "Starting local hardhat node forked from Arbitrum One..." + npx hardhat node --fork $BLOCKCHAIN_RPC > node.log 2>&1 & + NODE_PID=$! + STARTED_NODE=true + + # Wait for node to start + echo "Waiting for node to start..." + sleep 10 +fi + +echo "Node ready. Running POC task..." +echo "" + +# Run the POC task +npx hardhat ops:poc --network localhost + +echo "" +echo "=== POC Complete ===" diff --git a/packages/horizon/scripts/ops-test b/packages/horizon/scripts/ops-test new file mode 100755 index 000000000..a39639e5f --- /dev/null +++ b/packages/horizon/scripts/ops-test @@ -0,0 +1,200 @@ +#!/bin/bash +# +# Fork Test Script for TAP Escrow Recovery & Legacy Allocation Closure +# +# This script orchestrates a full test flow against a forked Arbitrum One chain +# to validate all operational tasks before running on mainnet. +# +# Prerequisites: +# - SUBGRAPH_API_KEY must be set: npx hardhat vars set SUBGRAPH_API_KEY +# - Foundry must be installed: curl -L https://foundry.paradigm.xyz | bash && foundryup +# +# Usage: +# ./scripts/ops-test # Run with defaults +# TEST_LIMIT=5 ./scripts/ops-test # Process 5 entities per task +# BLOCKCHAIN_RPC= ./scripts/ops-test # Use custom RPC endpoint +# + +set -eo pipefail + +# ============================================ +# Configuration +# ============================================ + +export SECURE_ACCOUNTS_DISABLE_PROVIDER=true +export PATH="$HOME/.foundry/bin:$PATH" + +BLOCKCHAIN_RPC=${BLOCKCHAIN_RPC:-"https://arb-mainnet.g.alchemy.com/v2/yRreth_oJQQeP_80z8dn-yR1LoxDKK_G"} +TEST_LIMIT=${TEST_LIMIT:-3} +OUTPUT_DIR="./ops-test-output" +NODE_LOG="$OUTPUT_DIR/node.log" + +# ============================================ +# Helper Functions +# ============================================ + +cleanup() { + if [ "$STARTED_NODE" = true ] && [ ! -z "$NODE_PID" ]; then + echo "" + echo "Cleaning up Hardhat node (PID: $NODE_PID)..." + kill -TERM $NODE_PID 2>/dev/null || true + wait $NODE_PID 2>/dev/null || true + fi +} + +trap cleanup EXIT + +wait_for_node() { + local max_attempts=30 + local attempt=0 + while ! curl -s http://localhost:8545 -X POST -H "Content-Type: application/json" \ + --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' > /dev/null 2>&1; do + attempt=$((attempt + 1)) + if [ $attempt -ge $max_attempts ]; then + echo "ERROR: Hardhat node failed to start" + cat $NODE_LOG + exit 1 + fi + sleep 1 + done +} + +# ============================================ +# Main Script +# ============================================ + +echo "==============================================" +echo "=== Ops Fork Test ===" +echo "==============================================" +echo "" +echo "Configuration:" +echo " BLOCKCHAIN_RPC: $BLOCKCHAIN_RPC" +echo " TEST_LIMIT: $TEST_LIMIT" +echo " OUTPUT_DIR: $OUTPUT_DIR" +echo "" + +# Create output directory +rm -rf $OUTPUT_DIR +mkdir -p $OUTPUT_DIR + +# ============================================ +# Phase 0: Start Fork +# ============================================ + +echo "=== Phase 0: Starting Arbitrum One Fork ===" +STARTED_NODE=false +NODE_PID="" + +if lsof -i:8545 > /dev/null 2>&1; then + echo "Hardhat node already running on port 8545, using existing node" + NODE_PID=$(lsof -t -i:8545 | head -1) +else + echo "Starting local hardhat node forked from Arbitrum One..." + npx hardhat node --fork $BLOCKCHAIN_RPC > $NODE_LOG 2>&1 & + NODE_PID=$! + STARTED_NODE=true + echo "Waiting for node to start (PID: $NODE_PID)..." + wait_for_node +fi +echo "Node ready!" +echo "" + +# ============================================ +# Phase 1: Query (BEFORE any state changes) +# ============================================ + +echo "=== Phase 1: Query Data from Subgraph ===" +echo "Note: Queries must happen before fork state changes (subgraph reflects mainnet, not fork)" +echo "" + +echo "Querying legacy allocations..." +npx hardhat ops:allocations:query --network localhost --output-dir $OUTPUT_DIR + +echo "" +echo "Querying TAP escrow accounts..." +npx hardhat ops:escrow:query --network localhost --output-dir $OUTPUT_DIR + +echo "" + +# ============================================ +# Phase 2: Close Allocations +# ============================================ + +echo "=== Phase 2: Close Legacy Allocations ===" +ALLOC_FILE=$(ls -t $OUTPUT_DIR/allocations-*.json 2>/dev/null | head -1) +if [ -z "$ALLOC_FILE" ]; then + echo "No allocations file found, skipping allocation closing" +else + echo "Using allocations from: $ALLOC_FILE" + npx hardhat ops:allocations:close --network localhost --input-file $ALLOC_FILE --limit $TEST_LIMIT --output-dir $OUTPUT_DIR + + CLOSE_RESULTS=$(ls -t $OUTPUT_DIR/close-allocations-results-*.json 2>/dev/null | head -1) + if [ ! -z "$CLOSE_RESULTS" ]; then + echo "" + echo "Verifying closed allocations..." + npx hardhat ops:verify --type allocations --input-file $CLOSE_RESULTS --network localhost + fi +fi + +echo "" + +# ============================================ +# Phase 3: Thaw Escrow +# ============================================ + +echo "=== Phase 3: Thaw TAP Escrow ===" +ESCROW_FILE=$(ls -t $OUTPUT_DIR/escrow-accounts-*.json 2>/dev/null | head -1) +if [ -z "$ESCROW_FILE" ]; then + echo "No escrow file found, skipping escrow operations" +else + echo "Using escrow accounts from: $ESCROW_FILE" + npx hardhat ops:escrow:thaw --network localhost --input-file $ESCROW_FILE --limit $TEST_LIMIT --output-dir $OUTPUT_DIR + + THAW_RESULTS=$(ls -t $OUTPUT_DIR/thaw-escrow-results-*.json 2>/dev/null | head -1) + if [ ! -z "$THAW_RESULTS" ]; then + echo "" + echo "Verifying thawed accounts..." + npx hardhat ops:verify --type escrow-thaw --input-file $THAW_RESULTS --network localhost + fi +fi + +echo "" + +# ============================================ +# Phase 4: Time Skip + Withdraw +# ============================================ + +echo "=== Phase 4: Fast-forward 30 Days & Withdraw ===" + +if [ ! -z "$ESCROW_FILE" ]; then + echo "Advancing blockchain time by 30 days..." + npx hardhat ops:time-skip --days 30 --network localhost + + echo "" + echo "Withdrawing thawed escrow accounts..." + npx hardhat ops:escrow:withdraw --network localhost --input-file $ESCROW_FILE --limit $TEST_LIMIT --output-dir $OUTPUT_DIR + + WITHDRAW_RESULTS=$(ls -t $OUTPUT_DIR/withdraw-escrow-results-*.json 2>/dev/null | head -1) + if [ ! -z "$WITHDRAW_RESULTS" ]; then + echo "" + echo "Verifying withdrawals..." + npx hardhat ops:verify --type escrow-withdraw --input-file $WITHDRAW_RESULTS --original-file $ESCROW_FILE --network localhost + fi +else + echo "No escrow file found, skipping withdraw phase" +fi + +echo "" + +# ============================================ +# Summary +# ============================================ + +echo "==============================================" +echo "=== All Tests Passed! ===" +echo "==============================================" +echo "" +echo "Output files:" +ls -la $OUTPUT_DIR/ +echo "" +echo "Test completed successfully." diff --git a/packages/horizon/tasks/ops/legacy-allocations.ts b/packages/horizon/tasks/ops/legacy-allocations.ts new file mode 100644 index 000000000..54729d94d --- /dev/null +++ b/packages/horizon/tasks/ops/legacy-allocations.ts @@ -0,0 +1,279 @@ +/** + * Legacy Allocations Operational Tasks + * + * Tasks for querying and force closing legacy allocations after Horizon migration. + */ + +import { task, types, vars } from 'hardhat/config' +import type { HardhatRuntimeEnvironment } from 'hardhat/types' +import { ZeroHash } from 'ethers' +import type { Signer } from 'ethers' + +import { getImpersonatedSigner, isLocalNetwork } from './lib/fork-utils' +import { + formatGRT, + generateAllocationsReport, + generateExecutionReport, + loadAllocationsFromFile, + printAllocationsSummary, + printCalldataSummary, + printExecutionSummary, + writeAllocationsReport, + writeCalldataBatch, + writeExecutionReport, +} from './lib/report' +import { groupAllocationsByIndexer, queryLegacyAllocations } from './lib/subgraph' +import type { Allocation, CalldataBatch, CalldataEntry, CloseAllocationResult } from './lib/types' +import { DEFAULTS } from './lib/types' + +// ============================================ +// Query Legacy Allocations Task +// ============================================ + +task('ops:allocations:query', 'Query and report active legacy allocations from Graph Network subgraph') + .addOptionalParam( + 'subgraphApiKey', + 'API key for The Graph Network gateway (can also use SUBGRAPH_API_KEY hardhat var)', + undefined, + types.string, + ) + .addOptionalParam( + 'excludedIndexers', + 'Comma-separated list of indexer addresses to exclude', + DEFAULTS.UPGRADE_INDEXER, + types.string, + ) + .addOptionalParam('outputDir', 'Output directory for reports', DEFAULTS.OUTPUT_DIR, types.string) + .setAction(async (args, hre: HardhatRuntimeEnvironment) => { + console.log('\n========== Query Legacy Allocations ==========') + + // Get API key from args or hardhat vars + let apiKey = args.subgraphApiKey + if (!apiKey) { + if (!vars.has('SUBGRAPH_API_KEY')) { + throw new Error('No subgraph API key provided. Set --subgraph-api-key or use `npx hardhat vars set SUBGRAPH_API_KEY`') + } + apiKey = vars.get('SUBGRAPH_API_KEY') + } + + // Parse excluded indexers + const excludedIndexers = args.excludedIndexers + .split(',') + .map((addr: string) => addr.trim().toLowerCase()) + .filter((addr: string) => addr.length > 0) + + console.log(`Network: ${hre.network.name}`) + console.log(`Chain ID: ${hre.network.config.chainId}`) + console.log(`Excluded Indexers: ${excludedIndexers.join(', ')}`) + console.log('') + + // Query subgraph + console.log('Querying Graph Network subgraph for legacy allocations...') + const allocations = await queryLegacyAllocations(apiKey, excludedIndexers) + + if (allocations.length === 0) { + console.log('No active legacy allocations found.') + return + } + + // Group by indexer for summary + const summaryByIndexer = groupAllocationsByIndexer(allocations) + + // Generate and write report + const report = generateAllocationsReport( + allocations, + summaryByIndexer, + excludedIndexers, + hre.network.name, + hre.network.config.chainId!, + ) + + const { jsonPath, csvPath } = writeAllocationsReport(report, args.outputDir) + + // Print summary + printAllocationsSummary(report) + + console.log('\nReports written to:') + console.log(` JSON: ${jsonPath}`) + console.log(` CSV: ${csvPath}`) + }) + +// ============================================ +// Close Legacy Allocations Task +// ============================================ + +task('ops:allocations:close', 'Force close legacy allocations') + .addOptionalParam( + 'inputFile', + 'JSON file with allocations to close (from ops:allocations:query). If not provided, queries subgraph.', + undefined, + types.string, + ) + .addOptionalParam( + 'subgraphApiKey', + 'API key for The Graph Network gateway (required if no inputFile)', + undefined, + types.string, + ) + .addOptionalParam( + 'excludedIndexers', + 'Comma-separated list of indexer addresses to exclude', + DEFAULTS.UPGRADE_INDEXER, + types.string, + ) + .addOptionalParam('limit', 'Maximum number of allocations to close (0 = all)', 0, types.int) + .addOptionalParam('accountIndex', 'Derivation path index for the account', 0, types.int) + .addOptionalParam('outputDir', 'Output directory for reports', DEFAULTS.OUTPUT_DIR, types.string) + .addFlag('calldataOnly', 'Generate calldata without executing transactions') + .addFlag('dryRun', 'Simulate without executing transactions') + .setAction(async (args, hre: HardhatRuntimeEnvironment) => { + console.log('\n========== Close Legacy Allocations ==========') + console.log(`Network: ${hre.network.name}`) + console.log(`Chain ID: ${hre.network.config.chainId}`) + console.log(`Mode: ${args.calldataOnly ? 'Calldata Only' : args.dryRun ? 'Dry Run' : 'Execute'}`) + + // Get allocations either from file or subgraph + let allocations: Allocation[] + + if (args.inputFile) { + console.log(`Loading allocations from: ${args.inputFile}`) + allocations = loadAllocationsFromFile(args.inputFile) + } else { + // Query subgraph + let apiKey = args.subgraphApiKey + if (!apiKey) { + if (!vars.has('SUBGRAPH_API_KEY')) { + throw new Error('No subgraph API key provided. Set --subgraph-api-key or use `npx hardhat vars set SUBGRAPH_API_KEY`') + } + apiKey = vars.get('SUBGRAPH_API_KEY') + } + + const excludedIndexers = args.excludedIndexers + .split(',') + .map((addr: string) => addr.trim().toLowerCase()) + .filter((addr: string) => addr.length > 0) + + console.log('Querying subgraph for legacy allocations...') + allocations = await queryLegacyAllocations(apiKey, excludedIndexers) + } + + if (allocations.length === 0) { + console.log('No allocations to close.') + return + } + + // Apply limit if specified (sorted by allocatedTokens descending already) + if (args.limit > 0 && allocations.length > args.limit) { + console.log(`Limiting to first ${args.limit} allocations (of ${allocations.length} total)`) + allocations = allocations.slice(0, args.limit) + } + + console.log(`Found ${allocations.length} allocations to close`) + console.log(`Total allocated GRT: ${formatGRT(allocations.reduce((sum, a) => sum + a.allocatedTokens, 0n))}`) + + // Initialize Graph Runtime Environment + const graph = hre.graph() + const horizonStaking = graph.horizon.contracts.HorizonStaking + + // Calldata-only mode + if (args.calldataOnly) { + const entries: CalldataEntry[] = allocations.map((allocation) => ({ + to: horizonStaking.target as string, + data: horizonStaking.interface.encodeFunctionData('closeAllocation', [ + allocation.id, + ZeroHash, + ]), + value: '0', + description: `Close allocation ${allocation.id} (indexer: ${allocation.indexer.id}, ${formatGRT(allocation.allocatedTokens)} GRT)`, + })) + + const batch: CalldataBatch = { + timestamp: new Date().toISOString(), + network: hre.network.name, + chainId: hre.network.config.chainId!, + entries, + } + + const filePath = writeCalldataBatch(batch, 'close-allocations', args.outputDir) + printCalldataSummary(batch, 'Close Allocations') + console.log(`\nCalldata written to: ${filePath}`) + return + } + + // Get signer - use impersonation on local networks, secure accounts on mainnet + let signer: Signer + if (isLocalNetwork(hre)) { + // On local networks, use any hardhat signer (force close is permissionless for old allocations) + const [defaultSigner] = await hre.ethers.getSigners() + signer = defaultSigner + console.log(`\nUsing local account: ${await signer.getAddress()} (impersonation mode)`) + } else { + // On mainnet, use secure accounts + signer = await graph.accounts.getDeployer(args.accountIndex) + console.log(`\nUsing account: ${await signer.getAddress()}`) + } + + const signerAddress = await signer.getAddress() + const balance = await hre.ethers.provider.getBalance(signerAddress) + console.log(`Account balance: ${hre.ethers.formatEther(balance)} ETH`) + + if (balance === 0n && !args.dryRun) { + throw new Error('Account has no ETH balance') + } + + // Execute transactions + console.log('\nClosing allocations...') + const results: CloseAllocationResult[] = [] + + for (let i = 0; i < allocations.length; i++) { + const allocation = allocations[i] + console.log(`[${i + 1}/${allocations.length}] Closing ${allocation.id}...`) + + if (args.dryRun) { + console.log(` [DRY RUN] Would close allocation for indexer ${allocation.indexer.id}`) + results.push({ + success: true, + allocationId: allocation.id, + indexer: allocation.indexer.id, + }) + continue + } + + try { + const tx = await horizonStaking.connect(signer).closeAllocation(allocation.id, ZeroHash) + const receipt = await tx.wait() + + results.push({ + success: true, + txHash: receipt!.hash, + gasUsed: receipt!.gasUsed, + allocationId: allocation.id, + indexer: allocation.indexer.id, + }) + + console.log(` Success: ${receipt!.hash}`) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + results.push({ + success: false, + error: errorMessage, + allocationId: allocation.id, + indexer: allocation.indexer.id, + }) + console.log(` Failed: ${errorMessage}`) + } + } + + // Generate and write execution report + const report = generateExecutionReport( + results, + args.dryRun ? 'dry-run' : 'execute', + hre.network.name, + hre.network.config.chainId!, + 'ops:allocations:close', + ) + + const filePath = writeExecutionReport(report, 'close-allocations', args.outputDir) + printExecutionSummary(report, 'Close Allocations') + console.log(`\nResults written to: ${filePath}`) + }) diff --git a/packages/horizon/tasks/ops/lib/fork-utils.ts b/packages/horizon/tasks/ops/lib/fork-utils.ts new file mode 100644 index 000000000..9edd5bccf --- /dev/null +++ b/packages/horizon/tasks/ops/lib/fork-utils.ts @@ -0,0 +1,117 @@ +/** + * Fork testing utilities for TAP Escrow Recovery & Legacy Allocation Closure operations + * + * These utilities enable testing operations against a forked Arbitrum One chain + * with impersonated accounts. + */ + +import type { HardhatRuntimeEnvironment } from 'hardhat/types' +import type { Signer } from 'ethers' + +// ============================================ +// Network Detection +// ============================================ + +const LOCAL_NETWORKS = ['localhost', 'hardhat', 'localNetwork'] + +/** + * Check if the current network is a local/forked network + */ +export function isLocalNetwork(hre: HardhatRuntimeEnvironment): boolean { + return LOCAL_NETWORKS.includes(hre.network.name) +} + +/** + * Require that we're on a local network, throw otherwise + */ +export function requireLocalNetwork(hre: HardhatRuntimeEnvironment): void { + if (!isLocalNetwork(hre)) { + throw new Error(`Network ${hre.network.name} is not a local network. This operation requires localhost, hardhat, or localNetwork.`) + } +} + +// ============================================ +// Account Utilities +// ============================================ + +/** + * Fund an account with ETH for gas (useful for impersonated accounts) + */ +export async function fundAccount( + hre: HardhatRuntimeEnvironment, + address: string, + amount: string = '1.0', +): Promise { + const [funder] = await hre.ethers.getSigners() + const tx = await funder.sendTransaction({ + to: address, + value: hre.ethers.parseEther(amount), + }) + await tx.wait() +} + +/** + * Get an impersonated signer and fund it with ETH + */ +export async function getImpersonatedSigner( + hre: HardhatRuntimeEnvironment, + address: string, + fundAmount: string = '1.0', +): Promise { + // Get impersonated signer + const signer = await hre.ethers.getImpersonatedSigner(address) + + // Fund the impersonated account with ETH for gas + await fundAccount(hre, address, fundAmount) + + return signer +} + +// ============================================ +// Time Manipulation +// ============================================ + +/** + * Advance blockchain time by a specified number of seconds + */ +export async function advanceTime( + hre: HardhatRuntimeEnvironment, + seconds: number, +): Promise { + requireLocalNetwork(hre) + await hre.ethers.provider.send('evm_increaseTime', [seconds]) + await hre.ethers.provider.send('evm_mine', []) +} + +/** + * Advance blockchain time by a specified number of days + */ +export async function advanceTimeDays( + hre: HardhatRuntimeEnvironment, + days: number, +): Promise { + const seconds = days * 24 * 60 * 60 + await advanceTime(hre, seconds) +} + +/** + * Get the current block timestamp + */ +export async function getBlockTimestamp( + hre: HardhatRuntimeEnvironment, +): Promise { + const block = await hre.ethers.provider.getBlock('latest') + return block!.timestamp +} + +// ============================================ +// Constants +// ============================================ + +export const FORK_DEFAULTS = { + // Default ETH amount to fund impersonated accounts + FUND_AMOUNT: '1.0', + + // 30 days in seconds (TAP escrow thawing period) + THAW_PERIOD_SECONDS: 30 * 24 * 60 * 60, +} as const diff --git a/packages/horizon/tasks/ops/lib/report.ts b/packages/horizon/tasks/ops/lib/report.ts new file mode 100644 index 000000000..0f01ef070 --- /dev/null +++ b/packages/horizon/tasks/ops/lib/report.ts @@ -0,0 +1,413 @@ +/** + * Report generation utilities for TAP Escrow Recovery & Legacy Allocation Closure operations + */ + +import * as fs from 'fs' +import * as path from 'path' + +import type { + Allocation, + AllocationsReport, + CalldataBatch, + CloseAllocationResult, + EscrowAccount, + EscrowReport, + ExecutionReport, + IndexerAllocationSummary, + SenderEscrowSummary, + ThawResult, + TransactionResult, + WithdrawResult, +} from './types' +import { DEFAULTS } from './types' + +// ============================================ +// Directory Setup +// ============================================ + +/** + * Ensure output directory exists + */ +export function ensureOutputDir(outputDir: string = DEFAULTS.OUTPUT_DIR): void { + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }) + } + + const calldataDir = path.join(outputDir, 'calldata') + if (!fs.existsSync(calldataDir)) { + fs.mkdirSync(calldataDir, { recursive: true }) + } +} + +/** + * Generate timestamp string for filenames + */ +function getTimestamp(): string { + const now = new Date() + return now.toISOString().replace(/[:.]/g, '-').slice(0, 19) +} + +// ============================================ +// GRT Formatting +// ============================================ + +/** + * Format GRT amount for display (18 decimals) + */ +export function formatGRT(amount: bigint): string { + const decimals = 18n + const divisor = 10n ** decimals + const whole = amount / divisor + const fraction = amount % divisor + const fractionStr = fraction.toString().padStart(Number(decimals), '0').slice(0, 4) + return `${whole.toLocaleString()}.${fractionStr}` +} + +/** + * Format GRT as simple number string for CSV + */ +export function formatGRTSimple(amount: bigint): string { + const decimals = 18n + const divisor = 10n ** decimals + const whole = amount / divisor + const fraction = amount % divisor + const fractionStr = fraction.toString().padStart(Number(decimals), '0').slice(0, 6) + return `${whole}.${fractionStr}` +} + +// ============================================ +// Allocations Report +// ============================================ + +/** + * Generate allocations report + */ +export function generateAllocationsReport( + allocations: Allocation[], + summaryByIndexer: IndexerAllocationSummary[], + excludedIndexers: string[], + network: string, + chainId: number, +): AllocationsReport { + return { + timestamp: new Date().toISOString(), + network, + chainId, + generatedBy: 'ops:allocations:query', + excludedIndexers, + totalAllocations: allocations.length, + totalAllocatedTokens: allocations.reduce((sum, a) => sum + a.allocatedTokens, 0n), + allocations, + summaryByIndexer, + } +} + +/** + * Write allocations report to files + */ +export function writeAllocationsReport( + report: AllocationsReport, + outputDir: string = DEFAULTS.OUTPUT_DIR, +): { jsonPath: string; csvPath: string } { + ensureOutputDir(outputDir) + const timestamp = getTimestamp() + + // Write JSON report + const jsonPath = path.join(outputDir, `allocations-${timestamp}.json`) + fs.writeFileSync( + jsonPath, + JSON.stringify( + report, + (_, value) => (typeof value === 'bigint' ? value.toString() : value), + 2, + ), + ) + + // Write CSV report + const csvPath = path.join(outputDir, `allocations-${timestamp}.csv`) + const csvHeader = 'allocation_id,indexer,indexer_url,allocated_tokens_grt,subgraph_deployment,created_epoch,status\n' + const csvRows = report.allocations.map((a) => + `${a.id},${a.indexer.id},${a.indexer.url || 'N/A'},${formatGRTSimple(a.allocatedTokens)},${a.subgraphDeployment.ipfsHash},${a.createdAtEpoch},${a.status}`, + ).join('\n') + fs.writeFileSync(csvPath, csvHeader + csvRows) + + return { jsonPath, csvPath } +} + +/** + * Print allocations summary to console + */ +export function printAllocationsSummary(report: AllocationsReport): void { + console.log('\n========== Legacy Allocations Summary ==========') + console.log(`Network: ${report.network} (Chain ID: ${report.chainId})`) + console.log(`Timestamp: ${report.timestamp}`) + console.log(`Excluded Indexers: ${report.excludedIndexers.join(', ') || 'None'}`) + console.log('') + console.log(`Total Allocations: ${report.totalAllocations}`) + console.log(`Total Allocated GRT: ${formatGRT(report.totalAllocatedTokens)}`) + console.log('') + + console.log('By Indexer:') + console.log('─'.repeat(110)) + console.log('| Indexer | URL | Allocations | Allocated GRT |') + console.log('─'.repeat(110)) + for (const summary of report.summaryByIndexer.slice(0, 15)) { + const urlDisplay = summary.indexerUrl ? summary.indexerUrl.slice(0, 28) : 'N/A' + console.log( + `| ${summary.indexer.slice(0, 42).padEnd(42)} | ${urlDisplay.padEnd(28)} | ${summary.allocationCount.toString().padStart(11)} | ${formatGRT(summary.totalAllocatedTokens).padStart(18)} |`, + ) + } + if (report.summaryByIndexer.length > 15) { + console.log(`| ... and ${report.summaryByIndexer.length - 15} more indexers`.padEnd(109) + '|') + } + console.log('─'.repeat(110)) +} + +// ============================================ +// Escrow Report +// ============================================ + +/** + * Generate escrow report + */ +export function generateEscrowReport( + accounts: EscrowAccount[], + summaryBySender: SenderEscrowSummary[], + senderAddresses: string[], + excludedReceivers: string[], + network: string, + chainId: number, +): EscrowReport { + return { + timestamp: new Date().toISOString(), + network, + chainId, + generatedBy: 'ops:escrow:query', + senderAddresses, + excludedReceivers, + totalAccounts: accounts.length, + totalBalance: accounts.reduce((sum, a) => sum + a.balance, 0n), + totalAmountThawing: accounts.reduce((sum, a) => sum + a.amountThawing, 0n), + accounts, + summaryBySender, + } +} + +/** + * Write escrow report to files + */ +export function writeEscrowReport( + report: EscrowReport, + outputDir: string = DEFAULTS.OUTPUT_DIR, +): { jsonPath: string; csvPath: string } { + ensureOutputDir(outputDir) + const timestamp = getTimestamp() + + // Write JSON report + const jsonPath = path.join(outputDir, `escrow-accounts-${timestamp}.json`) + fs.writeFileSync( + jsonPath, + JSON.stringify( + report, + (_, value) => (typeof value === 'bigint' ? value.toString() : value), + 2, + ), + ) + + // Write CSV report + const csvPath = path.join(outputDir, `escrow-accounts-${timestamp}.csv`) + const csvHeader = 'account_id,sender,receiver,balance_grt,amount_thawing_grt,thaw_end_timestamp,recoverable_grt\n' + const csvRows = report.accounts.map((a) => { + const recoverable = a.balance - a.amountThawing + return `${a.id},${a.sender},${a.receiver},${formatGRTSimple(a.balance)},${formatGRTSimple(a.amountThawing)},${a.thawEndTimestamp},${formatGRTSimple(recoverable > 0n ? recoverable : 0n)}` + }).join('\n') + fs.writeFileSync(csvPath, csvHeader + csvRows) + + return { jsonPath, csvPath } +} + +/** + * Print escrow summary to console + */ +export function printEscrowSummary(report: EscrowReport): void { + console.log('\n========== TAP Escrow Accounts Summary ==========') + console.log(`Network: ${report.network} (Chain ID: ${report.chainId})`) + console.log(`Timestamp: ${report.timestamp}`) + console.log(`Sender Addresses: ${report.senderAddresses.join(', ')}`) + console.log(`Excluded Receivers: ${report.excludedReceivers.join(', ') || 'None'}`) + console.log('') + console.log(`Total Accounts: ${report.totalAccounts}`) + console.log(`Total Balance: ${formatGRT(report.totalBalance)} GRT`) + console.log(`Total Amount Thawing: ${formatGRT(report.totalAmountThawing)} GRT`) + console.log(`Recoverable (balance - thawing): ${formatGRT(report.totalBalance - report.totalAmountThawing)} GRT`) + console.log('') + + console.log('By Sender:') + console.log('─'.repeat(90)) + console.log('| Sender | Accounts | Balance GRT | Thawing GRT |') + console.log('─'.repeat(90)) + for (const summary of report.summaryBySender) { + console.log( + `| ${summary.sender.slice(0, 42).padEnd(42)} | ${summary.accountCount.toString().padStart(8)} | ${formatGRT(summary.totalBalance).padStart(18)} | ${formatGRT(summary.totalAmountThawing).padStart(18)} |`, + ) + } + console.log('─'.repeat(90)) +} + +// ============================================ +// Execution Reports +// ============================================ + +/** + * Generate execution report + */ +export function generateExecutionReport( + results: T[], + mode: 'execute' | 'calldata-only' | 'dry-run', + network: string, + chainId: number, + generatedBy: string, +): ExecutionReport { + return { + timestamp: new Date().toISOString(), + network, + chainId, + generatedBy, + mode, + totalTransactions: results.length, + successCount: results.filter((r) => r.success).length, + failureCount: results.filter((r) => !r.success).length, + results, + } +} + +/** + * Write execution report to file + */ +export function writeExecutionReport( + report: ExecutionReport, + prefix: string, + outputDir: string = DEFAULTS.OUTPUT_DIR, +): string { + ensureOutputDir(outputDir) + const timestamp = getTimestamp() + const filePath = path.join(outputDir, `${prefix}-results-${timestamp}.json`) + + fs.writeFileSync( + filePath, + JSON.stringify( + report, + (_, value) => (typeof value === 'bigint' ? value.toString() : value), + 2, + ), + ) + + return filePath +} + +/** + * Print execution summary to console + */ +export function printExecutionSummary( + report: ExecutionReport, + operationType: string, +): void { + console.log(`\n========== ${operationType} Execution Summary ==========`) + console.log(`Mode: ${report.mode}`) + console.log(`Network: ${report.network} (Chain ID: ${report.chainId})`) + console.log('') + console.log(`Total Transactions: ${report.totalTransactions}`) + console.log(`Successful: ${report.successCount}`) + console.log(`Failed: ${report.failureCount}`) + + if (report.failureCount > 0) { + console.log('\nFailed Transactions:') + for (const result of report.results.filter((r) => !r.success)) { + console.log(` - Error: ${result.error}`) + } + } +} + +// ============================================ +// Calldata Reports +// ============================================ + +/** + * Write calldata batch to file + */ +export function writeCalldataBatch( + batch: CalldataBatch, + prefix: string, + outputDir: string = DEFAULTS.OUTPUT_DIR, +): string { + ensureOutputDir(outputDir) + const timestamp = getTimestamp() + const filePath = path.join(outputDir, 'calldata', `${prefix}-${timestamp}.json`) + + fs.writeFileSync(filePath, JSON.stringify(batch, null, 2)) + + return filePath +} + +/** + * Print calldata summary to console + */ +export function printCalldataSummary(batch: CalldataBatch, prefix: string): void { + console.log(`\n========== ${prefix} Calldata Summary ==========`) + console.log(`Network: ${batch.network} (Chain ID: ${batch.chainId})`) + console.log(`Timestamp: ${batch.timestamp}`) + console.log(`Total Transactions: ${batch.entries.length}`) + console.log('') + + console.log('First 5 entries:') + for (const entry of batch.entries.slice(0, 5)) { + console.log(` - ${entry.description}`) + console.log(` To: ${entry.to}`) + console.log(` Data: ${entry.data.slice(0, 50)}...`) + } + + if (batch.entries.length > 5) { + console.log(` ... and ${batch.entries.length - 5} more transactions`) + } +} + +// ============================================ +// Load Reports (for use in subsequent tasks) +// ============================================ + +/** + * Load allocations from a JSON file + */ +export function loadAllocationsFromFile(filePath: string): Allocation[] { + const content = fs.readFileSync(filePath, 'utf-8') + const report = JSON.parse(content) as AllocationsReport + + // Convert string values back to bigint + return report.allocations.map((a) => ({ + ...a, + allocatedTokens: BigInt(a.allocatedTokens as unknown as string), + indexer: { + ...a.indexer, + allocatedTokens: BigInt(a.indexer.allocatedTokens as unknown as string), + stakedTokens: BigInt(a.indexer.stakedTokens as unknown as string), + url: a.indexer.url || null, + }, + })) +} + +/** + * Load escrow accounts from a JSON file + */ +export function loadEscrowAccountsFromFile(filePath: string): EscrowAccount[] { + const content = fs.readFileSync(filePath, 'utf-8') + const report = JSON.parse(content) as EscrowReport + + // Convert string values back to bigint + return report.accounts.map((a) => ({ + ...a, + balance: BigInt(a.balance as unknown as string), + amountThawing: BigInt(a.amountThawing as unknown as string), + thawEndTimestamp: BigInt(a.thawEndTimestamp as unknown as string), + totalAmountThawing: BigInt(a.totalAmountThawing as unknown as string), + })) +} diff --git a/packages/horizon/tasks/ops/lib/subgraph.ts b/packages/horizon/tasks/ops/lib/subgraph.ts new file mode 100644 index 000000000..e7c3538a2 --- /dev/null +++ b/packages/horizon/tasks/ops/lib/subgraph.ts @@ -0,0 +1,296 @@ +/** + * Subgraph query utilities for TAP Escrow Recovery & Legacy Allocation Closure operations + */ + +import type { Allocation, EscrowAccount } from './types' + +// ============================================ +// Subgraph Endpoints +// ============================================ + +const SUBGRAPH_ENDPOINTS = { + // Graph Network subgraph for legacy allocations + GRAPH_NETWORK: 'https://gateway.thegraph.com/api/{apiKey}/subgraphs/id/DZz4kDTdmzWLWsV373w2bSmoar3umKKH9y82SUKr5qmp', + // TAP subgraph for escrow accounts + TAP: 'https://gateway.thegraph.com/api/{apiKey}/subgraphs/id/4sukbNVTzGELnhdnpyPqsf1QqtzNHEYKKmJkgaT8z6M1', +} as const + +// ============================================ +// GraphQL Queries +// ============================================ + +const LEGACY_ALLOCATIONS_QUERY = ` + query LegacyAllocations($first: Int!, $skip: Int!, $excludedIndexers: [String!]) { + allocations( + first: $first + skip: $skip + where: { + status: Active + isLegacy: true + allocatedTokens_gt: 0 + indexer_not_in: $excludedIndexers + } + orderBy: allocatedTokens + orderDirection: desc + ) { + id + allocatedTokens + createdAtEpoch + closedAtEpoch + createdAtBlockNumber + status + poi + indexer { + id + allocatedTokens + stakedTokens + url + } + subgraphDeployment { + id + ipfsHash + } + } + } +` + +const ESCROW_ACCOUNTS_QUERY = ` + query EscrowAccounts($first: Int!, $skip: Int!, $senders: [String!]) { + escrowAccounts( + first: $first + skip: $skip + where: { + sender_in: $senders + balance_gt: 0 + } + orderBy: balance + orderDirection: desc + ) { + id + sender { + id + } + receiver { + id + } + balance + thawEndTimestamp + totalAmountThawing + } + } +` + +// ============================================ +// Query Functions +// ============================================ + +/** + * Execute a GraphQL query against a subgraph + */ +async function executeQuery( + endpoint: string, + query: string, + variables: Record, +): Promise { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }) + + if (!response.ok) { + throw new Error(`Subgraph query failed: ${response.status} ${response.statusText}`) + } + + const json = await response.json() + + if (json.errors) { + throw new Error(`GraphQL errors: ${JSON.stringify(json.errors)}`) + } + + return json.data +} + +/** + * Paginate through all results from a subgraph query + */ +async function paginateQuery( + endpoint: string, + query: string, + variables: Record, + resultKey: string, + pageSize: number = 1000, +): Promise { + const results: T[] = [] + let skip = 0 + let hasMore = true + + while (hasMore) { + const data = await executeQuery>(endpoint, query, { + ...variables, + first: pageSize, + skip, + }) + + const pageResults = data[resultKey] || [] + results.push(...pageResults) + + if (pageResults.length < pageSize) { + hasMore = false + } else { + skip += pageSize + } + } + + return results +} + +/** + * Query legacy allocations from Graph Network subgraph + */ +export async function queryLegacyAllocations( + apiKey: string, + excludedIndexers: string[], +): Promise { + const endpoint = SUBGRAPH_ENDPOINTS.GRAPH_NETWORK.replace('{apiKey}', apiKey) + + // Normalize addresses to lowercase for subgraph query + const normalizedExcluded = excludedIndexers.map((addr) => addr.toLowerCase()) + + interface RawAllocation { + id: string + allocatedTokens: string + createdAtEpoch: number + closedAtEpoch: number | null + createdAtBlockNumber: number + status: string + poi: string | null + indexer: { + id: string + allocatedTokens: string + stakedTokens: string + url: string | null + } + subgraphDeployment: { + id: string + ipfsHash: string + } + } + + const rawAllocations = await paginateQuery( + endpoint, + LEGACY_ALLOCATIONS_QUERY, + { excludedIndexers: normalizedExcluded }, + 'allocations', + ) + + // Transform raw data to typed allocations + return rawAllocations.map((raw) => ({ + id: raw.id, + allocatedTokens: BigInt(raw.allocatedTokens), + createdAtEpoch: raw.createdAtEpoch, + closedAtEpoch: raw.closedAtEpoch, + createdAtBlockNumber: raw.createdAtBlockNumber, + status: raw.status as Allocation['status'], + poi: raw.poi, + indexer: { + id: raw.indexer.id, + allocatedTokens: BigInt(raw.indexer.allocatedTokens), + stakedTokens: BigInt(raw.indexer.stakedTokens), + url: raw.indexer.url || null, + }, + subgraphDeployment: raw.subgraphDeployment, + })) +} + +/** + * Query TAP escrow accounts from TAP subgraph + */ +export async function queryEscrowAccounts( + apiKey: string, + senderAddresses: string[], + excludedReceivers: string[] = [], +): Promise { + const endpoint = SUBGRAPH_ENDPOINTS.TAP.replace('{apiKey}', apiKey) + + // Normalize addresses to lowercase for subgraph query + const normalizedSenders = senderAddresses.map((addr) => addr.toLowerCase()) + const normalizedExcludedReceivers = excludedReceivers.map((addr) => addr.toLowerCase()) + + interface RawEscrowAccount { + id: string + sender: { id: string } + receiver: { id: string } + balance: string + thawEndTimestamp: string + totalAmountThawing: string + } + + const rawAccounts = await paginateQuery( + endpoint, + ESCROW_ACCOUNTS_QUERY, + { senders: normalizedSenders }, + 'escrowAccounts', + ) + + // Transform raw data to typed escrow accounts and filter out excluded receivers + return rawAccounts + .filter((raw) => !normalizedExcludedReceivers.includes(raw.receiver.id.toLowerCase())) + .map((raw) => ({ + id: raw.id, + sender: raw.sender.id, + receiver: raw.receiver.id, + balance: BigInt(raw.balance), + amountThawing: BigInt(raw.totalAmountThawing), + thawEndTimestamp: BigInt(raw.thawEndTimestamp), + totalAmountThawing: BigInt(raw.totalAmountThawing), + })) +} + +/** + * Group allocations by indexer for summary reporting + */ +export function groupAllocationsByIndexer(allocations: Allocation[]) { + const indexerMap = new Map() + + for (const allocation of allocations) { + const indexerId = allocation.indexer.id + if (!indexerMap.has(indexerId)) { + indexerMap.set(indexerId, []) + } + indexerMap.get(indexerId)!.push(allocation) + } + + return Array.from(indexerMap.entries()).map(([indexer, allocs]) => ({ + indexer, + indexerUrl: allocs[0]?.indexer.url || null, + allocations: allocs, + totalAllocatedTokens: allocs.reduce((sum, a) => sum + a.allocatedTokens, 0n), + allocationCount: allocs.length, + })) +} + +/** + * Group escrow accounts by sender for summary reporting + */ +export function groupEscrowAccountsBySender(accounts: EscrowAccount[]) { + const senderMap = new Map() + + for (const account of accounts) { + const senderId = account.sender + if (!senderMap.has(senderId)) { + senderMap.set(senderId, []) + } + senderMap.get(senderId)!.push(account) + } + + return Array.from(senderMap.entries()).map(([sender, accts]) => ({ + sender, + accounts: accts, + totalBalance: accts.reduce((sum, a) => sum + a.balance, 0n), + totalAmountThawing: accts.reduce((sum, a) => sum + a.amountThawing, 0n), + accountCount: accts.length, + })) +} diff --git a/packages/horizon/tasks/ops/lib/tap-escrow.ts b/packages/horizon/tasks/ops/lib/tap-escrow.ts new file mode 100644 index 000000000..908b4fd76 --- /dev/null +++ b/packages/horizon/tasks/ops/lib/tap-escrow.ts @@ -0,0 +1,301 @@ +/** + * TAP Escrow contract utilities for TAP Escrow Recovery operations + * + * Note: This is for the TAP v1 Escrow contract, which has a different interface + * than the Horizon PaymentsEscrow (v2). The v1 contract uses sender-receiver pairs + * while v2 uses payer-collector-receiver tuples. + */ + +import type { Signer } from 'ethers' +import { Contract, Interface } from 'ethers' + +import type { CalldataEntry, EscrowAccount, ThawResult, WithdrawResult } from './types' +import { DEFAULTS } from './types' + +// ============================================ +// TAP Escrow v1 Contract ABI +// ============================================ + +/** + * Minimal ABI for TAP Escrow v1 contract interactions + * Address: 0x8f477709eF277d4A880801D01A140a9CF88bA0d3 (Arbitrum One) + */ +const TAP_ESCROW_V1_ABI = [ + // State changing functions + 'function thaw(address receiver, uint256 amount) external', + 'function withdraw(address receiver) external', + + // View functions + 'function escrowAccounts(address sender, address receiver) external view returns (uint256 balance, uint256 amountThawing, uint256 thawEndTimestamp)', + 'function withdrawEscrowThawingPeriod() external view returns (uint256)', +] as const + +// ============================================ +// Contract Interface +// ============================================ + +/** + * Get TAP Escrow v1 contract interface for calldata encoding + */ +export function getTapEscrowInterface(): Interface { + return new Interface(TAP_ESCROW_V1_ABI) +} + +/** + * Create TAP Escrow v1 contract instance + */ +export function getTapEscrowContract(signer: Signer, address?: string): Contract { + return new Contract(address ?? DEFAULTS.TAP_ESCROW, TAP_ESCROW_V1_ABI, signer) +} + +// ============================================ +// Calldata Generation +// ============================================ + +/** + * Generate calldata for thawing escrow funds + */ +export function encodeThawCalldata(receiver: string, amount: bigint): string { + const iface = getTapEscrowInterface() + return iface.encodeFunctionData('thaw', [receiver, amount]) +} + +/** + * Generate calldata for withdrawing escrow funds + */ +export function encodeWithdrawCalldata(receiver: string): string { + const iface = getTapEscrowInterface() + return iface.encodeFunctionData('withdraw', [receiver]) +} + +/** + * Generate calldata entries for batch thaw operations + */ +export function generateThawCalldata( + accounts: EscrowAccount[], + escrowAddress: string = DEFAULTS.TAP_ESCROW, +): CalldataEntry[] { + return accounts.map((account) => { + // Thaw the available balance (balance - amountThawing) + const amountToThaw = account.balance - account.amountThawing + if (amountToThaw <= 0n) { + return { + to: escrowAddress, + data: '', + value: '0', + description: `Skip ${account.receiver} - already thawing or no balance`, + } + } + + return { + to: escrowAddress, + data: encodeThawCalldata(account.receiver, amountToThaw), + value: '0', + description: `Thaw ${formatGRT(amountToThaw)} GRT for receiver ${account.receiver}`, + } + }).filter((entry) => entry.data !== '') +} + +/** + * Generate calldata entries for batch withdraw operations + */ +export function generateWithdrawCalldata( + accounts: EscrowAccount[], + escrowAddress: string = DEFAULTS.TAP_ESCROW, +): CalldataEntry[] { + const now = BigInt(Math.floor(Date.now() / 1000)) + + return accounts.filter((account) => { + // Only include accounts that have completed thawing + return account.thawEndTimestamp > 0n && account.thawEndTimestamp <= now + }).map((account) => ({ + to: escrowAddress, + data: encodeWithdrawCalldata(account.receiver), + value: '0', + description: `Withdraw thawed funds for receiver ${account.receiver}`, + })) +} + +// ============================================ +// Transaction Execution +// ============================================ + +/** + * Execute thaw transaction for a single escrow account + */ +export async function executeThaw( + contract: Contract, + account: EscrowAccount, + dryRun: boolean = false, +): Promise { + const amountToThaw = account.balance - account.amountThawing + if (amountToThaw <= 0n) { + return { + success: false, + sender: account.sender, + receiver: account.receiver, + amount: 0n, + error: 'No balance available to thaw', + } + } + + if (dryRun) { + console.log(`[DRY RUN] Would thaw ${formatGRT(amountToThaw)} GRT for receiver ${account.receiver}`) + return { + success: true, + sender: account.sender, + receiver: account.receiver, + amount: amountToThaw, + } + } + + try { + const tx = await contract.thaw(account.receiver, amountToThaw) + const receipt = await tx.wait() + + // Calculate thaw end timestamp (30 days from now) + const thawingPeriod = await contract.withdrawEscrowThawingPeriod() + const thawEndTimestamp = BigInt(Math.floor(Date.now() / 1000)) + thawingPeriod + + return { + success: true, + txHash: receipt.hash, + gasUsed: receipt.gasUsed, + sender: account.sender, + receiver: account.receiver, + amount: amountToThaw, + thawEndTimestamp, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + sender: account.sender, + receiver: account.receiver, + amount: amountToThaw, + } + } +} + +/** + * Execute withdraw transaction for a single escrow account + */ +export async function executeWithdraw( + contract: Contract, + account: EscrowAccount, + dryRun: boolean = false, +): Promise { + const now = BigInt(Math.floor(Date.now() / 1000)) + + if (account.thawEndTimestamp === 0n) { + return { + success: false, + sender: account.sender, + receiver: account.receiver, + amount: 0n, + error: 'No thawing in progress', + } + } + + if (account.thawEndTimestamp > now) { + return { + success: false, + sender: account.sender, + receiver: account.receiver, + amount: 0n, + error: `Still thawing until ${new Date(Number(account.thawEndTimestamp) * 1000).toISOString()}`, + } + } + + const withdrawableAmount = account.amountThawing < account.balance + ? account.amountThawing + : account.balance + + if (dryRun) { + console.log(`[DRY RUN] Would withdraw ~${formatGRT(withdrawableAmount)} GRT for receiver ${account.receiver}`) + return { + success: true, + sender: account.sender, + receiver: account.receiver, + amount: withdrawableAmount, + } + } + + try { + const tx = await contract.withdraw(account.receiver) + const receipt = await tx.wait() + + return { + success: true, + txHash: receipt.hash, + gasUsed: receipt.gasUsed, + sender: account.sender, + receiver: account.receiver, + amount: withdrawableAmount, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + sender: account.sender, + receiver: account.receiver, + amount: withdrawableAmount, + } + } +} + +/** + * Execute batch thaw operations + */ +export async function executeBatchThaw( + contract: Contract, + accounts: EscrowAccount[], + dryRun: boolean = false, + onProgress?: (current: number, total: number, result: ThawResult) => void, +): Promise { + const results: ThawResult[] = [] + + for (let i = 0; i < accounts.length; i++) { + const result = await executeThaw(contract, accounts[i], dryRun) + results.push(result) + onProgress?.(i + 1, accounts.length, result) + } + + return results +} + +/** + * Execute batch withdraw operations + */ +export async function executeBatchWithdraw( + contract: Contract, + accounts: EscrowAccount[], + dryRun: boolean = false, + onProgress?: (current: number, total: number, result: WithdrawResult) => void, +): Promise { + const results: WithdrawResult[] = [] + + for (let i = 0; i < accounts.length; i++) { + const result = await executeWithdraw(contract, accounts[i], dryRun) + results.push(result) + onProgress?.(i + 1, accounts.length, result) + } + + return results +} + +// ============================================ +// Helpers +// ============================================ + +/** + * Format GRT amount for display (18 decimals) + */ +function formatGRT(amount: bigint): string { + const decimals = 18n + const divisor = 10n ** decimals + const whole = amount / divisor + const fraction = amount % divisor + const fractionStr = fraction.toString().padStart(Number(decimals), '0').slice(0, 4) + return `${whole.toLocaleString()}.${fractionStr}` +} diff --git a/packages/horizon/tasks/ops/lib/types.ts b/packages/horizon/tasks/ops/lib/types.ts new file mode 100644 index 000000000..29e09160a --- /dev/null +++ b/packages/horizon/tasks/ops/lib/types.ts @@ -0,0 +1,214 @@ +/** + * Type definitions for TAP Escrow Recovery & Legacy Allocation Closure operations + */ + +// ============================================ +// Legacy Allocation Types +// ============================================ + +/** + * Allocation data from Graph Network subgraph + */ +export interface Allocation { + id: string + allocatedTokens: bigint + createdAtEpoch: number + closedAtEpoch: number | null + createdAtBlockNumber: number + status: AllocationStatus + poi: string | null + indexer: Indexer + subgraphDeployment: SubgraphDeployment +} + +export interface Indexer { + id: string + allocatedTokens: bigint + stakedTokens: bigint + url: string | null +} + +export interface SubgraphDeployment { + id: string + ipfsHash: string +} + +export type AllocationStatus = 'Active' | 'Closed' | 'Finalized' | 'Claimed' + +/** + * Aggregated allocation data by indexer + */ +export interface IndexerAllocationSummary { + indexer: string + indexerUrl: string | null + allocations: Allocation[] + totalAllocatedTokens: bigint + allocationCount: number +} + +// ============================================ +// TAP Escrow Types +// ============================================ + +/** + * Escrow account data from TAP subgraph + */ +export interface EscrowAccount { + id: string + sender: string + receiver: string + balance: bigint + amountThawing: bigint + thawEndTimestamp: bigint + totalAmountThawing: bigint +} + +/** + * Sender summary with all their escrow accounts + */ +export interface SenderEscrowSummary { + sender: string + accounts: EscrowAccount[] + totalBalance: bigint + totalAmountThawing: bigint + accountCount: number +} + +// ============================================ +// Transaction Types +// ============================================ + +/** + * Transaction result for tracking execution + */ +export interface TransactionResult { + success: boolean + txHash?: string + error?: string + gasUsed?: bigint +} + +/** + * Result of closing an allocation + */ +export interface CloseAllocationResult extends TransactionResult { + allocationId: string + indexer: string +} + +/** + * Result of thawing escrow funds + */ +export interface ThawResult extends TransactionResult { + sender: string + receiver: string + amount: bigint + thawEndTimestamp?: bigint +} + +/** + * Result of withdrawing escrow funds + */ +export interface WithdrawResult extends TransactionResult { + sender: string + receiver: string + amount: bigint +} + +// ============================================ +// Calldata Types +// ============================================ + +/** + * Calldata for external execution (Fireblocks, Safe, etc.) + */ +export interface CalldataEntry { + to: string + data: string + value: string + description: string +} + +/** + * Batch of calldata entries + */ +export interface CalldataBatch { + timestamp: string + network: string + chainId: number + entries: CalldataEntry[] +} + +// ============================================ +// Report Types +// ============================================ + +/** + * Report metadata + */ +export interface ReportMetadata { + timestamp: string + network: string + chainId: number + generatedBy: string +} + +/** + * Legacy allocations report + */ +export interface AllocationsReport extends ReportMetadata { + excludedIndexers: string[] + totalAllocations: number + totalAllocatedTokens: bigint + allocations: Allocation[] + summaryByIndexer: IndexerAllocationSummary[] +} + +/** + * TAP escrow report + */ +export interface EscrowReport extends ReportMetadata { + senderAddresses: string[] + excludedReceivers: string[] + totalAccounts: number + totalBalance: bigint + totalAmountThawing: bigint + accounts: EscrowAccount[] + summaryBySender: SenderEscrowSummary[] +} + +/** + * Execution results report + */ +export interface ExecutionReport extends ReportMetadata { + mode: 'execute' | 'calldata-only' | 'dry-run' + totalTransactions: number + successCount: number + failureCount: number + results: T[] +} + +// ============================================ +// Constants +// ============================================ + +/** + * Default configuration values + */ +export const DEFAULTS = { + // Upgrade indexer - excluded by default from allocation closing + UPGRADE_INDEXER: '0xbdfb5ee5a2abf4fc7bb1bd1221067aef7f9de491', + + // Gateway sender addresses for TAP escrow queries + SENDER_ADDRESSES: [ + '0xdde4cffd3d9052a9cb618fc05a1cd02be1f2f467', // Primary (~1,092,951 GRT) + '0xdd6a6f76eb36b873c1c184e8b9b9e762fe216490', // Secondary (~2,843 GRT) + ], + + // Contract addresses (Arbitrum One) + HORIZON_STAKING: '0x00669A4CF01450B64E8A2A20E9b1FCB71E61eF03', + TAP_ESCROW: '0x8f477709eF277d4A880801D01A140a9CF88bA0d3', + + // Output directory + OUTPUT_DIR: './ops-output', +} as const diff --git a/packages/horizon/tasks/ops/poc.ts b/packages/horizon/tasks/ops/poc.ts new file mode 100644 index 000000000..7631d54f4 --- /dev/null +++ b/packages/horizon/tasks/ops/poc.ts @@ -0,0 +1,149 @@ +/** + * Proof-of-concept task for validating fork testing with impersonated accounts + * + * This task validates that we can: + * 1. Connect to a forked Arbitrum One chain + * 2. Impersonate accounts (gateway sender, deployer) + * 3. Read contract state + * 4. Send transactions from impersonated accounts + */ + +import { requireLocalNetwork } from '@graphprotocol/toolshed/hardhat' +import { task } from 'hardhat/config' +import type { HardhatRuntimeEnvironment } from 'hardhat/types' + +import { DEFAULTS } from './lib/types' + +// TAP Escrow v1 ABI (minimal for testing) +const TAP_ESCROW_ABI = [ + 'function escrowAccounts(address sender, address receiver) external view returns (uint256 balance, uint256 amountThawing, uint256 thawEndTimestamp)', + 'function thaw(address receiver, uint256 amount) external', + 'function withdrawEscrowThawingPeriod() external view returns (uint256)', +] + +task('ops:poc', 'Proof-of-concept: test fork and impersonation') + .setAction(async (_, hre: HardhatRuntimeEnvironment) => { + console.log('=== Fork & Impersonation POC ===\n') + + // Ensure we're on a local network (fork) + requireLocalNetwork(hre) + + const chainId = (await hre.ethers.provider.getNetwork()).chainId + console.log(`Connected to network: ${hre.network.name} (chainId: ${chainId})`) + + // Test 1: Read contract state from forked chain + console.log('\n--- Test 1: Read forked chain state ---') + + const escrowContract = new hre.ethers.Contract( + DEFAULTS.TAP_ESCROW, + TAP_ESCROW_ABI, + hre.ethers.provider, + ) + + const thawingPeriod = await escrowContract.withdrawEscrowThawingPeriod() + console.log(`TAP Escrow thawing period: ${thawingPeriod} seconds (${Number(thawingPeriod) / 86400} days)`) + + // Use one of the known sender/receiver pairs + const sender = DEFAULTS.SENDER_ADDRESSES[0] + // Pick a known receiver - we'll query a few to find one with balance + // For POC, let's just check if we can read the contract + console.log(`Sender address: ${sender}`) + + // Test 2: Impersonate the gateway sender account + console.log('\n--- Test 2: Impersonate gateway sender ---') + + const gatewaySigner = await hre.ethers.getImpersonatedSigner(sender) + console.log(`Impersonated signer address: ${gatewaySigner.address}`) + + // Fund the impersonated account with ETH for gas + const [funder] = await hre.ethers.getSigners() + console.log(`Funding from: ${funder.address}`) + + const fundTx = await funder.sendTransaction({ + to: sender, + value: hre.ethers.parseEther('1.0'), + }) + await fundTx.wait() + console.log(`Funded ${sender} with 1 ETH`) + + const balance = await hre.ethers.provider.getBalance(sender) + console.log(`Gateway sender ETH balance: ${hre.ethers.formatEther(balance)} ETH`) + + // Test 3: Try to call a view function with the impersonated account + console.log('\n--- Test 3: Call contract as impersonated account ---') + + const escrowWithSigner = escrowContract.connect(gatewaySigner) + + // Let's try to read an escrow account state + // We need a real receiver address - let's use the upgrade indexer as a test + const testReceiver = DEFAULTS.UPGRADE_INDEXER + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const escrowState = await (escrowWithSigner as any).escrowAccounts(sender, testReceiver) + console.log(`Escrow state for sender=${sender.slice(0, 10)}... receiver=${testReceiver.slice(0, 10)}...`) + console.log(` Balance: ${hre.ethers.formatEther(escrowState.balance)} GRT`) + console.log(` Amount Thawing: ${hre.ethers.formatEther(escrowState.amountThawing)} GRT`) + console.log(` Thaw End Timestamp: ${escrowState.thawEndTimestamp}`) + + // Test 4: Try to send a state-changing transaction (if there's balance) + console.log('\n--- Test 4: Send state-changing transaction ---') + + if (escrowState.balance > 0n && escrowState.balance > escrowState.amountThawing) { + const amountToThaw = escrowState.balance - escrowState.amountThawing + console.log(`Attempting to thaw ${hre.ethers.formatEther(amountToThaw)} GRT...`) + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tx = await (escrowWithSigner as any).thaw(testReceiver, amountToThaw) + const receipt = await tx.wait() + console.log(`Thaw transaction successful!`) + console.log(` TX Hash: ${receipt.hash}`) + console.log(` Gas Used: ${receipt.gasUsed}`) + + // Verify state changed + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const newState = await (escrowWithSigner as any).escrowAccounts(sender, testReceiver) + console.log(`\nNew escrow state:`) + console.log(` Amount Thawing: ${hre.ethers.formatEther(newState.amountThawing)} GRT`) + console.log(` Thaw End Timestamp: ${newState.thawEndTimestamp}`) + console.log(` Thaw End Date: ${new Date(Number(newState.thawEndTimestamp) * 1000).toISOString()}`) + } catch (error) { + console.log(`Thaw transaction failed (this might be expected if no balance): ${error}`) + } + } else { + console.log(`No thawable balance for this sender/receiver pair.`) + console.log(`This is fine - we verified we can read state and impersonate accounts.`) + + // Let's try a simpler test - just verify we CAN send a transaction + // by doing a simple ETH transfer back + console.log(`\nTrying simple ETH transfer to verify tx sending works...`) + const simpleTx = await gatewaySigner.sendTransaction({ + to: funder.address, + value: hre.ethers.parseEther('0.1'), + }) + const simpleReceipt = await simpleTx.wait() + console.log(`Simple transfer successful! TX: ${simpleReceipt?.hash}`) + } + + // Test 5: Test time manipulation + console.log('\n--- Test 5: Time manipulation ---') + + const blockBefore = await hre.ethers.provider.getBlock('latest') + console.log(`Current block timestamp: ${blockBefore?.timestamp} (${new Date((blockBefore?.timestamp || 0) * 1000).toISOString()})`) + + // Advance time by 30 days + const thirtyDays = 30 * 24 * 60 * 60 + await hre.network.provider.send('evm_increaseTime', [thirtyDays]) + await hre.network.provider.send('evm_mine') + + const blockAfter = await hre.ethers.provider.getBlock('latest') + console.log(`After advancing 30 days: ${blockAfter?.timestamp} (${new Date((blockAfter?.timestamp || 0) * 1000).toISOString()})`) + + console.log('\n=== All POC tests passed! ===') + console.log('\nValidated:') + console.log(' - Can connect to forked Arbitrum One') + console.log(' - Can read contract state from fork') + console.log(' - Can impersonate accounts') + console.log(' - Can fund impersonated accounts') + console.log(' - Can send transactions from impersonated accounts') + console.log(' - Can manipulate time (evm_increaseTime)') + }) diff --git a/packages/horizon/tasks/ops/tap-escrow.ts b/packages/horizon/tasks/ops/tap-escrow.ts new file mode 100644 index 000000000..2422dcb79 --- /dev/null +++ b/packages/horizon/tasks/ops/tap-escrow.ts @@ -0,0 +1,526 @@ +/** + * TAP Escrow Operational Tasks + * + * Tasks for querying, thawing, and withdrawing funds from the TAP v1 Escrow contract + * after Horizon migration. + */ + +import { task, types, vars } from 'hardhat/config' +import type { HardhatRuntimeEnvironment } from 'hardhat/types' +import type { Signer } from 'ethers' + +import { getImpersonatedSigner, isLocalNetwork } from './lib/fork-utils' +import { + formatGRT, + generateEscrowReport, + generateExecutionReport, + loadEscrowAccountsFromFile, + printCalldataSummary, + printEscrowSummary, + printExecutionSummary, + writeCalldataBatch, + writeEscrowReport, + writeExecutionReport, +} from './lib/report' +import { groupEscrowAccountsBySender, queryEscrowAccounts } from './lib/subgraph' +import { + executeBatchThaw, + executeBatchWithdraw, + generateThawCalldata, + generateWithdrawCalldata, + getTapEscrowContract, +} from './lib/tap-escrow' +import type { CalldataBatch, EscrowAccount } from './lib/types' +import { DEFAULTS } from './lib/types' + +// ============================================ +// Query Escrow Accounts Task +// ============================================ + +task('ops:escrow:query', 'Query and report TAP escrow accounts from TAP subgraph') + .addOptionalParam( + 'subgraphApiKey', + 'API key for The Graph Network gateway (can also use SUBGRAPH_API_KEY hardhat var)', + undefined, + types.string, + ) + .addOptionalParam( + 'senderAddresses', + 'Comma-separated list of sender addresses to query', + DEFAULTS.SENDER_ADDRESSES.join(','), + types.string, + ) + .addOptionalParam( + 'excludedReceivers', + 'Comma-separated list of receiver addresses to exclude', + DEFAULTS.UPGRADE_INDEXER, + types.string, + ) + .addOptionalParam('outputDir', 'Output directory for reports', DEFAULTS.OUTPUT_DIR, types.string) + .setAction(async (args, hre: HardhatRuntimeEnvironment) => { + console.log('\n========== Query TAP Escrow Accounts ==========') + + // Get API key from args or hardhat vars + let apiKey = args.subgraphApiKey + if (!apiKey) { + if (!vars.has('SUBGRAPH_API_KEY')) { + throw new Error('No subgraph API key provided. Set --subgraph-api-key or use `npx hardhat vars set SUBGRAPH_API_KEY`') + } + apiKey = vars.get('SUBGRAPH_API_KEY') + } + + // Parse sender addresses + const senderAddresses = args.senderAddresses + .split(',') + .map((addr: string) => addr.trim().toLowerCase()) + .filter((addr: string) => addr.length > 0) + + // Parse excluded receivers + const excludedReceivers = args.excludedReceivers + .split(',') + .map((addr: string) => addr.trim().toLowerCase()) + .filter((addr: string) => addr.length > 0) + + console.log(`Network: ${hre.network.name}`) + console.log(`Chain ID: ${hre.network.config.chainId}`) + console.log(`Sender Addresses: ${senderAddresses.join(', ')}`) + console.log(`Excluded Receivers: ${excludedReceivers.join(', ')}`) + console.log('') + + // Query subgraph + console.log('Querying TAP subgraph for escrow accounts...') + const accounts = await queryEscrowAccounts(apiKey, senderAddresses, excludedReceivers) + + if (accounts.length === 0) { + console.log('No escrow accounts found with balance > 0.') + return + } + + // Group by sender for summary + const summaryBySender = groupEscrowAccountsBySender(accounts) + + // Generate and write report + const report = generateEscrowReport( + accounts, + summaryBySender, + senderAddresses, + excludedReceivers, + hre.network.name, + hre.network.config.chainId!, + ) + + const { jsonPath, csvPath } = writeEscrowReport(report, args.outputDir) + + // Print summary + printEscrowSummary(report) + + console.log('\nReports written to:') + console.log(` JSON: ${jsonPath}`) + console.log(` CSV: ${csvPath}`) + }) + +// ============================================ +// Thaw Escrow Task +// ============================================ + +task('ops:escrow:thaw', 'Initiate thawing for TAP escrow accounts') + .addOptionalParam( + 'inputFile', + 'JSON file with escrow accounts (from ops:escrow:query). If not provided, queries subgraph.', + undefined, + types.string, + ) + .addOptionalParam( + 'subgraphApiKey', + 'API key for The Graph Network gateway (required if no inputFile)', + undefined, + types.string, + ) + .addOptionalParam( + 'senderAddresses', + 'Comma-separated list of sender addresses to query', + DEFAULTS.SENDER_ADDRESSES.join(','), + types.string, + ) + .addOptionalParam( + 'excludedReceivers', + 'Comma-separated list of receiver addresses to exclude', + DEFAULTS.UPGRADE_INDEXER, + types.string, + ) + .addOptionalParam('limit', 'Maximum number of accounts to thaw (0 = all)', 0, types.int) + .addOptionalParam('accountIndex', 'Derivation path index for the gateway account', 0, types.int) + .addOptionalParam('escrowAddress', 'TAP Escrow contract address', DEFAULTS.TAP_ESCROW, types.string) + .addOptionalParam('outputDir', 'Output directory for reports', DEFAULTS.OUTPUT_DIR, types.string) + .addFlag('calldataOnly', 'Generate calldata without executing transactions') + .addFlag('dryRun', 'Simulate without executing transactions') + .setAction(async (args, hre: HardhatRuntimeEnvironment) => { + console.log('\n========== Thaw TAP Escrow Accounts ==========') + console.log(`Network: ${hre.network.name}`) + console.log(`Chain ID: ${hre.network.config.chainId}`) + console.log(`Mode: ${args.calldataOnly ? 'Calldata Only' : args.dryRun ? 'Dry Run' : 'Execute'}`) + console.log(`Escrow Contract: ${args.escrowAddress}`) + + // Get escrow accounts either from file or subgraph + let accounts: EscrowAccount[] + + if (args.inputFile) { + console.log(`Loading escrow accounts from: ${args.inputFile}`) + accounts = loadEscrowAccountsFromFile(args.inputFile) + } else { + // Query subgraph + let apiKey = args.subgraphApiKey + if (!apiKey) { + if (!vars.has('SUBGRAPH_API_KEY')) { + throw new Error('No subgraph API key provided. Set --subgraph-api-key or use `npx hardhat vars set SUBGRAPH_API_KEY`') + } + apiKey = vars.get('SUBGRAPH_API_KEY') + } + + const senderAddresses = args.senderAddresses + .split(',') + .map((addr: string) => addr.trim().toLowerCase()) + .filter((addr: string) => addr.length > 0) + + const excludedReceivers = args.excludedReceivers + .split(',') + .map((addr: string) => addr.trim().toLowerCase()) + .filter((addr: string) => addr.length > 0) + + console.log('Querying subgraph for escrow accounts...') + accounts = await queryEscrowAccounts(apiKey, senderAddresses, excludedReceivers) + } + + // Filter to accounts that have thawable balance + let thawableAccounts = accounts.filter((a) => a.balance > a.amountThawing) + + if (thawableAccounts.length === 0) { + console.log('No accounts with thawable balance found.') + return + } + + // Apply limit if specified (accounts are sorted by balance descending) + if (args.limit > 0 && thawableAccounts.length > args.limit) { + console.log(`Limiting to first ${args.limit} accounts (of ${thawableAccounts.length} total)`) + thawableAccounts = thawableAccounts.slice(0, args.limit) + } + + const totalThawable = thawableAccounts.reduce( + (sum, a) => sum + (a.balance - a.amountThawing), + 0n, + ) + + console.log(`\nFound ${thawableAccounts.length} accounts with thawable balance`) + console.log(`Total thawable GRT: ${formatGRT(totalThawable)}`) + + // Calldata-only mode + if (args.calldataOnly) { + const entries = generateThawCalldata(thawableAccounts, args.escrowAddress) + + const batch: CalldataBatch = { + timestamp: new Date().toISOString(), + network: hre.network.name, + chainId: hre.network.config.chainId!, + entries, + } + + const filePath = writeCalldataBatch(batch, 'thaw-escrow', args.outputDir) + printCalldataSummary(batch, 'Thaw Escrow') + console.log(`\nCalldata written to: ${filePath}`) + return + } + + // Group accounts by sender for impersonation + const accountsBySender = new Map() + for (const account of thawableAccounts) { + const sender = account.sender.toLowerCase() + if (!accountsBySender.has(sender)) { + accountsBySender.set(sender, []) + } + accountsBySender.get(sender)!.push(account) + } + + // Execute transactions grouped by sender + console.log('\nThawing escrow accounts...') + const results: Awaited> = [] + let totalProcessed = 0 + + for (const [senderAddress, senderAccounts] of accountsBySender) { + // Get signer for this sender + let signer: Signer + if (isLocalNetwork(hre)) { + // On local networks, impersonate the sender address + console.log(`\nImpersonating sender: ${senderAddress}`) + signer = await getImpersonatedSigner(hre, senderAddress) + } else { + // On mainnet, use secure accounts + const graph = hre.graph() + signer = await graph.accounts.getGateway(args.accountIndex) + const signerAddress = await signer.getAddress() + if (signerAddress.toLowerCase() !== senderAddress) { + console.log(`Warning: Gateway address ${signerAddress} does not match sender ${senderAddress}`) + } + } + + const signerAddress = await signer.getAddress() + const balance = await hre.ethers.provider.getBalance(signerAddress) + console.log(`Using account: ${signerAddress} (balance: ${hre.ethers.formatEther(balance)} ETH)`) + + if (balance === 0n && !args.dryRun && !isLocalNetwork(hre)) { + throw new Error('Account has no ETH balance') + } + + // Get TAP Escrow contract with this signer + const escrowContract = getTapEscrowContract(signer, args.escrowAddress) + + // Execute batch for this sender + const senderResults = await executeBatchThaw( + escrowContract, + senderAccounts, + args.dryRun, + (current, total, result) => { + totalProcessed++ + if (result.success) { + console.log(`[${totalProcessed}/${thawableAccounts.length}] Thawed ${formatGRT(result.amount)} GRT for ${result.receiver}`) + } else { + console.log(`[${totalProcessed}/${thawableAccounts.length}] Failed for ${result.receiver}: ${result.error}`) + } + }, + ) + + results.push(...senderResults) + } + + // Generate and write execution report + const report = generateExecutionReport( + results, + args.dryRun ? 'dry-run' : 'execute', + hre.network.name, + hre.network.config.chainId!, + 'ops:escrow:thaw', + ) + + const filePath = writeExecutionReport(report, 'thaw-escrow', args.outputDir) + printExecutionSummary(report, 'Thaw Escrow') + console.log(`\nResults written to: ${filePath}`) + + // Print thaw end timestamp for successful thaws + const successfulThaws = results.filter((r) => r.success && r.thawEndTimestamp) + if (successfulThaws.length > 0 && !args.dryRun) { + const thawEndDate = new Date(Number(successfulThaws[0].thawEndTimestamp) * 1000) + console.log(`\nThaw period ends: ${thawEndDate.toISOString()}`) + console.log('Run ops:escrow:withdraw after this date to complete withdrawal.') + } + }) + +// ============================================ +// Withdraw Escrow Task +// ============================================ + +task('ops:escrow:withdraw', 'Withdraw thawed funds from TAP escrow accounts') + .addOptionalParam( + 'inputFile', + 'JSON file with escrow accounts (from ops:escrow:query). If not provided, queries subgraph.', + undefined, + types.string, + ) + .addOptionalParam( + 'subgraphApiKey', + 'API key for The Graph Network gateway (required if no inputFile)', + undefined, + types.string, + ) + .addOptionalParam( + 'senderAddresses', + 'Comma-separated list of sender addresses to query', + DEFAULTS.SENDER_ADDRESSES.join(','), + types.string, + ) + .addOptionalParam( + 'excludedReceivers', + 'Comma-separated list of receiver addresses to exclude', + DEFAULTS.UPGRADE_INDEXER, + types.string, + ) + .addOptionalParam('limit', 'Maximum number of accounts to withdraw (0 = all)', 0, types.int) + .addOptionalParam('accountIndex', 'Derivation path index for the gateway account', 0, types.int) + .addOptionalParam('escrowAddress', 'TAP Escrow contract address', DEFAULTS.TAP_ESCROW, types.string) + .addOptionalParam('outputDir', 'Output directory for reports', DEFAULTS.OUTPUT_DIR, types.string) + .addFlag('calldataOnly', 'Generate calldata without executing transactions') + .addFlag('dryRun', 'Simulate without executing transactions') + .setAction(async (args, hre: HardhatRuntimeEnvironment) => { + console.log('\n========== Withdraw TAP Escrow Accounts ==========') + console.log(`Network: ${hre.network.name}`) + console.log(`Chain ID: ${hre.network.config.chainId}`) + console.log(`Mode: ${args.calldataOnly ? 'Calldata Only' : args.dryRun ? 'Dry Run' : 'Execute'}`) + console.log(`Escrow Contract: ${args.escrowAddress}`) + + // Get escrow accounts either from file or subgraph + let accounts: EscrowAccount[] + + if (args.inputFile) { + console.log(`Loading escrow accounts from: ${args.inputFile}`) + accounts = loadEscrowAccountsFromFile(args.inputFile) + } else { + // Query subgraph + let apiKey = args.subgraphApiKey + if (!apiKey) { + if (!vars.has('SUBGRAPH_API_KEY')) { + throw new Error('No subgraph API key provided. Set --subgraph-api-key or use `npx hardhat vars set SUBGRAPH_API_KEY`') + } + apiKey = vars.get('SUBGRAPH_API_KEY') + } + + const senderAddresses = args.senderAddresses + .split(',') + .map((addr: string) => addr.trim().toLowerCase()) + .filter((addr: string) => addr.length > 0) + + const excludedReceivers = args.excludedReceivers + .split(',') + .map((addr: string) => addr.trim().toLowerCase()) + .filter((addr: string) => addr.length > 0) + + console.log('Querying subgraph for escrow accounts...') + accounts = await queryEscrowAccounts(apiKey, senderAddresses, excludedReceivers) + } + + // Filter to accounts that have completed thawing + const now = BigInt(Math.floor(Date.now() / 1000)) + let withdrawableAccounts = accounts.filter( + (a) => a.thawEndTimestamp > 0n && a.thawEndTimestamp <= now, + ) + + if (withdrawableAccounts.length === 0) { + console.log('\nNo accounts ready for withdrawal.') + + // Show accounts that are still thawing + const stillThawing = accounts.filter((a) => a.thawEndTimestamp > now) + if (stillThawing.length > 0) { + console.log('\nAccounts still thawing:') + for (const account of stillThawing.slice(0, 5)) { + const thawEndDate = new Date(Number(account.thawEndTimestamp) * 1000) + console.log(` ${account.receiver}: thaw ends ${thawEndDate.toISOString()}`) + } + if (stillThawing.length > 5) { + console.log(` ... and ${stillThawing.length - 5} more`) + } + } + + // Show accounts that haven't started thawing + const notThawing = accounts.filter((a) => a.thawEndTimestamp === 0n && a.balance > 0n) + if (notThawing.length > 0) { + console.log(`\n${notThawing.length} accounts have not started thawing. Run ops:escrow:thaw first.`) + } + + return + } + + // Apply limit if specified + if (args.limit > 0 && withdrawableAccounts.length > args.limit) { + console.log(`Limiting to first ${args.limit} accounts (of ${withdrawableAccounts.length} total)`) + withdrawableAccounts = withdrawableAccounts.slice(0, args.limit) + } + + const totalWithdrawable = withdrawableAccounts.reduce((sum, a) => sum + a.amountThawing, 0n) + + console.log(`\nFound ${withdrawableAccounts.length} accounts ready for withdrawal`) + console.log(`Total withdrawable GRT: ${formatGRT(totalWithdrawable)}`) + + // Calldata-only mode + if (args.calldataOnly) { + const entries = generateWithdrawCalldata(withdrawableAccounts, args.escrowAddress) + + const batch: CalldataBatch = { + timestamp: new Date().toISOString(), + network: hre.network.name, + chainId: hre.network.config.chainId!, + entries, + } + + const filePath = writeCalldataBatch(batch, 'withdraw-escrow', args.outputDir) + printCalldataSummary(batch, 'Withdraw Escrow') + console.log(`\nCalldata written to: ${filePath}`) + return + } + + // Group accounts by sender for impersonation + const accountsBySender = new Map() + for (const account of withdrawableAccounts) { + const sender = account.sender.toLowerCase() + if (!accountsBySender.has(sender)) { + accountsBySender.set(sender, []) + } + accountsBySender.get(sender)!.push(account) + } + + // Execute transactions grouped by sender + console.log('\nWithdrawing escrow accounts...') + const results: Awaited> = [] + let totalProcessed = 0 + + for (const [senderAddress, senderAccounts] of accountsBySender) { + // Get signer for this sender + let signer: Signer + if (isLocalNetwork(hre)) { + // On local networks, impersonate the sender address + console.log(`\nImpersonating sender: ${senderAddress}`) + signer = await getImpersonatedSigner(hre, senderAddress) + } else { + // On mainnet, use secure accounts + const graph = hre.graph() + signer = await graph.accounts.getGateway(args.accountIndex) + const signerAddress = await signer.getAddress() + if (signerAddress.toLowerCase() !== senderAddress) { + console.log(`Warning: Gateway address ${signerAddress} does not match sender ${senderAddress}`) + } + } + + const signerAddress = await signer.getAddress() + const balance = await hre.ethers.provider.getBalance(signerAddress) + console.log(`Using account: ${signerAddress} (balance: ${hre.ethers.formatEther(balance)} ETH)`) + + if (balance === 0n && !args.dryRun && !isLocalNetwork(hre)) { + throw new Error('Account has no ETH balance') + } + + // Get TAP Escrow contract with this signer + const escrowContract = getTapEscrowContract(signer, args.escrowAddress) + + // Execute batch for this sender + const senderResults = await executeBatchWithdraw( + escrowContract, + senderAccounts, + args.dryRun, + (current, total, result) => { + totalProcessed++ + if (result.success) { + console.log(`[${totalProcessed}/${withdrawableAccounts.length}] Withdrew ~${formatGRT(result.amount)} GRT for ${result.receiver}`) + } else { + console.log(`[${totalProcessed}/${withdrawableAccounts.length}] Failed for ${result.receiver}: ${result.error}`) + } + }, + ) + + results.push(...senderResults) + } + + // Generate and write execution report + const report = generateExecutionReport( + results, + args.dryRun ? 'dry-run' : 'execute', + hre.network.name, + hre.network.config.chainId!, + 'ops:escrow:withdraw', + ) + + const filePath = writeExecutionReport(report, 'withdraw-escrow', args.outputDir) + printExecutionSummary(report, 'Withdraw Escrow') + console.log(`\nResults written to: ${filePath}`) + + // Print total withdrawn + const totalWithdrawn = results + .filter((r) => r.success) + .reduce((sum, r) => sum + r.amount, 0n) + console.log(`\nTotal GRT withdrawn: ${formatGRT(totalWithdrawn)}`) + }) diff --git a/packages/horizon/tasks/ops/test-utils.ts b/packages/horizon/tasks/ops/test-utils.ts new file mode 100644 index 000000000..c4960539c --- /dev/null +++ b/packages/horizon/tasks/ops/test-utils.ts @@ -0,0 +1,34 @@ +/** + * Test utility tasks for fork testing operations + */ + +import { task, types } from 'hardhat/config' +import type { HardhatRuntimeEnvironment } from 'hardhat/types' + +import { advanceTimeDays, getBlockTimestamp, requireLocalNetwork } from './lib/fork-utils' + +// ============================================ +// Time Skip Task +// ============================================ + +task('ops:time-skip', 'Advance blockchain time (local networks only)') + .addParam('days', 'Number of days to advance', undefined, types.int) + .setAction(async (args, hre: HardhatRuntimeEnvironment) => { + requireLocalNetwork(hre) + + const beforeTimestamp = await getBlockTimestamp(hre) + const beforeDate = new Date(beforeTimestamp * 1000) + + console.log(`\n========== Time Skip ==========`) + console.log(`Network: ${hre.network.name}`) + console.log(`Current timestamp: ${beforeTimestamp} (${beforeDate.toISOString()})`) + console.log(`Advancing time by ${args.days} days...`) + + await advanceTimeDays(hre, args.days) + + const afterTimestamp = await getBlockTimestamp(hre) + const afterDate = new Date(afterTimestamp * 1000) + + console.log(`New timestamp: ${afterTimestamp} (${afterDate.toISOString()})`) + console.log(`Time advanced successfully!`) + }) diff --git a/packages/horizon/tasks/ops/verify.ts b/packages/horizon/tasks/ops/verify.ts new file mode 100644 index 000000000..386867eee --- /dev/null +++ b/packages/horizon/tasks/ops/verify.ts @@ -0,0 +1,253 @@ +/** + * Verification tasks for fork testing operations + * + * These tasks verify that state changes have been applied correctly by + * reading on-chain state directly (not from subgraph). + */ + +import * as fs from 'fs' +import { task, types } from 'hardhat/config' +import type { HardhatRuntimeEnvironment } from 'hardhat/types' + +import { formatGRT } from './lib/report' +import { getTapEscrowContract } from './lib/tap-escrow' +import type { + CloseAllocationResult, + EscrowAccount, + EscrowReport, + ExecutionReport, + ThawResult, + WithdrawResult, +} from './lib/types' +import { DEFAULTS } from './lib/types' + +// ============================================ +// Allocation State Enum (from HorizonStaking) +// ============================================ + +enum AllocationState { + Null, + Active, + Closed, + Finalized, +} + +// ============================================ +// Verification Task +// ============================================ + +task('ops:verify', 'Verify state changes after ops tasks') + .addParam('type', 'Verification type: allocations, escrow-thaw, escrow-withdraw', undefined, types.string) + .addParam('inputFile', 'JSON file with execution results (from close/thaw/withdraw task output)', undefined, types.string) + .addOptionalParam('originalFile', 'Original escrow accounts file (required for escrow-withdraw)', undefined, types.string) + .addOptionalParam('escrowAddress', 'TAP Escrow contract address', DEFAULTS.TAP_ESCROW, types.string) + .setAction(async (args, hre: HardhatRuntimeEnvironment) => { + console.log(`\n========== Verify: ${args.type} ==========`) + console.log(`Network: ${hre.network.name}`) + console.log(`Input file: ${args.inputFile}`) + + // Load input file + const content = fs.readFileSync(args.inputFile, 'utf-8') + + switch (args.type) { + case 'allocations': + await verifyAllocations(hre, content) + break + case 'escrow-thaw': + await verifyEscrowThaw(hre, content, args.escrowAddress) + break + case 'escrow-withdraw': + if (!args.originalFile) { + throw new Error('--original-file is required for escrow-withdraw verification') + } + await verifyEscrowWithdraw(hre, content, args.originalFile, args.escrowAddress) + break + default: + throw new Error(`Unknown verification type: ${args.type}. Valid types: allocations, escrow-thaw, escrow-withdraw`) + } + }) + +// ============================================ +// Allocation Verification +// ============================================ + +async function verifyAllocations( + hre: HardhatRuntimeEnvironment, + content: string, +): Promise { + const report = JSON.parse(content) as ExecutionReport + + // Only verify successful operations + const successfulOps = report.results.filter((r) => r.success) + if (successfulOps.length === 0) { + console.log('No successful operations to verify.') + return + } + + console.log(`\nVerifying ${successfulOps.length} closed allocations...`) + + const graph = hre.graph() + const horizonStaking = graph.horizon.contracts.HorizonStaking + + let verified = 0 + let failed = 0 + + for (const result of successfulOps) { + try { + const state = await horizonStaking.getAllocationState(result.allocationId) + + if (state === BigInt(AllocationState.Closed) || state === BigInt(AllocationState.Finalized)) { + verified++ + console.log(` [OK] ${result.allocationId} - State: ${AllocationState[Number(state)]}`) + } else { + failed++ + console.log(` [FAIL] ${result.allocationId} - Expected Closed/Finalized, got: ${AllocationState[Number(state)]}`) + } + } catch (error) { + failed++ + const errorMessage = error instanceof Error ? error.message : String(error) + console.log(` [ERROR] ${result.allocationId} - ${errorMessage}`) + } + } + + console.log(`\nVerification complete: ${verified} verified, ${failed} failed`) + + if (failed > 0) { + throw new Error(`Verification failed for ${failed} allocations`) + } + + console.log('All allocations verified as Closed!') +} + +// ============================================ +// Escrow Thaw Verification +// ============================================ + +async function verifyEscrowThaw( + hre: HardhatRuntimeEnvironment, + content: string, + escrowAddress: string, +): Promise { + const report = JSON.parse(content) as ExecutionReport + + // Only verify successful operations + const successfulOps = report.results.filter((r) => r.success) + if (successfulOps.length === 0) { + console.log('No successful operations to verify.') + return + } + + console.log(`\nVerifying ${successfulOps.length} thawed escrow accounts...`) + + // Get a signer for reading contract state + const [signer] = await hre.ethers.getSigners() + const escrowContract = getTapEscrowContract(signer, escrowAddress) + + let verified = 0 + let failed = 0 + + for (const result of successfulOps) { + try { + const account = await escrowContract.escrowAccounts(result.sender, result.receiver) + const thawEndTimestamp = account[2] // [balance, amountThawing, thawEndTimestamp] + + if (thawEndTimestamp > 0n) { + verified++ + const thawEndDate = new Date(Number(thawEndTimestamp) * 1000) + console.log(` [OK] ${result.receiver} - Thaw ends: ${thawEndDate.toISOString()}`) + } else { + failed++ + console.log(` [FAIL] ${result.receiver} - thawEndTimestamp is 0`) + } + } catch (error) { + failed++ + const errorMessage = error instanceof Error ? error.message : String(error) + console.log(` [ERROR] ${result.receiver} - ${errorMessage}`) + } + } + + console.log(`\nVerification complete: ${verified} verified, ${failed} failed`) + + if (failed > 0) { + throw new Error(`Verification failed for ${failed} escrow accounts`) + } + + console.log('All accounts verified with thaw in progress!') +} + +// ============================================ +// Escrow Withdraw Verification +// ============================================ + +async function verifyEscrowWithdraw( + hre: HardhatRuntimeEnvironment, + content: string, + originalFilePath: string, + escrowAddress: string, +): Promise { + const report = JSON.parse(content) as ExecutionReport + + // Only verify successful operations + const successfulOps = report.results.filter((r) => r.success) + if (successfulOps.length === 0) { + console.log('No successful operations to verify.') + return + } + + // Load original balances from the query file + const originalContent = fs.readFileSync(originalFilePath, 'utf-8') + const originalReport = JSON.parse(originalContent) as EscrowReport + + // Create a map of original balances + const originalBalances = new Map() + for (const account of originalReport.accounts) { + // Key by sender-receiver pair + const key = `${account.sender.toLowerCase()}-${account.receiver.toLowerCase()}` + originalBalances.set(key, BigInt(account.balance as unknown as string)) + } + + console.log(`\nVerifying ${successfulOps.length} withdrawn escrow accounts...`) + + // Get a signer for reading contract state + const [signer] = await hre.ethers.getSigners() + const escrowContract = getTapEscrowContract(signer, escrowAddress) + + let verified = 0 + let failed = 0 + + for (const result of successfulOps) { + try { + const key = `${result.sender.toLowerCase()}-${result.receiver.toLowerCase()}` + const originalBalance = originalBalances.get(key) + + if (!originalBalance) { + console.log(` [WARN] ${result.receiver} - Original balance not found in query file`) + continue + } + + const account = await escrowContract.escrowAccounts(result.sender, result.receiver) + const currentBalance = account[0] // [balance, amountThawing, thawEndTimestamp] + + if (currentBalance < originalBalance) { + verified++ + const withdrawn = originalBalance - currentBalance + console.log(` [OK] ${result.receiver} - Withdrew ${formatGRT(withdrawn)} GRT (${formatGRT(originalBalance)} -> ${formatGRT(currentBalance)})`) + } else { + failed++ + console.log(` [FAIL] ${result.receiver} - Balance not reduced (${formatGRT(originalBalance)} -> ${formatGRT(currentBalance)})`) + } + } catch (error) { + failed++ + const errorMessage = error instanceof Error ? error.message : String(error) + console.log(` [ERROR] ${result.receiver} - ${errorMessage}`) + } + } + + console.log(`\nVerification complete: ${verified} verified, ${failed} failed`) + + if (failed > 0) { + throw new Error(`Verification failed for ${failed} escrow accounts`) + } + + console.log('All accounts verified with reduced balance!') +}