diff --git a/.gitignore b/.gitignore index 429e58be..bd37e796 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,12 @@ deployments/**/31337.* broadcast/**/dry-run/** script/deploy/tasks/1337/** script/deploy/tasks/31337/** +aggregator-hooks/.env +aggregator-hooks/checkpoints/ +aggregator-hooks/detected/ +aggregator-hooks/node_modules/ +deployed-pools/ + # Smoke test broadcast logs are transient validation artifacts, not deployment records broadcast/V2SmokeTest.s.sol/** broadcast/V3SmokeTest.s.sol/** diff --git a/.gitmodules b/.gitmodules index 7bc547ea..2efc9571 100644 --- a/.gitmodules +++ b/.gitmodules @@ -61,6 +61,9 @@ [submodule "src/pkgs/universal-router-2_0"] path = src/pkgs/universal-router-2_0 url = https://github.com/Uniswap/universal-router -[submodule "src/pkgs/v4-hooks-public"] - path = src/pkgs/v4-hooks-public +[submodule "lib/v4-hooks-public"] + path = lib/v4-hooks-public url = https://github.com/Uniswap/v4-hooks-public +[submodule "lib/v4-core"] + path = lib/v4-core + url = https://github.com/Uniswap/v4-core diff --git a/aggregator-hooks/.env.example b/aggregator-hooks/.env.example new file mode 100644 index 00000000..cafe37a6 --- /dev/null +++ b/aggregator-hooks/.env.example @@ -0,0 +1,43 @@ +# Environment variables for discovery scripts. +# All chain-specific vars must use VAR_ (e.g. RPC_URL_1, RPC_URL_8453). + +# createPools.ts +# RPC_URL_ required when using --chain-id +# PRIVATE_KEY= required for forge script (even with --dry-run) + +# Optional: RPS, CONCURRENCY (for stableswapng, fluiddext1 historical) +# RPS_1= +# CONCURRENCY_1= + +# Chain ID +#RPC_URL_= +#POOL_MANAGER_= # createPools self-deploy +#FLUID_LIQUIDITY_= # createPools self-deploy (fluiddext1 only) +#FLUID_DEX_LITE_= # discovery + createPools self-deploy (fluiddexlite) +#FLUID_DEX_LITE_RESOLVER_= # discovery + createPools self-deploy (fluiddexlite) +#FLUID_DEX_T1_FACTORY_= +# FluidDexT1: both resolvers required for pool creation; resolver only for discovery (historical/polling) +#FLUID_DEX_T1_RESOLVER_= # IFluidDexResolver (getDexTokens, estimateSwap) +#FLUID_DEX_T1_RESERVES_RESOLVER_= # IFluidDexReservesResolver (getPoolReserves) + +# StableSwap (legacy Curve): MetaRegistry for meta pool rejection (createPools self-deploy) +#STABLESWAP_METAREGISTRY_= + +# StableSwap-NG: Curve factory (meta pool rejection). Defaults to mainnet if unset. +#STABLESWAPNG_FACTORY_= + +# ── Singleton aggregator types (UniswapV3, UniswapV2, Slipstream, PancakeSwapV3) ── +# One aggregator contract is deployed per chain and reused for all pools of that type. +# The address is written automatically to .env after the first deploy; pre-set it to +# skip re-deployment on subsequent runs. + +#UNISWAP_V3_AGGREGATOR_= # auto-written on first deploy (uniswapv3) +#UNISWAP_V2_AGGREGATOR_= # auto-written on first deploy (uniswapv2) +#SLIPSTREAM_AGGREGATOR_= # auto-written on first deploy (slipstream) +#PANCAKESWAP_V3_AGGREGATOR_= # auto-written on first deploy (pancakeswapv3) + +# External DEX factory addresses (required for singleton aggregator deploy) +#UNISWAP_V3_FACTORY_= +#UNISWAP_V2_FACTORY_= +#SLIPSTREAM_FACTORY_= +#PANCAKESWAP_V3_FACTORY_= diff --git a/aggregator-hooks/README.md b/aggregator-hooks/README.md new file mode 100644 index 00000000..4d6fd041 --- /dev/null +++ b/aggregator-hooks/README.md @@ -0,0 +1,289 @@ +# Aggregator Hook Scripts + +Aggregator hook pool discovery and creation utilities. Merged from agg-hook-tests. + +## Factories + +Aggregator Hook factories should be deployed before running any of these scripts, although not strictly necessary. If desired you can deploy and initialize directly from an EOA. If Factories are not deployed, you must use --self-deploy when creating pools. + +## Scripts + +### Historical discovery (one-time full scrape) + +| Script | Description | +| ---------------------------- | ----------------------------------------------------------------- | +| `historical/FluidDexLite.ts` | Scrape LogInitialize events from FluidDexLite | +| `historical/FluidDexT1.ts` | Scrape LogDexDeployed events from FluidDexFactory | +| `historical/StableSwapNG.ts` | Enumerate pool_count + pool_list from Curve StableSwap-NG factory | + +### Polling discovery (incremental, checkpoint-based) + +Polling scripts scan only new blocks since the last run. They load and update a checkpoint file to avoid re-processing. Use these for ongoing discovery (e.g. cron) instead of historical one-time scrapes. + +| Script | Description | +| ------------------------- | ---------------------------------------------------------------------------- | +| `polling/FluidDexLite.ts` | Scan LogInitialize events from FluidDexLite since checkpoint | +| `polling/FluidDexT1.ts` | Scan LogDexDeployed events from FluidDexFactory; fetch tokens via resolver | +| `polling/StableSwapNG.ts` | Scan PlainPoolDeployed/MetaPoolDeployed events; fetch new pools from factory | + +All polling scripts append new pools to the same output files used by historical scripts, in createPools format. + +**Polling options:** + +| Arg | Default | Description | +| ------------------ | ------------- | --------------------------------------------------------------- | +| `--checkpoint-dir` | `checkpoints` | Where to store/load checkpoint files (`{dir}/{chainId}/*.json`) | +| `--start-block` | — | Override checkpoint; start scan from this block | + +**Polling env vars:** + +| Env | Default | Description | +| ----------------- | ------- | --------------------------------------------------------------------------- | +| `FINALITY_BLOCKS` | 10 | Blocks to subtract from latest; checkpoint stops before finality for safety | +| `LOOKBACK_BLOCKS` | 200000 | Blocks to scan when checkpoint is missing and no `--start-block` given | + +### Pool creation + +| Script | Description | +| -------------------- | ---------------------------------------------------- | +| `src/createPools.ts` | Create Uniswap v4 hooks from discovered pool configs | + +--- + +## Environment variables + +All discovery scripts use chain-ID-suffixed env vars. Use `VAR_` (e.g. `RPC_URL_1`, `RPC_URL_8453`) or `VAR` for single-chain usage. + +### By script + +| Script | Required | Optional | +| ---------------- | ------------------------------- | ------------------------------------------------------------ | +| **fluiddexlite** | `RPC_URL` | `FLUID_DEX_LITE_RESOLVER` (default mainnet resolver) | +| **fluiddext1** | `RPC_URL`, `FLUID_DEX_T1_RESOLVER` | `FLUID_DEX_T1_FACTORY`, `RPS`, `CONCURRENCY` | +| **stableswapng** | `RPC_URL` | `STABLESWAPNG_FACTORY`, `RPS`, `CONCURRENCY` | + +**Polling scripts** use the same env vars as their historical counterparts (fluiddexlite, fluiddext1, stableswapng), plus `FINALITY_BLOCKS` and `LOOKBACK_BLOCKS` (see Polling env vars above). FluidDexLite polling also requires `FLUID_DEX_LITE`. + +--- + +## CLI arguments + +All discovery scripts require `--chain-id `. + +### Common args + +| Arg | Default | Description | +| ---------------- | ---------- | ------------------------------------------------------------------ | +| `--chain-id` | (required) | Chain ID; selects env vars | +| `--output-dir` | `detected` | Output base dir; files go to `output-dir/chain-id/.json` | +| `--chunk-blocks` | 100000 | Block chunk size for getLogs | +| `--start-block` | — | Start scan from this block | + +### Discovery args + +| Arg | Default | Description | +| ------------- | ------- | ------------------------ | +| `--end-block` | latest | End block for event scan | + +### Script-specific args + +| Script | Arg | Default | Description | +| ------------ | ------------------ | ------------- | ------------------------------- | +| fluiddext1 | `--mode` | enumerate | `logs` \| `enumerate` \| `both` | +| stableswapng | `--chunk` | 500 | pool_list batch size | +| stableswapng | `--start-index` | 0 | Start pool_list index | +| **polling** | `--checkpoint-dir` | `checkpoints` | Checkpoint storage directory | + +--- + +## Output paths + +- **Output**: `{OUTPUT_DIR}/{CHAIN_ID}/{OUTPUT_FILE}.json` + +| Script | Output file | +| ------------ | ----------------------- | +| fluiddexlite | fluiddexlite-pools.json | +| fluiddext1 | fluiddext1-pools.json | +| stableswapng | stableswapng-pools.json | + +Polling scripts write to the same output paths and append new pools. Checkpoints go to `{checkpoint-dir}/{chainId}/{protocol}_checkpoint.json`. + +--- + +## Example invocations + +**Important:** Run all commands from the `aggregator-hooks/` directory so the scripts load `.env` from that folder. + +```bash +# From contracts/aggregator-hooks/ directory: +cd aggregator-hooks && npm install +cp .env.example .env +# Edit .env with your values, then run: + +# Historical fluiddexlite on mainnet +npx tsx historical/FluidDexLite.ts --chain-id 1 + +# Historical fluiddext1 on Base +npx tsx historical/FluidDexT1.ts --chain-id 8453 --output-dir output + +# Historical stableswapng with custom chunk +npx tsx historical/StableSwapNG.ts --chain-id 1 --chunk 200 + +# Polling (incremental; use cron or run periodically) +npx tsx polling/FluidDexLite.ts --chain-id 1 +npx tsx polling/FluidDexT1.ts --chain-id 8453 --output-dir output +npx tsx polling/StableSwapNG.ts --chain-id 1 --checkpoint-dir checkpoints + +# createPools with chain-id (uses RPC_URL_1 from env) +npx tsx src/createPools.ts detected/1/fluiddexlite-pools-curated.json 0xFactoryAddr --chain-id 1 + +# createPools with Etherscan verification (ETHERSCAN_API_KEY must be set) +npx tsx src/createPools.ts detected/1/fluiddexlite-pools-curated.json 0xFactoryAddr --chain-id 1 --verify + +# createPools self-deploy with Blockscout verification (BLOCKSCOUT_API_URL_1 must be set in .env) +npx tsx src/createPools.ts detected/1/fluiddext1-pools-curated.json --self-deploy --chain-id 1 --verify +``` + +--- + +## createPools + +### Arguments + +| Arg | Required | Default | Description | +| ------------------------------ | -------- | --------------- | ---------------------------------------------------------------------------- | +| `jsonFile` | yes | — | Path to JSON file with pool configs (each must have `poolType`) | +| `factoryAddress` | yes\* | — | Factory contract address (\*required when not using `--self-deploy`) | +| `--self-deploy` | no | — | Deploy hooks from wallet instead of via factory | +| `--chain-id ` | no | — | Chain ID; selects `RPC_URL_` from env | +| `--registry-dir ` | no | `created-pools` | Append deployed pools to `deployed-.json` in this dir | +| `--dry-run` | no | — | Simulate forge scripts without broadcasting | +| `--verbose`, `-v` | no | — | Run forge scripts with `-vvvv` | +| `--start-at ` | no | 1 | Start at 1-based pool index (skip earlier pools). Use to resume. | +| `--jobs `, `-j ` | no | 1 | Parallel salt mining workers (1–16). Speeds up mining. | +| `--priority-gas-price ` | no | RPC default | Max priority fee per gas for EIP1559 (e.g. `3gwei`). Speeds up tx inclusion. | +| `--verify` | no | — | Submit hook contract for block explorer verification after deployment | +| `--verifier ` | no | `etherscan` | Verifier backend: `etherscan`, `blockscout`, or `sourcify` | +| `--verifier-url ` | no | — | Custom verifier API URL. Auto-selected if `BLOCKSCOUT_API_URL` env is set. | +| `--compiler-version ` | no | from artifact | Solc version to pass to the verifier (e.g. `0.8.24`). See note below. | + +**Modes:** + +- **Factory mode:** `createPools.ts pools.json 0xFactoryAddr [--chain-id 1] [--registry-dir ./deployed-pools]` +- **Self-deploy:** `createPools.ts pools.json --self-deploy [--chain-id 1] [--registry-dir ./deployed-pools]` + +### Environment variables + +| Env | Description | +| ------------------------------------------------------ | ---------------------------------------------------------------------------------- | +| `RPC_URL` or `RPC_URL_` | RPC endpoint (use `RPC_URL_1` etc. when `--chain-id` is set) | +| `PRIVATE_KEY` | Signing key for transactions (required even with `--dry-run`) | +| `POOL_MANAGER` or `POOL_MANAGER_` | Uniswap v4 PoolManager address (required for self-deploy) | +| `ETHERSCAN_API_KEY` or `ETHERSCAN_API_KEY_` | API key for Etherscan verification (required when using `--verify` with Etherscan) | +| `BLOCKSCOUT_API_URL` or `BLOCKSCOUT_API_URL_` | Blockscout API URL. If set, `--verify` automatically uses Blockscout. | + +**StableSwap (stableswap):** `STABLESWAP_METAREGISTRY_` — Curve MetaRegistry for meta pool rejection (required for self-deploy). + +**StableSwap-NG (stableswapng):** `STABLESWAPNG_FACTORY_` — Curve StableSwap NG factory for meta pool rejection. Defaults to mainnet `0x6A8cbed756804B16E05E741eDaBd5cB544AE21bf` if unset. + +### Security + +- `PRIVATE_KEY` is passed to forge via the process environment only (not the command line). + +### Dry run and verbose + +Use `--dry-run` to simulate without broadcasting: + +```bash +npx tsx src/createPools.ts your-pools.json --self-deploy --chain-id 1 --dry-run +``` + +Use `--verbose` or `-v` to run forge scripts with maximum verbosity (`-vvvv`) and log full forge output on errors. + +### Contract verification + +Pass `--verify` to automatically submit each deployed hook for block explorer verification after deployment. Verification failures are non-fatal — the script logs a warning and continues. + +**Etherscan:** + +```bash +# Requires ETHERSCAN_API_KEY or ETHERSCAN_API_KEY_1 in env +npx tsx src/createPools.ts pools.json 0xFactory --chain-id 1 --verify +npx tsx src/createPools.ts pools.json --self-deploy --chain-id 1 --verify +``` + +**Blockscout (via `--verifier-url`):** + +```bash +npx tsx src/createPools.ts pools.json 0xFactory --chain-id 1 --verify \ + --verifier blockscout \ + --verifier-url https://eth.blockscout.com/api +``` + +**Blockscout (via env var, no extra flags needed):** + +Set `BLOCKSCOUT_API_URL` (or `BLOCKSCOUT_API_URL_`) in `.env`. When this is set, `--verify` automatically uses Blockscout without needing `--verifier` or `--verifier-url` on the command line: + +```bash +# .env +BLOCKSCOUT_API_URL_1=https://eth.blockscout.com/api +BLOCKSCOUT_API_URL_8453=https://base.blockscout.com/api + +# Then just: +npx tsx src/createPools.ts pools.json 0xFactory --chain-id 1 --verify +``` + +**Well-known Blockscout API URLs:** + +| Chain | Chain ID | URL | +| -------- | -------- | ------------------------------------- | +| Mainnet | 1 | `https://eth.blockscout.com/api` | +| Base | 8453 | `https://base.blockscout.com/api` | +| Arbitrum | 42161 | `https://arbitrum.blockscout.com/api` | +| Optimism | 10 | `https://optimism.blockscout.com/api` | + +> **Note:** Blockscout's public instances do not require an API key. `ETHERSCAN_API_KEY` can be omitted when using Blockscout. + +#### Compiler version and factory mode + +In factory mode, the factory embeds the hook's bytecode at _its own_ compile time. Verification must use the same solc version the factory was compiled with — not necessarily the version currently installed locally. + +The script auto-detects the compiler version from the build artifact in `out/` (populated by `forge build`). This is reliable **as long as** the installed forge/solc version matches what built the factory. If it doesn't, pass `--compiler-version` explicitly: + +```bash +# Factory was compiled with 0.8.24; local solc is different +npx tsx src/createPools.ts pools.json 0xFactory --chain-id 1 --verify --compiler-version 0.8.24 +``` + +To check what version the factory was originally built with, look at the git history or the `out/` artifact at the time of factory deployment. In self-deploy mode this is not an issue — forge compiles and verifies with the same local version automatically. + +--- + +## Setup + +### TypeScript scripts + +```bash +cd aggregator-hooks +npm install +cp .env.example .env +# Edit .env with your values +``` + +**Run all discovery and createPools commands from the `aggregator-hooks/` directory** so they load the `.env` file in that folder. + +### Solidity (mine_hook, createPools) + +The `createPools` script and `mine_hook.sh` run from the **contracts/** directory (project root). They require: + +> **Note:** When running `createPools` via `npx tsx src/createPools.ts`, run it from **aggregator-hooks/** so it loads `aggregator-hooks/.env`. The forge scripts invoked by createPools run from contracts/ but inherit env vars from the parent process. + +1. **v4-hooks-public** (`main` branch): Already added as submodule. Track latest `main` (from **contracts/** root): + + ```bash + git submodule update --init --recursive + (cd lib/v4-hooks-public && git fetch origin main && git checkout main && git pull origin main) + git submodule update --init --recursive + ``` + +2. **Foundry**: `forge` must be available. The scripts use `lib/v4-hooks-public/script/SelfCreateHook.s.sol` and `lib/v4-hooks-public/script/MineAggregatorHook.s.sol`. diff --git a/aggregator-hooks/abis/FluidDexLiteFactory.json b/aggregator-hooks/abis/FluidDexLiteFactory.json new file mode 100644 index 00000000..d7d3869a --- /dev/null +++ b/aggregator-hooks/abis/FluidDexLiteFactory.json @@ -0,0 +1,6 @@ +[ + "function POOL_MANAGER() external view returns (address)", + "function FLUID_DEX_LITE() external view returns (address)", + "function FLUID_DEX_LITE_RESOLVER() external view returns (address)", + "function createPool(bytes32 salt, bytes32 dexSalt, address currency0, address currency1, uint24 fee, int24 tickSpacing, uint160 sqrtPriceX96) external returns (address hook)" +] diff --git a/aggregator-hooks/abis/FluidDexLiteResolver.json b/aggregator-hooks/abis/FluidDexLiteResolver.json new file mode 100644 index 00000000..51be0746 --- /dev/null +++ b/aggregator-hooks/abis/FluidDexLiteResolver.json @@ -0,0 +1,4 @@ +[ + "function getAllDexes() external view returns (tuple(address token0, address token1, bytes32 salt)[] memory)", + "function getDexState(tuple(address token0, address token1, bytes32 salt) dexKey) external view returns (tuple(tuple(uint256 fee, uint256 revenueCut, uint256 rebalancingStatus, bool isCenterPriceShiftActive, uint256 centerPrice, address centerPriceAddress, bool isRangePercentShiftActive, uint256 upperRangePercent, uint256 lowerRangePercent, bool isThresholdPercentShiftActive, uint256 upperShiftThresholdPercent, uint256 lowerShiftThresholdPercent, uint256 token0Decimals, uint256 token1Decimals, uint256 totalToken0AdjustedAmount, uint256 totalToken1AdjustedAmount) dexVariables, tuple(uint256 lastInteractionTimestamp, uint256 rebalancingShiftingTime, uint256 maxCenterPrice, uint256 minCenterPrice, uint256 shiftPercentage, uint256 centerPriceShiftingTime, uint256 startTimestamp) centerPriceShift, tuple(uint256 oldUpperRangePercent, uint256 oldLowerRangePercent, uint256 shiftingTime, uint256 startTimestamp) rangeShift, tuple(uint256 oldUpperThresholdPercent, uint256 oldLowerThresholdPercent, uint256 shiftingTime, uint256 startTimestamp) thresholdShift) dexState)" +] diff --git a/aggregator-hooks/abis/FluidDexT1Factory.json b/aggregator-hooks/abis/FluidDexT1Factory.json new file mode 100644 index 00000000..80463ddb --- /dev/null +++ b/aggregator-hooks/abis/FluidDexT1Factory.json @@ -0,0 +1,7 @@ +[ + "function POOL_MANAGER() external view returns (address)", + "function fluidDexReservesResolver() external view returns (address)", + "function fluidDexResolver() external view returns (address)", + "function FLUID_LIQUIDITY() external view returns (address)", + "function createPool(bytes32 salt, address fluidPool, address currency0, address currency1, uint24 fee, int24 tickSpacing, uint160 sqrtPriceX96) external returns (address hook)" +] diff --git a/aggregator-hooks/abis/FluidDexT1HistoricalFactory.json b/aggregator-hooks/abis/FluidDexT1HistoricalFactory.json new file mode 100644 index 00000000..f17fd7f9 --- /dev/null +++ b/aggregator-hooks/abis/FluidDexT1HistoricalFactory.json @@ -0,0 +1,6 @@ +[ + "event LogDexDeployed(address indexed dex, uint256 indexed dexId)", + "function totalDexes() external view returns (uint256)", + "function getDexAddress(uint256 dexId) public view returns (address)", + "function isDex(address dex) public view returns (bool)" +] diff --git a/aggregator-hooks/abis/FluidDexT1Resolver.json b/aggregator-hooks/abis/FluidDexT1Resolver.json new file mode 100644 index 00000000..edaf9880 --- /dev/null +++ b/aggregator-hooks/abis/FluidDexT1Resolver.json @@ -0,0 +1,3 @@ +[ + "function getDexTokens(address dex_) external view returns (address token0_, address token1_)" +] diff --git a/aggregator-hooks/abis/StableSwapNGFactory.json b/aggregator-hooks/abis/StableSwapNGFactory.json new file mode 100644 index 00000000..8bb2efec --- /dev/null +++ b/aggregator-hooks/abis/StableSwapNGFactory.json @@ -0,0 +1,5 @@ +[ + "function poolManager() external view returns (address)", + "function curveFactory() external view returns (address)", + "function createPool(bytes32 salt, address curvePool, address[] calldata tokens, uint24 fee, int24 tickSpacing, uint160 sqrtPriceX96) external returns (address hook)" +] diff --git a/aggregator-hooks/abis/StableSwapNGHistoricalFactory.json b/aggregator-hooks/abis/StableSwapNGHistoricalFactory.json new file mode 100644 index 00000000..56a96be0 --- /dev/null +++ b/aggregator-hooks/abis/StableSwapNGHistoricalFactory.json @@ -0,0 +1,7 @@ +[ + "function pool_count() view returns (uint256)", + "function pool_list(uint256) view returns (address)", + "function get_n_coins(address) view returns (uint256)", + "function get_coins(address) view returns (address[])", + "function get_base_pool(address) view returns (address)" +] diff --git a/aggregator-hooks/abis/StableswapFactory.json b/aggregator-hooks/abis/StableswapFactory.json new file mode 100644 index 00000000..41ba6727 --- /dev/null +++ b/aggregator-hooks/abis/StableswapFactory.json @@ -0,0 +1,6 @@ +[ + "function POOL_MANAGER() external view returns (address)", + "function poolManager() external view returns (address)", + "function metaRegistry() external view returns (address)", + "function createPool(bytes32 salt, address curvePool, address[] calldata tokens, uint24 fee, int24 tickSpacing, uint160 sqrtPriceX96) external returns (address hook)" +] diff --git a/aggregator-hooks/abis/index.ts b/aggregator-hooks/abis/index.ts new file mode 100644 index 00000000..778b113e --- /dev/null +++ b/aggregator-hooks/abis/index.ts @@ -0,0 +1,27 @@ +/** + * Load ABIs via createRequire (avoids import attributes for Prettier compatibility). + */ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +export const STABLESWAP_FACTORY_ABI: string[] = [ + ...require('./StableswapFactory.json'), +]; +export const STABLESWAPNG_FACTORY_ABI: string[] = [ + ...require('./StableSwapNGFactory.json'), +]; +export const FLUIDDEXT1_FACTORY_ABI: string[] = [ + ...require('./FluidDexT1Factory.json'), +]; +export const FLUIDDEXLITE_FACTORY_ABI: string[] = [ + ...require('./FluidDexLiteFactory.json'), +]; +export const FLUIDDEXT1_HISTORICAL_FACTORY_ABI = + require('./FluidDexT1HistoricalFactory.json') as readonly string[]; +export const FLUIDDEXT1_RESOLVER_ABI = + require('./FluidDexT1Resolver.json') as readonly string[]; +export const STABLESWAPNG_HISTORICAL_FACTORY_ABI = + require('./StableSwapNGHistoricalFactory.json') as readonly string[]; +export const FLUIDDEXLITE_RESOLVER_ABI = + require('./FluidDexLiteResolver.json') as readonly string[]; diff --git a/aggregator-hooks/creation-modules/FluidDexLite.ts b/aggregator-hooks/creation-modules/FluidDexLite.ts new file mode 100644 index 00000000..2b784a73 --- /dev/null +++ b/aggregator-hooks/creation-modules/FluidDexLite.ts @@ -0,0 +1,131 @@ +/** + * FluidDex Lite aggregator hook deployment module. + */ +import { ethers } from 'ethers'; +import { mustEnvForChain } from '../src/cli.js'; +import { FLUIDDEXLITE_FACTORY_ABI } from '../abis/index.js'; +import { + DEFAULT_SQRT_PRICE_X96, + type Address, + type CreationModule, + type FactoryImmutables, +} from './types.js'; + +export interface FluidDexLitePoolConfig { + poolType: 'fluiddexlite'; + dexSalt: string; + currency0: Address; + currency1: Address; + fee: number | null; + tickSpacing: number | null; + sqrtPriceX96: bigint | null; +} + +const PROTOCOL_ID = 0xf3; + +const orderPair = (a: Address, b: Address): [Address, Address] => + a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a]; + +export const fluiddexliteModule: CreationModule = { + poolType: 'fluiddexlite', + protocolId: PROTOCOL_ID, + factoryAbi: FLUIDDEXLITE_FACTORY_ABI, + contractIdentifier: + 'lib/v4-hooks-public/src/aggregator-hooks/implementations/FluidDexLite/FluidDexLiteAggregator.sol:FluidDexLiteAggregator', + + getHookParams(config) { + return { + fee: config.fee ?? 0, + tickSpacing: config.tickSpacing ?? 60, + sqrtPriceX96: config.sqrtPriceX96 ?? DEFAULT_SQRT_PRICE_X96, + }; + }, + + buildPoolKeys(config, hookAddress) { + const params = this.getHookParams(config); + const [c0, c1] = orderPair(config.currency0, config.currency1); + return [ + { + currency0: c0, + currency1: c1, + fee: params.fee, + tickSpacing: params.tickSpacing, + hooks: hookAddress, + }, + ]; + }, + + getExternalPool(config) { + return config.dexSalt; + }, + + getImmutablesFromEnv(chainId: number): FactoryImmutables { + return { + poolManager: mustEnvForChain('POOL_MANAGER', chainId) as Address, + fluidDexLite: mustEnvForChain('FLUID_DEX_LITE', chainId) as Address, + fluidDexLiteResolver: mustEnvForChain( + 'FLUID_DEX_LITE_RESOLVER', + chainId, + ) as Address, + }; + }, + + async readFactoryImmutables(provider, factoryAddress) { + const factory = new ethers.Contract( + factoryAddress, + FLUIDDEXLITE_FACTORY_ABI, + provider, + ); + const [poolManager, fluidDexLite, fluidDexLiteResolver] = await Promise.all( + [ + factory.POOL_MANAGER(), + factory.FLUID_DEX_LITE(), + factory.FLUID_DEX_LITE_RESOLVER(), + ], + ); + return { + poolManager: poolManager as Address, + fluidDexLite: fluidDexLite as Address, + fluidDexLiteResolver: fluidDexLiteResolver as Address, + }; + }, + + encodeConstructorArgs(config, immutables) { + const encoded = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address', 'address', 'bytes32'], + [ + immutables.poolManager, + immutables.fluidDexLite, + immutables.fluidDexLiteResolver, + config.dexSalt, + ], + ); + return encoded.startsWith('0x') ? encoded : `0x${encoded}`; + }, + + buildSelfDeployEnvVars(config, immutables) { + const params = this.getHookParams(config); + return { + FLUID_DEX_LITE: immutables.fluidDexLite!, + FLUID_DEX_LITE_RESOLVER: immutables.fluidDexLiteResolver!, + DEX_SALT: config.dexSalt, + TOKENS: [config.currency0, config.currency1].join(','), + FEE: params.fee.toString(), + TICK_SPACING: params.tickSpacing.toString(), + SQRT_PRICE_X96: params.sqrtPriceX96.toString(), + }; + }, + + buildCreatePoolArgs(config, salt) { + const params = this.getHookParams(config); + return [ + salt, + config.dexSalt, + config.currency0, + config.currency1, + params.fee, + params.tickSpacing, + params.sqrtPriceX96, + ]; + }, +}; diff --git a/aggregator-hooks/creation-modules/FluidDexT1.ts b/aggregator-hooks/creation-modules/FluidDexT1.ts new file mode 100644 index 00000000..f1c4b5c9 --- /dev/null +++ b/aggregator-hooks/creation-modules/FluidDexT1.ts @@ -0,0 +1,142 @@ +/** + * FluidDex T1 aggregator hook deployment module. + */ +import { ethers } from 'ethers'; +import { mustEnvForChain } from '../src/cli.js'; +import { FLUIDDEXT1_FACTORY_ABI } from '../abis/index.js'; +import { + DEFAULT_SQRT_PRICE_X96, + type Address, + type CreationModule, + type FactoryImmutables, +} from './types.js'; + +export interface FluidDexT1PoolConfig { + poolType: 'fluiddext1'; + fluidPool: Address; + currency0: Address; + currency1: Address; + fee: number | null; + tickSpacing: number | null; + sqrtPriceX96: bigint | null; +} + +const PROTOCOL_ID = 0xf1; + +const orderPair = (a: Address, b: Address): [Address, Address] => + a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a]; + +export const fluiddext1Module: CreationModule = { + poolType: 'fluiddext1', + protocolId: PROTOCOL_ID, + factoryAbi: FLUIDDEXT1_FACTORY_ABI, + contractIdentifier: + 'lib/v4-hooks-public/src/aggregator-hooks/implementations/FluidDexT1/FluidDexT1Aggregator.sol:FluidDexT1Aggregator', + + getHookParams(config) { + return { + fee: config.fee ?? 0, + tickSpacing: config.tickSpacing ?? 60, + sqrtPriceX96: config.sqrtPriceX96 ?? DEFAULT_SQRT_PRICE_X96, + }; + }, + + buildPoolKeys(config, hookAddress) { + const params = this.getHookParams(config); + const [c0, c1] = orderPair(config.currency0, config.currency1); + return [ + { + currency0: c0, + currency1: c1, + fee: params.fee, + tickSpacing: params.tickSpacing, + hooks: hookAddress, + }, + ]; + }, + + getExternalPool(config) { + return config.fluidPool; + }, + + getImmutablesFromEnv(chainId: number): FactoryImmutables { + return { + poolManager: mustEnvForChain('POOL_MANAGER', chainId) as Address, + fluidDexReservesResolver: mustEnvForChain( + 'FLUID_DEX_T1_RESERVES_RESOLVER', + chainId, + ) as Address, + fluidDexResolver: mustEnvForChain( + 'FLUID_DEX_T1_RESOLVER', + chainId, + ) as Address, + fluidLiquidity: mustEnvForChain('FLUID_LIQUIDITY', chainId) as Address, + }; + }, + + async readFactoryImmutables(provider, factoryAddress) { + const factory = new ethers.Contract( + factoryAddress, + FLUIDDEXT1_FACTORY_ABI, + provider, + ); + const [ + poolManager, + fluidDexReservesResolver, + fluidDexResolver, + fluidLiquidity, + ] = await Promise.all([ + factory.POOL_MANAGER(), + factory.fluidDexReservesResolver(), + factory.fluidDexResolver(), + factory.FLUID_LIQUIDITY(), + ]); + return { + poolManager: poolManager as Address, + fluidDexReservesResolver: fluidDexReservesResolver as Address, + fluidDexResolver: fluidDexResolver as Address, + fluidLiquidity: fluidLiquidity as Address, + }; + }, + + encodeConstructorArgs(config, immutables) { + const encoded = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address', 'address', 'address', 'address'], + [ + immutables.poolManager, + config.fluidPool, + immutables.fluidDexReservesResolver, + immutables.fluidDexResolver, + immutables.fluidLiquidity, + ], + ); + return encoded.startsWith('0x') ? encoded : `0x${encoded}`; + }, + + buildSelfDeployEnvVars(config, immutables) { + const params = this.getHookParams(config); + return { + FLUID_POOL: config.fluidPool, + FLUID_DEX_T1_RESERVES_RESOLVER: immutables.fluidDexReservesResolver!, + FLUID_DEX_T1_RESOLVER: immutables.fluidDexResolver!, + FLUID_LIQUIDITY: immutables.fluidLiquidity!, + TOKENS: [config.currency0, config.currency1].join(','), + FEE: params.fee.toString(), + TICK_SPACING: params.tickSpacing.toString(), + SQRT_PRICE_X96: params.sqrtPriceX96.toString(), + }; + }, + + buildCreatePoolArgs(config, salt) { + const params = this.getHookParams(config); + return [ + salt, + config.fluidPool, + config.currency0, + config.currency1, + params.fee, + params.tickSpacing, + params.sqrtPriceX96, + ]; + }, +}; diff --git a/aggregator-hooks/creation-modules/PancakeSwapV3.ts b/aggregator-hooks/creation-modules/PancakeSwapV3.ts new file mode 100644 index 00000000..457b2dff --- /dev/null +++ b/aggregator-hooks/creation-modules/PancakeSwapV3.ts @@ -0,0 +1,131 @@ +/** + * PancakeSwap V3 singleton aggregator hook deployment module. + * + * One PancakeSwapV3Aggregator is deployed per chain. Identical to + * UniswapV3Aggregator in routing logic (fee-tier factory lookup) but + * implements the PancakeSwap V3 swap callback ABI instead of Uniswap V3. + */ +import { ethers } from 'ethers'; +import { mustEnvForChain } from '../src/cli.js'; +import { + DEFAULT_SQRT_PRICE_X96, + type Address, + type CreationModule, + type FactoryImmutables, + type PoolKeyRecord, +} from './types.js'; + +export interface PancakeSwapV3PoolConfig { + poolType: 'pancakeswapv3'; + /** The existing PancakeSwap V3 pool address being wrapped */ + v3Pool: Address; + currency0: Address; + currency1: Address; + /** PancakeSwap V3 fee tier (e.g. 100, 500, 2500, 10000) */ + fee: number; + tickSpacing?: number | null; + sqrtPriceX96?: bigint | null; +} + +const PROTOCOL_ID = 0x93; + +const AGGREGATOR_ABI = [ + 'function poolManager() view returns (address)', + 'function factory() view returns (address)', +]; + +const orderPair = (a: Address, b: Address): [Address, Address] => + a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a]; + +export const pancakeswapv3Module: CreationModule = { + poolType: 'pancakeswapv3', + protocolId: PROTOCOL_ID, + factoryAbi: [], + contractIdentifier: + 'lib/v4-hooks-public/src/aggregator-hooks/implementations/PancakeSwapV3/PancakeSwapV3Aggregator.sol:PancakeSwapV3Aggregator', + isSingleton: true, + aggregatorEnvKey: 'PANCAKESWAP_V3_AGGREGATOR', + + getHookParams(config) { + return { + fee: config.fee, + tickSpacing: config.tickSpacing ?? 60, + sqrtPriceX96: config.sqrtPriceX96 ?? DEFAULT_SQRT_PRICE_X96, + }; + }, + + buildPoolKeys(config, hookAddress) { + const params = this.getHookParams(config); + const [c0, c1] = orderPair(config.currency0, config.currency1); + return [ + { + currency0: c0, + currency1: c1, + fee: params.fee, + tickSpacing: params.tickSpacing, + hooks: hookAddress, + }, + ]; + }, + + getExternalPool(config) { + return config.v3Pool; + }, + + getImmutablesFromEnv(chainId) { + return { + poolManager: mustEnvForChain('POOL_MANAGER', chainId) as Address, + externalFactory: mustEnvForChain( + 'PANCAKESWAP_V3_FACTORY', + chainId, + ) as Address, + }; + }, + + async readFactoryImmutables(provider, aggregatorAddress) { + const aggregator = new ethers.Contract( + aggregatorAddress, + AGGREGATOR_ABI, + provider, + ); + const [poolManager, externalFactory] = await Promise.all([ + aggregator.poolManager(), + aggregator.factory(), + ]); + return { + poolManager: poolManager as Address, + externalFactory: externalFactory as Address, + }; + }, + + encodeConstructorArgs(_config, immutables) { + const encoded = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address', 'string'], + [ + immutables.poolManager, + immutables.externalFactory, + 'PancakeSwapV3Aggregator v1.0', + ], + ); + return encoded.startsWith('0x') ? encoded : `0x${encoded}`; + }, + + buildSelfDeployEnvVars(_config, immutables) { + return { + EXTERNAL_FACTORY: immutables.externalFactory!, + HOOK_VERSION: 'PancakeSwapV3Aggregator v1.0', + }; + }, + + buildCreatePoolArgs(_config, _salt) { + throw new Error( + 'PancakeSwapV3 uses singleton self-deploy mode; factory mode (createPool) is not supported.', + ); + }, + + buildInitializeArgs(config, hookAddress): [PoolKeyRecord, bigint] { + const [poolKey] = this.buildPoolKeys(config, hookAddress); + const sqrtPriceX96 = config.sqrtPriceX96 ?? DEFAULT_SQRT_PRICE_X96; + return [poolKey, sqrtPriceX96]; + }, +}; diff --git a/aggregator-hooks/creation-modules/Slipstream.ts b/aggregator-hooks/creation-modules/Slipstream.ts new file mode 100644 index 00000000..5ec4e97d --- /dev/null +++ b/aggregator-hooks/creation-modules/Slipstream.ts @@ -0,0 +1,129 @@ +/** + * Slipstream singleton aggregator hook deployment module. + * + * One SlipstreamAggregator is deployed per chain. Each pool config entry + * registers a new V4 pool backed by an existing Slipstream CL pool (resolved + * by tickSpacing via the Slipstream factory, rather than fee tier as in V3). + */ +import { ethers } from 'ethers'; +import { mustEnvForChain } from '../src/cli.js'; +import { + DEFAULT_SQRT_PRICE_X96, + type Address, + type CreationModule, + type FactoryImmutables, + type PoolKeyRecord, +} from './types.js'; + +export interface SlipstreamPoolConfig { + poolType: 'slipstream'; + /** The existing Slipstream pool address being wrapped */ + slipstreamPool: Address; + currency0: Address; + currency1: Address; + /** Slipstream tickSpacing — used for both pool lookup and V4 PoolKey (fee is always DYNAMIC_FEE_FLAG = 0x800000) */ + tickSpacing: number; + sqrtPriceX96?: bigint | null; +} + +const PROTOCOL_ID = 0xa1; + +const DYNAMIC_FEE_FLAG = 0x800000; + +const AGGREGATOR_ABI = [ + 'function poolManager() view returns (address)', + 'function factory() view returns (address)', +]; + +const orderPair = (a: Address, b: Address): [Address, Address] => + a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a]; + +export const slipstreamModule: CreationModule = { + poolType: 'slipstream', + protocolId: PROTOCOL_ID, + factoryAbi: [], + contractIdentifier: + 'lib/v4-hooks-public/src/aggregator-hooks/implementations/Slipstream/SlipstreamAggregator.sol:SlipstreamAggregator', + isSingleton: true, + aggregatorEnvKey: 'SLIPSTREAM_AGGREGATOR', + + getHookParams(config) { + return { + fee: DYNAMIC_FEE_FLAG, // LPFeeLibrary.DYNAMIC_FEE_FLAG — required by SlipstreamAggregator._resolveExternalPool + tickSpacing: config.tickSpacing, + sqrtPriceX96: config.sqrtPriceX96 ?? DEFAULT_SQRT_PRICE_X96, + }; + }, + + buildPoolKeys(config, hookAddress) { + const params = this.getHookParams(config); + const [c0, c1] = orderPair(config.currency0, config.currency1); + return [ + { + currency0: c0, + currency1: c1, + fee: params.fee, + tickSpacing: params.tickSpacing, + hooks: hookAddress, + }, + ]; + }, + + getExternalPool(config) { + return config.slipstreamPool; + }, + + getImmutablesFromEnv(chainId) { + return { + poolManager: mustEnvForChain('POOL_MANAGER', chainId) as Address, + externalFactory: mustEnvForChain( + 'SLIPSTREAM_FACTORY', + chainId, + ) as Address, + }; + }, + + async readFactoryImmutables(provider, aggregatorAddress) { + const aggregator = new ethers.Contract( + aggregatorAddress, + AGGREGATOR_ABI, + provider, + ); + const [poolManager, externalFactory] = await Promise.all([ + aggregator.poolManager(), + aggregator.factory(), + ]); + return { + poolManager: poolManager as Address, + externalFactory: externalFactory as Address, + }; + }, + + encodeConstructorArgs(_config, immutables) { + // SlipstreamAggregator constructor: (IPoolManager manager, address slipstreamFactory) + // hookVersion is hardcoded in the contract; not a constructor arg. + const encoded = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address'], + [immutables.poolManager, immutables.externalFactory], + ); + return encoded.startsWith('0x') ? encoded : `0x${encoded}`; + }, + + buildSelfDeployEnvVars(_config, immutables) { + return { + EXTERNAL_FACTORY: immutables.externalFactory!, + }; + }, + + buildCreatePoolArgs(_config, _salt) { + throw new Error( + 'Slipstream uses singleton self-deploy mode; factory mode (createPool) is not supported.', + ); + }, + + buildInitializeArgs(config, hookAddress): [PoolKeyRecord, bigint] { + const [poolKey] = this.buildPoolKeys(config, hookAddress); + const sqrtPriceX96 = config.sqrtPriceX96 ?? DEFAULT_SQRT_PRICE_X96; + return [poolKey, sqrtPriceX96]; + }, +}; diff --git a/aggregator-hooks/creation-modules/StableSwap.ts b/aggregator-hooks/creation-modules/StableSwap.ts new file mode 100644 index 00000000..ecfc9785 --- /dev/null +++ b/aggregator-hooks/creation-modules/StableSwap.ts @@ -0,0 +1,142 @@ +/** + * StableSwap (Curve) aggregator hook deployment module. + * Requires Curve MetaRegistry address for meta pool rejection. + */ +import { ethers } from 'ethers'; +import { getEnvForChain, mustEnvForChain } from '../src/cli.js'; +import { STABLESWAP_FACTORY_ABI } from '../abis/index.js'; +import { + DEFAULT_SQRT_PRICE_X96, + type Address, + type CreationModule, + type FactoryImmutables, + type PoolKeyRecord, +} from './types.js'; + +export interface StableSwapPoolConfig { + poolType: 'stableswap'; + curvePool: Address; + tokens: Address[]; + fee: number | null; + tickSpacing: number | null; + sqrtPriceX96: bigint | null; +} + +const PROTOCOL_ID = 0xc1; + +const orderPair = (a: Address, b: Address): [Address, Address] => + a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a]; + +export const stableswapModule: CreationModule = { + poolType: 'stableswap', + protocolId: PROTOCOL_ID, + factoryAbi: STABLESWAP_FACTORY_ABI, + contractIdentifier: + 'lib/v4-hooks-public/src/aggregator-hooks/implementations/StableSwap/StableSwapAggregator.sol:StableSwapAggregator', + + getHookParams(config) { + return { + fee: config.fee ?? 0, + tickSpacing: config.tickSpacing ?? 60, + sqrtPriceX96: config.sqrtPriceX96 ?? DEFAULT_SQRT_PRICE_X96, + }; + }, + + buildPoolKeys(config, hookAddress) { + const params = this.getHookParams(config); + const tokens = config.tokens; + const keys: PoolKeyRecord[] = []; + for (let i = 0; i < tokens.length; i++) { + for (let j = i + 1; j < tokens.length; j++) { + const [c0, c1] = orderPair(tokens[i], tokens[j]); + keys.push({ + currency0: c0, + currency1: c1, + fee: params.fee, + tickSpacing: params.tickSpacing, + hooks: hookAddress, + }); + } + } + return keys; + }, + + getExternalPool(config) { + return config.curvePool; + }, + + getImmutablesFromEnv(chainId: number): FactoryImmutables { + const metaRegistry = mustEnvForChain( + 'STABLESWAP_METAREGISTRY', + chainId, + ) as Address; + return { + poolManager: mustEnvForChain('POOL_MANAGER', chainId) as Address, + metaRegistry, + }; + }, + + async readFactoryImmutables(provider, factoryAddress) { + const factory = new ethers.Contract( + factoryAddress, + STABLESWAP_FACTORY_ABI, + provider, + ); + let poolManagerRaw: string; + try { + poolManagerRaw = await factory.poolManager(); + } catch { + poolManagerRaw = await factory.POOL_MANAGER(); + } + const metaRegistryRaw = await factory.metaRegistry(); + const poolManager = ethers.getAddress(poolManagerRaw); + const metaRegistry = ethers.getAddress(metaRegistryRaw); + return { + poolManager: poolManager as Address, + metaRegistry: metaRegistry as Address, + }; + }, + + encodeConstructorArgs(config, immutables) { + const metaRegistry = + immutables.metaRegistry ?? + (immutables as { metaRegistry?: Address }).metaRegistry; + if (!metaRegistry) { + throw new Error( + 'StableSwap requires metaRegistry. Set STABLESWAP_METAREGISTRY or use StableSwapAggregatorFactory.', + ); + } + const encoded = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address', 'address'], + [immutables.poolManager, config.curvePool, metaRegistry], + ); + return encoded.startsWith('0x') ? encoded : `0x${encoded}`; + }, + + buildSelfDeployEnvVars(config, immutables) { + const params = this.getHookParams(config); + const metaRegistry = + immutables.metaRegistry ?? + (immutables as { metaRegistry?: Address }).metaRegistry; + return { + CURVE_POOL: config.curvePool, + METAREGISTRY: metaRegistry ?? '', + TOKENS: config.tokens.join(','), + FEE: params.fee.toString(), + TICK_SPACING: params.tickSpacing.toString(), + SQRT_PRICE_X96: params.sqrtPriceX96.toString(), + }; + }, + + buildCreatePoolArgs(config, salt) { + const params = this.getHookParams(config); + return [ + salt, + config.curvePool, + config.tokens, + params.fee, + params.tickSpacing, + BigInt(params.sqrtPriceX96), + ]; + }, +}; diff --git a/aggregator-hooks/creation-modules/StableSwapNG.ts b/aggregator-hooks/creation-modules/StableSwapNG.ts new file mode 100644 index 00000000..8e7d2f6b --- /dev/null +++ b/aggregator-hooks/creation-modules/StableSwapNG.ts @@ -0,0 +1,139 @@ +/** + * StableSwap-NG (Curve) aggregator hook deployment module. + * Requires Curve StableSwap NG factory address for meta pool rejection. + */ +import { ethers } from 'ethers'; +import { getEnvForChain, mustEnvForChain } from '../src/cli.js'; +import { STABLESWAPNG_FACTORY_ABI } from '../abis/index.js'; +import { + DEFAULT_SQRT_PRICE_X96, + type Address, + type CreationModule, + type FactoryImmutables, + type PoolKeyRecord, +} from './types.js'; + +export interface StableSwapNGPoolConfig { + poolType: 'stableswapng'; + curvePool: Address; + tokens: Address[]; + fee: number | null; + tickSpacing: number | null; + sqrtPriceX96: bigint | null; +} + +const PROTOCOL_ID = 0xc2; + +const orderPair = (a: Address, b: Address): [Address, Address] => + a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a]; + +const DEFAULT_STABLESWAPNG_FACTORY = + '0x6A8cbed756804B16E05E741eDaBd5cB544AE21bf'; + +export const stableswapngModule: CreationModule = { + poolType: 'stableswapng', + protocolId: PROTOCOL_ID, + factoryAbi: STABLESWAPNG_FACTORY_ABI, + contractIdentifier: + 'lib/v4-hooks-public/src/aggregator-hooks/implementations/StableSwapNG/StableSwapNGAggregator.sol:StableSwapNGAggregator', + + getHookParams(config) { + return { + fee: config.fee ?? 0, + tickSpacing: config.tickSpacing ?? 60, + sqrtPriceX96: config.sqrtPriceX96 ?? DEFAULT_SQRT_PRICE_X96, + }; + }, + + buildPoolKeys(config, hookAddress) { + const params = this.getHookParams(config); + const tokens = config.tokens; + const keys: PoolKeyRecord[] = []; + for (let i = 0; i < tokens.length; i++) { + for (let j = i + 1; j < tokens.length; j++) { + const [c0, c1] = orderPair(tokens[i], tokens[j]); + keys.push({ + currency0: c0, + currency1: c1, + fee: params.fee, + tickSpacing: params.tickSpacing, + hooks: hookAddress, + }); + } + } + return keys; + }, + + getExternalPool(config) { + return config.curvePool; + }, + + getImmutablesFromEnv(chainId: number): FactoryImmutables { + const curveFactory = + getEnvForChain('STABLESWAPNG_FACTORY', chainId) ?? + DEFAULT_STABLESWAPNG_FACTORY; + return { + poolManager: mustEnvForChain('POOL_MANAGER', chainId) as Address, + curveFactory: curveFactory as Address, + }; + }, + + async readFactoryImmutables(provider, factoryAddress) { + const factory = new ethers.Contract( + factoryAddress, + STABLESWAPNG_FACTORY_ABI, + provider, + ); + const [poolManager, curveFactory] = await Promise.all([ + factory.poolManager().then((a: string) => ethers.getAddress(a)), + factory.curveFactory().then((a: string) => ethers.getAddress(a)), + ]); + return { + poolManager: poolManager as Address, + curveFactory: curveFactory as Address, + }; + }, + + encodeConstructorArgs(config, immutables) { + const curveFactory = + immutables.curveFactory ?? + (immutables as { curveFactory?: Address }).curveFactory; + if (!curveFactory) { + throw new Error( + 'StableSwapNG requires curveFactory. Set STABLESWAPNG_FACTORY or use StableSwapNGAggregatorFactory.', + ); + } + const encoded = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address', 'address'], + [immutables.poolManager, config.curvePool, curveFactory], + ); + return encoded.startsWith('0x') ? encoded : `0x${encoded}`; + }, + + buildSelfDeployEnvVars(config, immutables) { + const params = this.getHookParams(config); + const curveFactory = + immutables.curveFactory ?? + (immutables as { curveFactory?: Address }).curveFactory; + return { + CURVE_POOL: config.curvePool, + CURVE_FACTORY: curveFactory ?? '', + TOKENS: config.tokens.join(','), + FEE: params.fee.toString(), + TICK_SPACING: params.tickSpacing.toString(), + SQRT_PRICE_X96: params.sqrtPriceX96.toString(), + }; + }, + + buildCreatePoolArgs(config, salt) { + const params = this.getHookParams(config); + return [ + salt, + config.curvePool, + config.tokens, + params.fee, + params.tickSpacing, + params.sqrtPriceX96, + ]; + }, +}; diff --git a/aggregator-hooks/creation-modules/UniswapV2.ts b/aggregator-hooks/creation-modules/UniswapV2.ts new file mode 100644 index 00000000..aee24ab4 --- /dev/null +++ b/aggregator-hooks/creation-modules/UniswapV2.ts @@ -0,0 +1,131 @@ +/** + * UniswapV2 singleton aggregator hook deployment module. + * + * One UniswapV2Aggregator is deployed per chain. Each pool config entry + * registers a new V4 pool backed by an existing Uniswap V2 pair (resolved + * by currency pair via the V2 factory). Fee and tickSpacing on the V4 + * PoolKey do not affect routing — the pair is keyed by currency pair only. + */ +import { ethers } from 'ethers'; +import { mustEnvForChain } from '../src/cli.js'; +import { + DEFAULT_SQRT_PRICE_X96, + type Address, + type CreationModule, + type FactoryImmutables, + type PoolKeyRecord, +} from './types.js'; + +export interface UniswapV2PoolConfig { + poolType: 'uniswapv2'; + /** The existing Uniswap V2 pair address being wrapped */ + v2Pair: Address; + currency0: Address; + currency1: Address; + /** V4 PoolKey tickSpacing (fee is always 0 for V2 pools) */ + tickSpacing?: number | null; + sqrtPriceX96?: bigint | null; +} + +const PROTOCOL_ID = 0x02; + +const AGGREGATOR_ABI = [ + 'function poolManager() view returns (address)', + 'function factory() view returns (address)', +]; + +const orderPair = (a: Address, b: Address): [Address, Address] => + a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a]; + +export const uniswapv2Module: CreationModule = { + poolType: 'uniswapv2', + protocolId: PROTOCOL_ID, + factoryAbi: [], + contractIdentifier: + 'lib/v4-hooks-public/src/aggregator-hooks/implementations/UniswapV2/UniswapV2Aggregator.sol:UniswapV2Aggregator', + isSingleton: true, + aggregatorEnvKey: 'UNISWAP_V2_AGGREGATOR', + + getHookParams(config) { + return { + fee: 0, + tickSpacing: config.tickSpacing ?? 1, + sqrtPriceX96: config.sqrtPriceX96 ?? DEFAULT_SQRT_PRICE_X96, + }; + }, + + buildPoolKeys(config, hookAddress) { + const params = this.getHookParams(config); + const [c0, c1] = orderPair(config.currency0, config.currency1); + return [ + { + currency0: c0, + currency1: c1, + fee: params.fee, + tickSpacing: params.tickSpacing, + hooks: hookAddress, + }, + ]; + }, + + getExternalPool(config) { + return config.v2Pair; + }, + + getImmutablesFromEnv(chainId) { + return { + poolManager: mustEnvForChain('POOL_MANAGER', chainId) as Address, + externalFactory: mustEnvForChain( + 'UNISWAP_V2_FACTORY', + chainId, + ) as Address, + }; + }, + + async readFactoryImmutables(provider, aggregatorAddress) { + const aggregator = new ethers.Contract( + aggregatorAddress, + AGGREGATOR_ABI, + provider, + ); + const [poolManager, externalFactory] = await Promise.all([ + aggregator.poolManager(), + aggregator.factory(), + ]); + return { + poolManager: poolManager as Address, + externalFactory: externalFactory as Address, + }; + }, + + encodeConstructorArgs(_config, immutables) { + const encoded = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address', 'string'], + [ + immutables.poolManager, + immutables.externalFactory, + 'UniswapV2Aggregator v1.0', + ], + ); + return encoded.startsWith('0x') ? encoded : `0x${encoded}`; + }, + + buildSelfDeployEnvVars(_config, immutables) { + return { + EXTERNAL_FACTORY: immutables.externalFactory!, + HOOK_VERSION: 'UniswapV2Aggregator v1.0', + }; + }, + + buildCreatePoolArgs(_config, _salt) { + throw new Error( + 'UniswapV2 uses singleton self-deploy mode; factory mode (createPool) is not supported.', + ); + }, + + buildInitializeArgs(config, hookAddress): [PoolKeyRecord, bigint] { + const [poolKey] = this.buildPoolKeys(config, hookAddress); + const sqrtPriceX96 = config.sqrtPriceX96 ?? DEFAULT_SQRT_PRICE_X96; + return [poolKey, sqrtPriceX96]; + }, +}; diff --git a/aggregator-hooks/creation-modules/UniswapV3.ts b/aggregator-hooks/creation-modules/UniswapV3.ts new file mode 100644 index 00000000..13295277 --- /dev/null +++ b/aggregator-hooks/creation-modules/UniswapV3.ts @@ -0,0 +1,131 @@ +/** + * UniswapV3 singleton aggregator hook deployment module. + * + * One UniswapV3Aggregator is deployed per chain. Each pool config entry + * registers a new V4 pool backed by an existing Uniswap V3 pool (resolved + * by fee tier via the V3 factory). + */ +import { ethers } from 'ethers'; +import { mustEnvForChain } from '../src/cli.js'; +import { + DEFAULT_SQRT_PRICE_X96, + type Address, + type CreationModule, + type FactoryImmutables, + type PoolKeyRecord, +} from './types.js'; + +export interface UniswapV3PoolConfig { + poolType: 'uniswapv3'; + /** The existing Uniswap V3 pool address being wrapped */ + v3Pool: Address; + currency0: Address; + currency1: Address; + /** V3 fee tier (e.g. 500, 3000, 10000) — used for both V3 pool lookup and V4 PoolKey */ + fee: number; + tickSpacing?: number | null; + sqrtPriceX96?: bigint | null; +} + +const PROTOCOL_ID = 0x03; + +const AGGREGATOR_ABI = [ + 'function poolManager() view returns (address)', + 'function factory() view returns (address)', +]; + +const orderPair = (a: Address, b: Address): [Address, Address] => + a.toLowerCase() < b.toLowerCase() ? [a, b] : [b, a]; + +export const uniswapv3Module: CreationModule = { + poolType: 'uniswapv3', + protocolId: PROTOCOL_ID, + factoryAbi: [], + contractIdentifier: + 'lib/v4-hooks-public/src/aggregator-hooks/implementations/UniswapV3/UniswapV3Aggregator.sol:UniswapV3Aggregator', + isSingleton: true, + aggregatorEnvKey: 'UNISWAP_V3_AGGREGATOR', + + getHookParams(config) { + return { + fee: config.fee, + tickSpacing: config.tickSpacing ?? 60, + sqrtPriceX96: config.sqrtPriceX96 ?? DEFAULT_SQRT_PRICE_X96, + }; + }, + + buildPoolKeys(config, hookAddress) { + const params = this.getHookParams(config); + const [c0, c1] = orderPair(config.currency0, config.currency1); + return [ + { + currency0: c0, + currency1: c1, + fee: params.fee, + tickSpacing: params.tickSpacing, + hooks: hookAddress, + }, + ]; + }, + + getExternalPool(config) { + return config.v3Pool; + }, + + getImmutablesFromEnv(chainId) { + return { + poolManager: mustEnvForChain('POOL_MANAGER', chainId) as Address, + externalFactory: mustEnvForChain( + 'UNISWAP_V3_FACTORY', + chainId, + ) as Address, + }; + }, + + async readFactoryImmutables(provider, aggregatorAddress) { + const aggregator = new ethers.Contract( + aggregatorAddress, + AGGREGATOR_ABI, + provider, + ); + const [poolManager, externalFactory] = await Promise.all([ + aggregator.poolManager(), + aggregator.factory(), + ]); + return { + poolManager: poolManager as Address, + externalFactory: externalFactory as Address, + }; + }, + + encodeConstructorArgs(_config, immutables) { + const encoded = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address', 'string'], + [ + immutables.poolManager, + immutables.externalFactory, + 'UniswapV3Aggregator v1.0', + ], + ); + return encoded.startsWith('0x') ? encoded : `0x${encoded}`; + }, + + buildSelfDeployEnvVars(_config, immutables) { + return { + EXTERNAL_FACTORY: immutables.externalFactory!, + HOOK_VERSION: 'UniswapV3Aggregator v1.0', + }; + }, + + buildCreatePoolArgs(_config, _salt) { + throw new Error( + 'UniswapV3 uses singleton self-deploy mode; factory mode (createPool) is not supported.', + ); + }, + + buildInitializeArgs(config, hookAddress): [PoolKeyRecord, bigint] { + const [poolKey] = this.buildPoolKeys(config, hookAddress); + const sqrtPriceX96 = config.sqrtPriceX96 ?? DEFAULT_SQRT_PRICE_X96; + return [poolKey, sqrtPriceX96]; + }, +}; diff --git a/aggregator-hooks/creation-modules/index.ts b/aggregator-hooks/creation-modules/index.ts new file mode 100644 index 00000000..0071f0a3 --- /dev/null +++ b/aggregator-hooks/creation-modules/index.ts @@ -0,0 +1,67 @@ +/** + * Creation modules registry. Import this to get all supported pool types. + */ +import { stableswapModule } from './StableSwap.js'; +import { stableswapngModule } from './StableSwapNG.js'; +import { fluiddext1Module } from './FluidDexT1.js'; +import { fluiddexliteModule } from './FluidDexLite.js'; +import { uniswapv3Module } from './UniswapV3.js'; +import { uniswapv2Module } from './UniswapV2.js'; +import { slipstreamModule } from './Slipstream.js'; +import { pancakeswapv3Module } from './PancakeSwapV3.js'; +import type { CreationModule } from './types.js'; + +export type { + Address, + CreationModule, + PoolKeyRecord, + PoolEntry, + PoolDeployedEntry, + FactoryImmutables, + HookParams, +} from './types.js'; +export type { StableSwapPoolConfig } from './StableSwap.js'; +export type { StableSwapNGPoolConfig } from './StableSwapNG.js'; +export type { FluidDexT1PoolConfig } from './FluidDexT1.js'; +export type { FluidDexLitePoolConfig } from './FluidDexLite.js'; +export type { UniswapV3PoolConfig } from './UniswapV3.js'; +export type { UniswapV2PoolConfig } from './UniswapV2.js'; +export type { SlipstreamPoolConfig } from './Slipstream.js'; +export type { PancakeSwapV3PoolConfig } from './PancakeSwapV3.js'; + +export { + stableswapModule, + stableswapngModule, + fluiddext1Module, + fluiddexliteModule, +}; +export { + uniswapv3Module, + uniswapv2Module, + slipstreamModule, + pancakeswapv3Module, +}; + +export type PoolConfig = + | import('./StableSwap.js').StableSwapPoolConfig + | import('./StableSwapNG.js').StableSwapNGPoolConfig + | import('./FluidDexT1.js').FluidDexT1PoolConfig + | import('./FluidDexLite.js').FluidDexLitePoolConfig + | import('./UniswapV3.js').UniswapV3PoolConfig + | import('./UniswapV2.js').UniswapV2PoolConfig + | import('./Slipstream.js').SlipstreamPoolConfig + | import('./PancakeSwapV3.js').PancakeSwapV3PoolConfig; + +/** Registry of all creation modules by pool type */ +export const CREATION_MODULES: Record = { + stableswap: stableswapModule, + stableswapng: stableswapngModule, + fluiddext1: fluiddext1Module, + fluiddexlite: fluiddexliteModule, + uniswapv3: uniswapv3Module, + uniswapv2: uniswapv2Module, + slipstream: slipstreamModule, + pancakeswapv3: pancakeswapv3Module, +}; + +export const POOL_TYPES = Object.keys(CREATION_MODULES) as string[]; diff --git a/aggregator-hooks/creation-modules/types.ts b/aggregator-hooks/creation-modules/types.ts new file mode 100644 index 00000000..65418cc5 --- /dev/null +++ b/aggregator-hooks/creation-modules/types.ts @@ -0,0 +1,131 @@ +/** + * Shared types and CreationModule interface for pool deployment. + * Each protocol (stableswap, stableswapng, fluiddext1, fluiddexlite) implements this interface. + */ +import type { Provider } from 'ethers'; + +/** Ethereum address: 0x-prefixed hex string */ +export type Address = `0x${string}`; + +/** PoolKey shape for registry output (matches Uniswap v4 PoolKey) */ +export interface PoolKeyRecord { + currency0: Address; + currency1: Address; + fee: number; + tickSpacing: number; + hooks: Address; +} + +/** A single pool: its PoolKey paired with the computed PoolId */ +export interface PoolEntry { + poolKey: PoolKeyRecord; + poolId: string; +} + +/** Entry appended to pool-deployed registry */ +export interface PoolDeployedEntry { + pools: PoolEntry[]; + metadata: { + externalPool: string; + hookAddress: Address; + txHash?: string; + blockNumber?: number; + [key: string]: unknown; + }; +} + +/** Immutables for self-deploy (from env) or factory (from contract) */ +export interface FactoryImmutables { + poolManager: Address; + [key: string]: string | Address; +} + +/** Default sqrtPriceX96 for 1:1 (2^96) */ +export const DEFAULT_SQRT_PRICE_X96 = 79228162514264337593543950336n; + +/** Default hook parameters when not specified in config */ +export const DEFAULT_HOOK_PARAMS = { + fee: 0, + tickSpacing: 60, + sqrtPriceX96: DEFAULT_SQRT_PRICE_X96, +} as const; + +export interface HookParams { + fee: number; + tickSpacing: number; + sqrtPriceX96: bigint; +} + +/** + * Creation module interface. Each pool type implements this to provide + * type-specific deployment logic. + * + * Singleton types (UniswapV3, UniswapV2, Slipstream, PancakeSwapV3) set + * `isSingleton: true`. For these, createPools.ts deploys the aggregator once + * per chain (writing the address to .env), then calls poolManager.initialize + * for each pool config entry rather than deploying a new hook per pool. + */ +export interface CreationModule { + /** Pool type identifier (e.g. "stableswap", "fluiddext1") */ + poolType: string; + + /** Protocol ID for salt mining (matches MineAggregatorHook.s.sol) */ + protocolId: number; + + /** Factory contract ABI for createPool and reading immutables */ + factoryAbi: string[]; + + /** Solidity contract identifier for forge verify-contract (path:ContractName) */ + contractIdentifier: string; + + /** + * If true, one aggregator is deployed per chain and reused for all pools. + * Pools are registered by calling poolManager.initialize rather than a factory. + */ + isSingleton?: boolean; + + /** + * Env var key (without chain suffix) that persists the deployed singleton + * address, e.g. "UNISWAP_V3_AGGREGATOR". Required when isSingleton is true. + */ + aggregatorEnvKey?: string; + + /** Resolve hook params with defaults */ + getHookParams(config: TConfig): HookParams; + + /** Build PoolKey records for registry */ + buildPoolKeys(config: TConfig, hookAddress: Address): PoolKeyRecord[]; + + /** Get external pool address/identifier for metadata */ + getExternalPool(config: TConfig): string; + + /** Read immutables from env for self-deploy */ + getImmutablesFromEnv(chainId: number): FactoryImmutables; + + /** Read immutables from factory contract (or deployed singleton aggregator) */ + readFactoryImmutables( + provider: Provider, + factoryAddress: Address, + ): Promise; + + /** Encode constructor args for salt mining */ + encodeConstructorArgs(config: TConfig, immutables: FactoryImmutables): string; + + /** Build env vars for SelfCreateHook.s.sol self-deploy */ + buildSelfDeployEnvVars( + config: TConfig, + immutables: FactoryImmutables, + ): Record; + + /** Build createPool call args for factory contract. Throws for singleton types. */ + buildCreatePoolArgs(config: TConfig, salt: string): unknown[]; + + /** + * Build args for poolManager.initialize — singleton types only. + * Returns [poolKey, sqrtPriceX96] to pass to the PoolManager initialize function. + */ + buildInitializeArgs?( + config: TConfig, + hookAddress: Address, + ): [PoolKeyRecord, bigint]; +} diff --git a/aggregator-hooks/historical/FluidDexLite.ts b/aggregator-hooks/historical/FluidDexLite.ts new file mode 100644 index 00000000..4bd77cbe --- /dev/null +++ b/aggregator-hooks/historical/FluidDexLite.ts @@ -0,0 +1,165 @@ +/** + * Fluid Dex Lite historical pool discovery. + * Enumerates all pools via FluidDexLiteResolver.getAllDexes(). + * + * FluidDexLite does NOT emit LogInitialize events; pool discovery uses the + * resolver's getAllDexes() view (see Fluid DEX Lite integration docs). + * + * Usage: + * npx tsx historical/fluiddexlite.ts --chain-id 1 + * + * Options: + * --chain-id (required) Chain ID; loads RPC_URL_, FLUID_DEX_LITE_RESOLVER_ + * --output-dir output directory (default: detected); writes to output-dir/chain-id/fluiddexlite-pools.json + * + * Env vars (use VAR_ or VAR for single chain): + * RPC_URL (required) + * FLUID_DEX_LITE_RESOLVER (optional) FluidDexLiteResolver; default mainnet: 0x26b696D0dfDAB6c894Aa9a6575fCD07BB25BbD2C + * + * Output: JSON array in createPools.ts FluidDexLitePoolConfig format. + * Fees are fetched via getDexState() and converted from Fluid 1e4 to Uniswap v4 1e6 format. + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { ethers } from 'ethers'; +import { parseArgs, getEnvForChain, toInt, resolveOutputPath } from '@src/cli'; +import { ensureDirForFile } from '@src/utils'; +import { FLUIDDEXLITE_RESOLVER_ABI } from '../abis/index.js'; +import type { Address } from '../creation-modules/types.js'; + +const OUTPUT_FILE = 'fluiddexlite-pools.json'; +const DEFAULT_RESOLVER: Address = '0x26b696D0dfDAB6c894Aa9a6575fCD07BB25BbD2C'; + +/** Fluid native token; map to address(0) for Uniswap v4 pool init */ +const FLUID_NATIVE: Address = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; +const ZERO_ADDRESS: Address = '0x0000000000000000000000000000000000000000'; + +function toUniswapV4Currency(addr: string): Address { + return addr.toLowerCase() === FLUID_NATIVE.toLowerCase() + ? ZERO_ADDRESS + : (ethers.getAddress(addr) as Address); +} + +/** Same shape as createPools.ts FluidDexLitePoolConfig */ +type CreatePoolsFluidLiteConfig = { + poolType: 'fluiddexlite'; + dexSalt: string; + currency0: Address; + currency1: Address; + fee: number | null; + tickSpacing: number | null; + sqrtPriceX96: bigint | null; +}; + +/** + * Converts Fluid fee (1e4 basis) to Uniswap v4 fee (1e6 basis). + * Fluid: 10000 = 100%. Uniswap v4: 10000 = 1%. + * + * @param fluidFee - Fluid fee in 1e4 basis (bigint or number) + * @returns Uniswap v4 fee as uint24 (max 16_777_215) + */ +function fluidFeeToUniswapV4(fluidFee: bigint | number): number { + const MAX_U24 = 16_777_215; + const converted = Number(fluidFee) * 100; // fluidFee * 1e6 / 1e4 + return Math.min(Math.max(0, Math.floor(converted)), MAX_U24); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + const resolverRaw = + getEnvForChain('FLUID_DEX_LITE_RESOLVER', chainId) ?? DEFAULT_RESOLVER; + if (!rpcUrl) { + console.error('Missing env: RPC_URL (or RPC_URL_)'); + process.exit(1); + } + + const resolver = ethers.getAddress(resolverRaw) as Address; + const outputDir = (args['output-dir'] as string) ?? 'detected'; + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const resolverContract = new ethers.Contract( + resolver, + FLUIDDEXLITE_RESOLVER_ABI as unknown as string[], + provider, + ); + + console.error(`[enum] FluidDexLiteResolver at ${resolver}`); + const dexKeys = (await resolverContract.getAllDexes()) as Array<{ + token0: string; + token1: string; + salt: string; + }>; + + const configs: CreatePoolsFluidLiteConfig[] = []; + for (const dk of dexKeys) { + const mapped = [ + toUniswapV4Currency(dk.token0), + toUniswapV4Currency(dk.token1), + ].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + const [currency0, currency1] = [mapped[0], mapped[1]]; + + let fee: number | null = null; + const dexKey = { token0: dk.token0, token1: dk.token1, salt: dk.salt }; + try { + const dexState = (await resolverContract.getDexState(dexKey)) as { + dexVariables: { fee: bigint | number }; + }; + const fluidFee = dexState.dexVariables.fee; + if (fluidFee && Number(fluidFee) > 0) { + fee = fluidFeeToUniswapV4(fluidFee); + } + } catch { + // getDexState reverted; keep fee null + console.error( + `getDexState reverted for dexKey: ${JSON.stringify(dexKey)}`, + ); + } + + configs.push({ + poolType: 'fluiddexlite', + dexSalt: dk.salt as string, + currency0, + currency1, + fee, + tickSpacing: null, + sqrtPriceX96: null, + }); + } + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + ensureDirForFile(outPath); + fs.writeFileSync(outPath, JSON.stringify(configs, null, 2) + '\n'); + + console.log( + JSON.stringify( + { + ok: true, + chainId, + resolver, + poolsFound: configs.length, + outFile: outPath, + }, + null, + 2, + ), + ); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/aggregator-hooks/historical/FluidDexT1.ts b/aggregator-hooks/historical/FluidDexT1.ts new file mode 100644 index 00000000..ee65187f --- /dev/null +++ b/aggregator-hooks/historical/FluidDexT1.ts @@ -0,0 +1,233 @@ +/** + * Fluid Dex T1 historical pool discovery. + * Scrapes LogDexDeployed events from FluidDexFactory and fetches tokens via resolver. + * + * Usage: + * npx tsx historical/fluiddext1.ts --chain-id 1 + * + * Options: + * --chain-id (required) Chain ID; loads RPC_URL_, FLUID_DEX_T1_RESOLVER_, etc. + * --output-dir output directory (default: output); writes to output-dir/chain-id/fluiddext1-pools.json + * --chunk-blocks block chunk size for getLogs (default: 100000) + * --start-block start block for log scan (default: 0) + * --end-block end block for log scan (default: latest) + * --mode logs|enumerate|both (default: logs) + * + * Env vars (use VAR_ or VAR for single chain): + * RPC_URL (required) + * FLUID_DEX_T1_RESOLVER (required) IFluidDexResolver for getDexTokens + * FLUID_DEX_T1_FACTORY (optional, default mainnet) + * RPS (optional, default 80) max RPC requests per second + * CONCURRENCY (optional, default 8) max concurrent RPC calls + * + * Output: JSON array in createPools.ts FluidDexT1PoolConfig format. + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { JsonRpcProvider, Contract, Interface, getAddress } from 'ethers'; +import { parseArgs, getEnvForChain, toInt, resolveOutputPath } from '@src/cli'; +import { pRateLimit, pLimit, ensureDirForFile } from '@src/utils'; +import { + FLUIDDEXT1_HISTORICAL_FACTORY_ABI, + FLUIDDEXT1_RESOLVER_ABI, +} from '../abis/index.js'; +import type { Address } from '../creation-modules/types.js'; + +const OUTPUT_FILE = 'fluiddext1-pools.json'; +const DEFAULT_FACTORY: Address = '0x91716c4eDA1fB55e84Bf8b4c7085f84285c19085'; + +/** Same shape as createPools.ts FluidDexT1PoolConfig */ +type CreatePoolsFluidDexT1Config = { + poolType: 'fluiddext1'; + fluidPool: Address; + currency0: Address; + currency1: Address; + fee: number | null; + tickSpacing: number | null; + sqrtPriceX96: bigint | null; +}; + +/** Fluid native token; map to address(0) for Uniswap v4 pool init */ +const FLUID_NATIVE: Address = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; +const ZERO_ADDRESS: Address = '0x0000000000000000000000000000000000000000'; + +function toUniswapV4Currency(addr: string): Address { + return addr.toLowerCase() === FLUID_NATIVE.toLowerCase() + ? ZERO_ADDRESS + : (getAddress(addr) as Address); +} + +function orderCurrencies(token0: Address, token1: Address): [Address, Address] { + const mapped = [ + toUniswapV4Currency(token0), + toUniswapV4Currency(token1), + ].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + return [mapped[0]!, mapped[1]!]; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + const resolverAddr = getEnvForChain('FLUID_DEX_T1_RESOLVER', chainId); + const factoryAddrRaw = + getEnvForChain('FLUID_DEX_T1_FACTORY', chainId) ?? DEFAULT_FACTORY; + + if (!rpcUrl || !resolverAddr) { + console.error('Missing env: RPC_URL and FLUID_DEX_T1_RESOLVER'); + process.exit(1); + } + + const factoryAddr = getAddress(factoryAddrRaw.toLowerCase()) as Address; + const outputDir = (args['output-dir'] as string) ?? 'detected'; + const chunkSize = BigInt(Math.max(1, toInt(args['chunk-blocks'], 100_000))); + const startBlock = BigInt(Math.max(0, toInt(args['start-block'], 0))); + const endBlockRaw = args['end-block']; + const mode = ( + (args['mode'] as string | undefined) ?? 'enumerate' + ).toLowerCase(); + const rps = toInt(args['rps'] ?? getEnvForChain('RPS', chainId), 80); + const concurrency = Math.max( + 1, + toInt(getEnvForChain('CONCURRENCY', chainId), 8), + ); + + const rateLimitAcquire = pRateLimit(rps); + const limit = pLimit(concurrency); + + const provider = new JsonRpcProvider(rpcUrl); + const factory = new Contract( + factoryAddr, + FLUIDDEXT1_HISTORICAL_FACTORY_ABI, + provider, + ); + const resolver = new Contract( + getAddress(resolverAddr.toLowerCase()), + FLUIDDEXT1_RESOLVER_ABI, + provider, + ); + + const latest = BigInt(await provider.getBlockNumber()); + const endBlock = endBlockRaw ? BigInt(String(endBlockRaw)) : latest; + + if (startBlock < 0n) throw new Error('start-block must be >= 0'); + if (endBlock < startBlock) + throw new Error('end-block must be >= start-block'); + + const byDexAddr = new Map(); + + if (mode === 'logs' || mode === 'both') { + const iface = new Interface( + FLUIDDEXT1_HISTORICAL_FACTORY_ABI as unknown as string[], + ); + const topic0 = iface.getEvent('LogDexDeployed')!.topicHash; + + for (let from = startBlock; from <= endBlock; from += chunkSize) { + const to = + from + chunkSize - 1n > endBlock ? endBlock : from + chunkSize - 1n; + + const logs = await provider.getLogs({ + address: factoryAddr, + fromBlock: from, + toBlock: to, + topics: [topic0], + }); + + for (const log of logs) { + const parsed = iface.parseLog(log); + const dex = getAddress(parsed!.args.dex) as Address; + const dexId = parsed!.args.dexId.toString(); + if (!byDexAddr.has(dex)) byDexAddr.set(dex, dexId); + } + + if ((to - startBlock) % (chunkSize * 10n) === 0n || to === endBlock) { + console.error(`[logs] scanned ${from}..${to} (${logs.length} logs)`); + } + } + } + + if (mode === 'enumerate' || mode === 'both') { + const total = await factory.totalDexes(); + console.error(`[enumerate] totalDexes() = ${total}`); + + for (let i = 1n; i <= total; i++) { + const dex = getAddress(await factory.getDexAddress(i)) as Address; + const ok = await factory.isDex(dex); + if (!ok) continue; + + if (!byDexAddr.has(dex)) byDexAddr.set(dex, i.toString()); + + if (i % 50n === 0n || i === total) { + console.error(`[enumerate] ${i}/${total}`); + } + } + } + + const uniqueDexes = Array.from(byDexAddr.keys()); + console.log( + `Found ${uniqueDexes.length} unique Fluid Dex T1 pools. Fetching tokens via resolver...`, + ); + + const configs: CreatePoolsFluidDexT1Config[] = []; + let skipped = 0; + + for (const fluidPool of uniqueDexes) { + const config = await limit(async () => { + await rateLimitAcquire(); + let token0: Address; + let token1: Address; + try { + [token0, token1] = (await resolver.getDexTokens(fluidPool)) as [ + Address, + Address, + ]; + } catch { + return null; + } + const [currency0, currency1] = orderCurrencies(token0, token1); + + return { + poolType: 'fluiddext1' as const, + fluidPool, + currency0, + currency1, + fee: null, + tickSpacing: null, + sqrtPriceX96: null, + } satisfies CreatePoolsFluidDexT1Config; + }); + + if (config) configs.push(config); + else skipped++; + } + + if (skipped > 0) { + console.error( + `Skipped ${skipped} pools (getDexTokens reverted - may be VaultT1 or deprecated)`, + ); + } + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + ensureDirForFile(outPath); + fs.writeFileSync(outPath, JSON.stringify(configs, null, 2)); + console.log( + `Wrote ${outPath} (${configs.length} pools in createPools.ts format)`, + ); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/aggregator-hooks/historical/PancakeSwapV3.ts b/aggregator-hooks/historical/PancakeSwapV3.ts new file mode 100644 index 00000000..d429fde5 --- /dev/null +++ b/aggregator-hooks/historical/PancakeSwapV3.ts @@ -0,0 +1,207 @@ +/** + * PancakeSwap V3 historical pool discovery. + * Scans PoolCreated events from the PancakeSwap V3 factory and outputs pool + * configs in createPools.ts PancakeSwapV3PoolConfig format. + * + * Usage: + * npx tsx historical/PancakeSwapV3.ts --chain-id 1 + * + * Options: + * --chain-id (required) Chain ID + * --output-dir output directory (default: detected) + * --chunk-blocks block chunk size for getLogs (default: 10000; auto-halved on range errors) + * --start-block start block (default: 0) + * --end-block end block (default: latest) + * + * Env vars (VAR_ or VAR): + * RPC_URL (required) + * PANCAKESWAP_V3_FACTORY (optional, default mainnet factory) + * RPS (optional, default 80) + * + * Output: JSON array in PancakeSwapV3PoolConfig format. + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { ethers } from 'ethers'; +import { parseArgs, getEnvForChain, toInt, resolveOutputPath } from '@src/cli'; +import { pRateLimit, ensureDirForFile } from '@src/utils'; +import type { Address } from '../creation-modules/types.js'; + +const OUTPUT_FILE = 'pancakeswapv3-pools.json'; +const DEFAULT_FACTORY: Address = '0x0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +const FACTORY_ABI = [ + 'event PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)', +]; + +type PancakeSwapV3PoolConfig = { + poolType: 'pancakeswapv3'; + v3Pool: Address; + currency0: Address; + currency1: Address; + fee: number; + tickSpacing: number; + sqrtPriceX96: null; +}; + +function isRangeLimitError(err: unknown): boolean { + const msg = String( + (err as { error?: { message?: string }; message?: string })?.error + ?.message ?? + (err as { message?: string })?.message ?? + '', + ); + return ( + msg.toLowerCase().includes('range') || + msg.toLowerCase().includes('limit') || + msg.includes('-32614') + ); +} + +async function getLogsWithRetry( + provider: ethers.JsonRpcProvider, + filter: { + address: string; + topics: string[]; + fromBlock: bigint; + toBlock: bigint; + }, +): Promise { + try { + return await provider.getLogs({ + address: filter.address, + topics: filter.topics, + fromBlock: filter.fromBlock, + toBlock: filter.toBlock, + }); + } catch (err) { + if (!isRangeLimitError(err) || filter.toBlock <= filter.fromBlock) + throw err; + const mid = filter.fromBlock + (filter.toBlock - filter.fromBlock) / 2n; + const [lo, hi] = await Promise.all([ + getLogsWithRetry(provider, { ...filter, toBlock: mid }), + getLogsWithRetry(provider, { ...filter, fromBlock: mid + 1n }), + ]); + return lo.concat(hi); + } +} + +function isNative(addr: string): boolean { + return addr.toLowerCase() === ZERO_ADDRESS; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + if (!rpcUrl) { + console.error('Missing env: RPC_URL'); + process.exit(1); + } + + const factoryRaw = + getEnvForChain('PANCAKESWAP_V3_FACTORY', chainId) ?? DEFAULT_FACTORY; + const factory = ethers.getAddress(factoryRaw) as Address; + + const outputDir = (args['output-dir'] as string) ?? 'detected'; + const chunkSize = BigInt(Math.max(1, toInt(args['chunk-blocks'], 10_000))); + const startBlock = BigInt(Math.max(0, toInt(args['start-block'], 0))); + const rps = toInt(getEnvForChain('RPS', chainId) ?? '80', 80); + const rateLimitAcquire = pRateLimit(rps); + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const iface = new ethers.Interface(FACTORY_ABI); + const topic0 = iface.getEvent('PoolCreated')!.topicHash; + + const latest = BigInt(await provider.getBlockNumber()); + const endBlockRaw = args['end-block']; + const endBlock = endBlockRaw ? BigInt(String(endBlockRaw)) : latest; + + console.error(`Factory: ${factory}`); + console.error( + `Scanning blocks ${startBlock}..${endBlock} (chunk=${chunkSize})`, + ); + + const seen = new Set
(); + const configs: PancakeSwapV3PoolConfig[] = []; + + for (let from = startBlock; from <= endBlock; from += chunkSize) { + const to = + from + chunkSize - 1n > endBlock ? endBlock : from + chunkSize - 1n; + await rateLimitAcquire(); + + const logs = await getLogsWithRetry(provider, { + address: factory, + topics: [topic0], + fromBlock: from, + toBlock: to, + }); + + for (const log of logs) { + const parsed = iface.parseLog(log); + if (!parsed) continue; + + const token0 = ethers.getAddress(parsed.args.token0 as string) as Address; + const token1 = ethers.getAddress(parsed.args.token1 as string) as Address; + const pool = ethers.getAddress(parsed.args.pool as string) as Address; + const fee = Number(parsed.args.fee); + const tickSpacing = Number(parsed.args.tickSpacing); + + if (isNative(token0) || isNative(token1)) continue; + if (seen.has(pool)) continue; + seen.add(pool); + + configs.push({ + poolType: 'pancakeswapv3', + v3Pool: pool, + currency0: token0, + currency1: token1, + fee, + tickSpacing, + sqrtPriceX96: null, + }); + } + + if (to === endBlock || (to - startBlock) % (chunkSize * 10n) === 0n) { + console.error( + `[scan] ${from}..${to} — found ${configs.length} pools so far`, + ); + } + console.log(`[scan] ${from}..${to} — found ${configs.length} pools so far`); + } + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + ensureDirForFile(outPath); + fs.writeFileSync(outPath, JSON.stringify(configs, null, 2) + '\n'); + console.log( + JSON.stringify( + { + ok: true, + chainId, + factory, + poolsFound: configs.length, + outFile: outPath, + }, + null, + 2, + ), + ); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/aggregator-hooks/historical/Slipstream.ts b/aggregator-hooks/historical/Slipstream.ts new file mode 100644 index 00000000..469b441c --- /dev/null +++ b/aggregator-hooks/historical/Slipstream.ts @@ -0,0 +1,212 @@ +/** + * Slipstream historical pool discovery. + * Scans PoolCreated events from the Slipstream (Velodrome/Aerodrome CL) factory + * and outputs pool configs in createPools.ts SlipstreamPoolConfig format. + * + * Usage: + * npx tsx historical/Slipstream.ts --chain-id 10 + * + * Options: + * --chain-id (required) Chain ID + * --output-dir output directory (default: detected) + * --chunk-blocks block chunk size for getLogs (default: 10000; auto-halved on range errors) + * --start-block start block (default: 0) + * --end-block end block (default: latest) + * + * Env vars (VAR_ or VAR): + * RPC_URL (required) + * SLIPSTREAM_FACTORY (required — no chain-agnostic default; set SLIPSTREAM_FACTORY_) + * RPS (optional, default 80) + * + * Output: JSON array in SlipstreamPoolConfig format. + * Fee is always 0 (Slipstream pools key by tickSpacing, not fee). + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { ethers } from 'ethers'; +import { parseArgs, getEnvForChain, toInt, resolveOutputPath } from '@src/cli'; +import { pRateLimit, ensureDirForFile } from '@src/utils'; +import type { Address } from '../creation-modules/types.js'; + +const OUTPUT_FILE = 'slipstream-pools.json'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +// Slipstream CLFactory emits tickSpacing (not fee) as the third indexed param +const FACTORY_ABI = [ + 'event PoolCreated(address indexed token0, address indexed token1, int24 indexed tickSpacing, address pool)', +]; + +type SlipstreamPoolConfig = { + poolType: 'slipstream'; + slipstreamPool: Address; + currency0: Address; + currency1: Address; + tickSpacing: number; + sqrtPriceX96: null; +}; + +function isRangeLimitError(err: unknown): boolean { + const msg = String( + (err as { error?: { message?: string }; message?: string })?.error + ?.message ?? + (err as { message?: string })?.message ?? + '', + ); + return ( + msg.toLowerCase().includes('range') || + msg.toLowerCase().includes('limit') || + msg.includes('-32614') + ); +} + +async function getLogsWithRetry( + provider: ethers.JsonRpcProvider, + filter: { + address: string; + topics: string[]; + fromBlock: bigint; + toBlock: bigint; + }, +): Promise { + try { + return await provider.getLogs({ + address: filter.address, + topics: filter.topics, + fromBlock: filter.fromBlock, + toBlock: filter.toBlock, + }); + } catch (err) { + if (!isRangeLimitError(err) || filter.toBlock <= filter.fromBlock) + throw err; + const mid = filter.fromBlock + (filter.toBlock - filter.fromBlock) / 2n; + const [lo, hi] = await Promise.all([ + getLogsWithRetry(provider, { ...filter, toBlock: mid }), + getLogsWithRetry(provider, { ...filter, fromBlock: mid + 1n }), + ]); + return lo.concat(hi); + } +} + +function isNative(addr: string): boolean { + return addr.toLowerCase() === ZERO_ADDRESS; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + if (!rpcUrl) { + console.error('Missing env: RPC_URL'); + process.exit(1); + } + + const factoryRaw = getEnvForChain('SLIPSTREAM_FACTORY', chainId); + if (!factoryRaw) { + console.error( + 'Missing env: SLIPSTREAM_FACTORY (or SLIPSTREAM_FACTORY_)', + ); + process.exit(1); + } + const factory = ethers.getAddress(factoryRaw) as Address; + + const outputDir = (args['output-dir'] as string) ?? 'detected'; + const chunkSize = BigInt(Math.max(1, toInt(args['chunk-blocks'], 10_000))); + const startBlock = BigInt(Math.max(0, toInt(args['start-block'], 0))); + const rps = toInt(getEnvForChain('RPS', chainId) ?? '80', 80); + const rateLimitAcquire = pRateLimit(rps); + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const iface = new ethers.Interface(FACTORY_ABI); + const topic0 = iface.getEvent('PoolCreated')!.topicHash; + + const latest = BigInt(await provider.getBlockNumber()); + const endBlockRaw = args['end-block']; + const endBlock = endBlockRaw ? BigInt(String(endBlockRaw)) : latest; + + console.error(`Factory: ${factory}`); + console.error( + `Scanning blocks ${startBlock}..${endBlock} (chunk=${chunkSize})`, + ); + + const seen = new Set
(); + const configs: SlipstreamPoolConfig[] = []; + + for (let from = startBlock; from <= endBlock; from += chunkSize) { + const to = + from + chunkSize - 1n > endBlock ? endBlock : from + chunkSize - 1n; + await rateLimitAcquire(); + + const logs = await getLogsWithRetry(provider, { + address: factory, + topics: [topic0], + fromBlock: from, + toBlock: to, + }); + + for (const log of logs) { + const parsed = iface.parseLog(log); + if (!parsed) continue; + + const token0 = ethers.getAddress(parsed.args.token0 as string) as Address; + const token1 = ethers.getAddress(parsed.args.token1 as string) as Address; + const pool = ethers.getAddress(parsed.args.pool as string) as Address; + const tickSpacing = Number(parsed.args.tickSpacing); + + // Skip native currency — SlipstreamAggregator reverts on address(0) + if (isNative(token0) || isNative(token1)) continue; + if (seen.has(pool)) continue; + seen.add(pool); + + // token0 < token1 guaranteed by the factory + configs.push({ + poolType: 'slipstream', + slipstreamPool: pool, + currency0: token0, + currency1: token1, + tickSpacing, + sqrtPriceX96: null, + }); + } + + if (to === endBlock || (to - startBlock) % (chunkSize * 10n) === 0n) { + console.error( + `[scan] ${from}..${to} — found ${configs.length} pools so far`, + ); + } + console.log(`[scan] ${from}..${to} — found ${configs.length} pools so far`); + } + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + ensureDirForFile(outPath); + fs.writeFileSync(outPath, JSON.stringify(configs, null, 2) + '\n'); + console.log( + JSON.stringify( + { + ok: true, + chainId, + factory, + poolsFound: configs.length, + outFile: outPath, + }, + null, + 2, + ), + ); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/aggregator-hooks/historical/StableSwapNG.ts b/aggregator-hooks/historical/StableSwapNG.ts new file mode 100644 index 00000000..54e3728a --- /dev/null +++ b/aggregator-hooks/historical/StableSwapNG.ts @@ -0,0 +1,211 @@ +/** + * StableSwap-NG historical pool discovery. + * Enumerates all pools from Curve StableSwap-NG Factory via pool_count + pool_list. + * + * Usage: + * npx tsx historical/stableswapng.ts --chain-id 1 + * + * Options: + * --chain-id (required) Chain ID; loads RPC_URL_, STABLESWAPNG_FACTORY_ + * --output-dir output directory (default: output); writes to output-dir/chain-id/stableswapng-pools.json + * --chunk chunk size for pool_list reads (default: 500) + * --start-index start at pool_list index n (default: 0) + * + * Env vars (use VAR_ or VAR for single chain): + * RPC_URL (required) + * STABLESWAPNG_FACTORY (optional, default mainnet Curve StableSwap-NG factory) + * RPS (optional, default 80) max RPC requests per second + * CONCURRENCY (optional, default 8) max concurrent RPC calls + * + * Output: JSON array in createPools.ts StableSwapPoolConfig format. + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { JsonRpcProvider, Contract, getAddress, ZeroAddress } from 'ethers'; +import { parseArgs, getEnvForChain, toInt, resolveOutputPath } from '@src/cli'; +import { pRateLimit, pLimit, ensureDirForFile } from '@src/utils'; +import { STABLESWAPNG_HISTORICAL_FACTORY_ABI } from '../abis/index.js'; +import type { Address } from '../creation-modules/types.js'; + +const OUTPUT_FILE = 'stableswapng-pools.json'; +const DEFAULT_FACTORY: Address = '0x6A8cbed756804B16E05E741eDaBd5cB544AE21bf'; + +type PoolMeta = { + pool: Address; + kind: 'plain' | 'meta'; + nCoins?: number; + coins?: Address[]; + basePool?: Address; +}; + +/** Same shape as createPools.ts StableSwapPoolConfig for stableswapng pool type */ +type CreatePoolsStableSwapConfig = { + poolType: 'stableswapng'; + curvePool: Address; + tokens: Address[]; + fee: number | null; + tickSpacing: number | null; + sqrtPriceX96: bigint | null; +}; + +const CREATE_POOLS_DEFAULTS = { + fee: null as number | null, + tickSpacing: null as number | null, + sqrtPriceX96: null as bigint | null, +} as const; + +function saveJson(filePath: string, data: unknown): void { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); +} + +function uniqAddresses(addrs: string[]): Address[] { + const s = new Set
(); + for (const a of addrs) { + if (!a) continue; + const norm = a.toLowerCase(); + if (norm === ZeroAddress.toLowerCase()) continue; + s.add(getAddress(a) as Address); + } + return [...s]; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + const factoryAddrRaw = + getEnvForChain('STABLESWAPNG_FACTORY', chainId) ?? DEFAULT_FACTORY; + + if (!rpcUrl) { + console.error('Missing env: RPC_URL (or RPC_URL_)'); + process.exit(1); + } + + const factoryAddress = getAddress(factoryAddrRaw) as Address; + const outputDir = (args['output-dir'] as string) ?? 'detected'; + const chunkSize = toInt(args['chunk'], 500); + const concurrency = Math.max( + 1, + toInt(getEnvForChain('CONCURRENCY', chainId), 8), + ); + const rps = toInt(getEnvForChain('RPS', chainId), 80); + const rateLimitAcquire = rps > 0 ? pRateLimit(rps) : async () => {}; + const startIndex = toInt(args['start-index'], 0); + + const provider = new JsonRpcProvider(rpcUrl); + const factory = new Contract( + factoryAddress, + STABLESWAPNG_HISTORICAL_FACTORY_ABI, + provider, + ); + const limit = pLimit(concurrency); + + const poolCountBn: bigint = await factory.pool_count(); + const poolCount = Number(poolCountBn); + if (!Number.isFinite(poolCount) || poolCount < 0) { + throw new Error(`Unexpected pool_count: ${poolCountBn.toString()}`); + } + + console.log(`Factory: ${factoryAddress}`); + console.log(`pool_count: ${poolCount}`); + console.log(`Starting at index: ${startIndex}`); + console.log( + `chunkSize=${chunkSize} concurrency=${concurrency} rps=${rps > 0 ? rps : 'unlimited'}`, + ); + + const pools: Address[] = []; + const metas: PoolMeta[] = []; + const metaByPool = new Map(); + + for (let i = startIndex; i < poolCount; i += chunkSize) { + const end = Math.min(poolCount, i + chunkSize); + + const addrs = await Promise.all( + Array.from({ length: end - i }, (_, k) => + limit(async () => { + await rateLimitAcquire(); + const addr: string = await factory.pool_list(i + k); + return getAddress(addr) as Address; + }), + ), + ); + + pools.push(...addrs); + + const chunkMetas = await Promise.all( + addrs.map((pool) => + limit(async () => { + await rateLimitAcquire(); + const nCoinsBn = (await factory.get_n_coins(pool)) as bigint; + await rateLimitAcquire(); + const coinsRaw = (await factory.get_coins(pool)) as string[]; + await rateLimitAcquire(); + const basePoolRaw = await factory.get_base_pool(pool); + + const basePool = getAddress(basePoolRaw) as Address; + const coins = uniqAddresses(coinsRaw); + + return { + pool, + kind: + basePool.toLowerCase() === ZeroAddress.toLowerCase() + ? 'plain' + : 'meta', + nCoins: Number(nCoinsBn), + coins, + basePool: + basePool.toLowerCase() === ZeroAddress.toLowerCase() + ? undefined + : basePool, + } satisfies PoolMeta; + }), + ), + ); + + metas.push(...chunkMetas); + for (let j = 0; j < addrs.length; j++) + metaByPool.set(addrs[j], chunkMetas[j]); + console.log(`Fetched [${i}, ${end}) / ${poolCount}`); + } + + const uniquePools = uniqAddresses(pools); + + const createPoolsConfigs: CreatePoolsStableSwapConfig[] = uniquePools + .filter((curvePool) => metaByPool.get(curvePool)?.kind === 'plain') + .map((curvePool) => { + const meta = metaByPool.get(curvePool)!; + const tokens = meta?.coins ?? []; + return { + poolType: 'stableswapng' as const, + curvePool, + tokens, + fee: CREATE_POOLS_DEFAULTS.fee, + tickSpacing: CREATE_POOLS_DEFAULTS.tickSpacing, + sqrtPriceX96: CREATE_POOLS_DEFAULTS.sqrtPriceX96, + }; + }); + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + ensureDirForFile(outPath); + saveJson(outPath, createPoolsConfigs); + console.log( + `Wrote ${outPath} (${createPoolsConfigs.length} pools in createPools.ts format)`, + ); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/aggregator-hooks/historical/UniswapV2.ts b/aggregator-hooks/historical/UniswapV2.ts new file mode 100644 index 00000000..dd8eaa51 --- /dev/null +++ b/aggregator-hooks/historical/UniswapV2.ts @@ -0,0 +1,207 @@ +/** + * Uniswap V2 historical pool discovery. + * Scans PairCreated events from the Uniswap V2 factory and outputs pool + * configs in createPools.ts UniswapV2PoolConfig format. + * + * Usage: + * npx tsx historical/UniswapV2.ts --chain-id 1 + * + * Options: + * --chain-id (required) Chain ID + * --output-dir output directory (default: detected) + * --chunk-blocks block chunk size for getLogs (default: 10000; auto-halved on range errors) + * --start-block start block (default: 0) + * --end-block end block (default: latest) + * + * Env vars (VAR_ or VAR): + * RPC_URL (required) + * UNISWAP_V2_FACTORY (optional, default mainnet factory) + * RPS (optional, default 80) + * + * Output: JSON array in UniswapV2PoolConfig format. + * Fee is always 0 (30 bps taken by V2 internally, not a V4 PoolKey param). + * tickSpacing defaults to 1 (minimum valid). + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { ethers } from 'ethers'; +import { parseArgs, getEnvForChain, toInt, resolveOutputPath } from '@src/cli'; +import { pRateLimit, ensureDirForFile } from '@src/utils'; +import type { Address } from '../creation-modules/types.js'; + +const OUTPUT_FILE = 'uniswapv2-pools.json'; +const DEFAULT_FACTORY: Address = '0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +const FACTORY_ABI = [ + 'event PairCreated(address indexed token0, address indexed token1, address pair, uint)', +]; + +type UniswapV2PoolConfig = { + poolType: 'uniswapv2'; + v2Pair: Address; + currency0: Address; + currency1: Address; + tickSpacing: number; + sqrtPriceX96: null; +}; + +function isRangeLimitError(err: unknown): boolean { + const msg = String( + (err as { error?: { message?: string }; message?: string })?.error + ?.message ?? + (err as { message?: string })?.message ?? + '', + ); + return ( + msg.toLowerCase().includes('range') || + msg.toLowerCase().includes('limit') || + msg.includes('-32614') + ); +} + +async function getLogsWithRetry( + provider: ethers.JsonRpcProvider, + filter: { + address: string; + topics: string[]; + fromBlock: bigint; + toBlock: bigint; + }, +): Promise { + try { + return await provider.getLogs({ + address: filter.address, + topics: filter.topics, + fromBlock: filter.fromBlock, + toBlock: filter.toBlock, + }); + } catch (err) { + if (!isRangeLimitError(err) || filter.toBlock <= filter.fromBlock) + throw err; + const mid = filter.fromBlock + (filter.toBlock - filter.fromBlock) / 2n; + const [lo, hi] = await Promise.all([ + getLogsWithRetry(provider, { ...filter, toBlock: mid }), + getLogsWithRetry(provider, { ...filter, fromBlock: mid + 1n }), + ]); + return lo.concat(hi); + } +} + +function isNative(addr: string): boolean { + return addr.toLowerCase() === ZERO_ADDRESS; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + if (!rpcUrl) { + console.error('Missing env: RPC_URL'); + process.exit(1); + } + + const factoryRaw = + getEnvForChain('UNISWAP_V2_FACTORY', chainId) ?? DEFAULT_FACTORY; + const factory = ethers.getAddress(factoryRaw) as Address; + + const outputDir = (args['output-dir'] as string) ?? 'detected'; + const chunkSize = BigInt(Math.max(1, toInt(args['chunk-blocks'], 10_000))); + const startBlock = BigInt(Math.max(0, toInt(args['start-block'], 0))); + const rps = toInt(getEnvForChain('RPS', chainId) ?? '80', 80); + const rateLimitAcquire = pRateLimit(rps); + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const iface = new ethers.Interface(FACTORY_ABI); + const topic0 = iface.getEvent('PairCreated')!.topicHash; + + const latest = BigInt(await provider.getBlockNumber()); + const endBlockRaw = args['end-block']; + const endBlock = endBlockRaw ? BigInt(String(endBlockRaw)) : latest; + + console.error(`Factory: ${factory}`); + console.error( + `Scanning blocks ${startBlock}..${endBlock} (chunk=${chunkSize})`, + ); + + const seen = new Set
(); + const configs: UniswapV2PoolConfig[] = []; + + for (let from = startBlock; from <= endBlock; from += chunkSize) { + const to = + from + chunkSize - 1n > endBlock ? endBlock : from + chunkSize - 1n; + await rateLimitAcquire(); + + const logs = await getLogsWithRetry(provider, { + address: factory, + topics: [topic0], + fromBlock: from, + toBlock: to, + }); + + for (const log of logs) { + const parsed = iface.parseLog(log); + if (!parsed) continue; + + const token0 = ethers.getAddress(parsed.args.token0 as string) as Address; + const token1 = ethers.getAddress(parsed.args.token1 as string) as Address; + const pair = ethers.getAddress(parsed.args.pair as string) as Address; + + // Skip native currency — UniswapV2Aggregator reverts on address(0) + if (isNative(token0) || isNative(token1)) continue; + if (seen.has(pair)) continue; + seen.add(pair); + + // token0 < token1 is guaranteed by the V2 factory + configs.push({ + poolType: 'uniswapv2', + v2Pair: pair, + currency0: token0, + currency1: token1, + tickSpacing: 1, + sqrtPriceX96: null, + }); + } + + if (to === endBlock || (to - startBlock) % (chunkSize * 10n) === 0n) { + console.error( + `[scan] ${from}..${to} — found ${configs.length} pools so far`, + ); + } + console.log(`[scan] ${from}..${to} — found ${configs.length} pools so far`); + } + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + ensureDirForFile(outPath); + fs.writeFileSync(outPath, JSON.stringify(configs, null, 2) + '\n'); + console.log( + JSON.stringify( + { + ok: true, + chainId, + factory, + poolsFound: configs.length, + outFile: outPath, + }, + null, + 2, + ), + ); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/aggregator-hooks/historical/UniswapV3.ts b/aggregator-hooks/historical/UniswapV3.ts new file mode 100644 index 00000000..ee1ff3cb --- /dev/null +++ b/aggregator-hooks/historical/UniswapV3.ts @@ -0,0 +1,211 @@ +/** + * Uniswap V3 historical pool discovery. + * Scans PoolCreated events from the Uniswap V3 factory and outputs pool + * configs in createPools.ts UniswapV3PoolConfig format. + * + * Usage: + * npx tsx historical/UniswapV3.ts --chain-id 1 + * + * Options: + * --chain-id (required) Chain ID + * --output-dir output directory (default: detected) + * --chunk-blocks block chunk size for getLogs (default: 10000; auto-halved on range errors) + * --start-block start block (default: 0) + * --end-block end block (default: latest) + * + * Env vars (VAR_ or VAR): + * RPC_URL (required) + * UNISWAP_V3_FACTORY (optional, default mainnet factory) + * RPS (optional, default 80) + * CONCURRENCY (optional, default 8) + * + * Output: JSON array in UniswapV3PoolConfig format (fee + tickSpacing from event). + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { ethers } from 'ethers'; +import { parseArgs, getEnvForChain, toInt, resolveOutputPath } from '@src/cli'; +import { pRateLimit, ensureDirForFile } from '@src/utils'; +import type { Address } from '../creation-modules/types.js'; + +const OUTPUT_FILE = 'uniswapv3-pools.json'; +const DEFAULT_FACTORY: Address = '0x1F98431c8aD98523631AE4a59f267346ea31F984'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +const FACTORY_ABI = [ + 'event PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)', +]; + +type UniswapV3PoolConfig = { + poolType: 'uniswapv3'; + v3Pool: Address; + currency0: Address; + currency1: Address; + fee: number; + tickSpacing: number; + sqrtPriceX96: null; +}; + +function isRangeLimitError(err: unknown): boolean { + const msg = String( + (err as { error?: { message?: string }; message?: string })?.error + ?.message ?? + (err as { message?: string })?.message ?? + '', + ); + return ( + msg.toLowerCase().includes('range') || + msg.toLowerCase().includes('limit') || + msg.includes('-32614') + ); +} + +/** getLogs with automatic chunk-halving on range-limit errors. */ +async function getLogsWithRetry( + provider: ethers.JsonRpcProvider, + filter: { + address: string; + topics: string[]; + fromBlock: bigint; + toBlock: bigint; + }, +): Promise { + try { + return await provider.getLogs({ + address: filter.address, + topics: filter.topics, + fromBlock: filter.fromBlock, + toBlock: filter.toBlock, + }); + } catch (err) { + if (!isRangeLimitError(err) || filter.toBlock <= filter.fromBlock) + throw err; + const mid = filter.fromBlock + (filter.toBlock - filter.fromBlock) / 2n; + const [lo, hi] = await Promise.all([ + getLogsWithRetry(provider, { ...filter, toBlock: mid }), + getLogsWithRetry(provider, { ...filter, fromBlock: mid + 1n }), + ]); + return lo.concat(hi); + } +} + +function isNative(addr: string): boolean { + return addr.toLowerCase() === ZERO_ADDRESS; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + if (!rpcUrl) { + console.error('Missing env: RPC_URL'); + process.exit(1); + } + + const factoryRaw = + getEnvForChain('UNISWAP_V3_FACTORY', chainId) ?? DEFAULT_FACTORY; + const factory = ethers.getAddress(factoryRaw) as Address; + + const outputDir = (args['output-dir'] as string) ?? 'detected'; + const chunkSize = BigInt(Math.max(1, toInt(args['chunk-blocks'], 10_000))); + const startBlock = BigInt(Math.max(0, toInt(args['start-block'], 0))); + const rps = toInt(getEnvForChain('RPS', chainId) ?? '80', 80); + const rateLimitAcquire = pRateLimit(rps); + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const iface = new ethers.Interface(FACTORY_ABI); + const topic0 = iface.getEvent('PoolCreated')!.topicHash; + + const latest = BigInt(await provider.getBlockNumber()); + const endBlockRaw = args['end-block']; + const endBlock = endBlockRaw ? BigInt(String(endBlockRaw)) : latest; + + console.error(`Factory: ${factory}`); + console.error( + `Scanning blocks ${startBlock}..${endBlock} (chunk=${chunkSize})`, + ); + + const seen = new Set
(); + const configs: UniswapV3PoolConfig[] = []; + + for (let from = startBlock; from <= endBlock; from += chunkSize) { + const to = + from + chunkSize - 1n > endBlock ? endBlock : from + chunkSize - 1n; + await rateLimitAcquire(); + + const logs = await getLogsWithRetry(provider, { + address: factory, + topics: [topic0], + fromBlock: from, + toBlock: to, + }); + + for (const log of logs) { + const parsed = iface.parseLog(log); + if (!parsed) continue; + + const token0 = ethers.getAddress(parsed.args.token0 as string) as Address; + const token1 = ethers.getAddress(parsed.args.token1 as string) as Address; + const pool = ethers.getAddress(parsed.args.pool as string) as Address; + const fee = Number(parsed.args.fee); + const tickSpacing = Number(parsed.args.tickSpacing); + + // Skip native currency — UniswapV3Aggregator reverts on address(0) + if (isNative(token0) || isNative(token1)) continue; + if (seen.has(pool)) continue; + seen.add(pool); + + // token0 < token1 is guaranteed by the V3 factory; currency ordering is already correct + configs.push({ + poolType: 'uniswapv3', + v3Pool: pool, + currency0: token0, + currency1: token1, + fee, + tickSpacing, + sqrtPriceX96: null, + }); + } + + if (to === endBlock || (to - startBlock) % (chunkSize * 10n) === 0n) { + console.error( + `[scan] ${from}..${to} — found ${configs.length} pools so far`, + ); + } + console.log(`[scan] ${from}..${to} — found ${configs.length} pools so far`); + } + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + ensureDirForFile(outPath); + fs.writeFileSync(outPath, JSON.stringify(configs, null, 2) + '\n'); + console.log( + JSON.stringify( + { + ok: true, + chainId, + factory, + poolsFound: configs.length, + outFile: outPath, + }, + null, + 2, + ), + ); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/aggregator-hooks/package-lock.json b/aggregator-hooks/package-lock.json new file mode 100644 index 00000000..bf94ed29 --- /dev/null +++ b/aggregator-hooks/package-lock.json @@ -0,0 +1,729 @@ +{ + "name": "agg-hook-scripts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agg-hook-scripts", + "version": "1.0.0", + "dependencies": { + "dotenv": "^17.3.1", + "ethers": "^6.13.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "prettier": "^3.8.3", + "tsx": "^4.19.2", + "typescript": "^5.7.0" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/aggregator-hooks/package.json b/aggregator-hooks/package.json new file mode 100644 index 00000000..2d937024 --- /dev/null +++ b/aggregator-hooks/package.json @@ -0,0 +1,27 @@ +{ + "name": "agg-hook-scripts", + "version": "1.0.0", + "description": "Aggregator hook pool discovery and creation scripts", + "type": "module", + "scripts": { + "build": "tsc && cp abis/*.json dist/abis/", + "stableswapng": "tsx historical/StableSwapNG.ts", + "fluiddext1": "tsx historical/FluidDexT1.ts", + "fluiddexlite": "tsx historical/FluidDexLite.ts", + "create-pools": "tsx src/createPools.ts", + "format": "prettier --write \"**/*.{ts,json}\"" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "prettier": "^3.8.3", + "tsx": "^4.19.2", + "typescript": "^5.7.0" + }, + "prettier": { + "singleQuote": true + }, + "dependencies": { + "dotenv": "^17.3.1", + "ethers": "^6.13.0" + } +} diff --git a/aggregator-hooks/polling/FluidDexLite.ts b/aggregator-hooks/polling/FluidDexLite.ts new file mode 100644 index 00000000..0ec82ce7 --- /dev/null +++ b/aggregator-hooks/polling/FluidDexLite.ts @@ -0,0 +1,288 @@ +/** + * Fluid Dex Lite pool discovery (checkpoint-based polling): + * - loads checkpoint which stores lastProcessedBlock + * - scans blocks since checkpoint for LogInitialize events from FluidDexLite + * - decodes events and appends new pool records to output + * - updates checkpoint to the latest safely processed block + * + * Usage: + * npx tsx polling/fluiddexlite.ts --chain-id 1 + * + * Options: + * --chain-id (required) Chain ID; loads RPC_URL_, FLUID_DEX_LITE_ + * --output-dir output directory (default: detected); writes to output-dir/chain-id/fluiddexlite-pools.json + * --checkpoint-dir checkpoint directory (default: checkpoints); writes to checkpoint-dir/chain-id/dexlite_checkpoint.json + * --chunk-blocks block chunk size for getLogs (default: 10000) + * --start-block (optional) override checkpoint; start scan from this block + * + * Env vars (use VAR_ or VAR for single chain): + * RPC_URL (required) + * FLUID_DEX_LITE (required) FluidDexLite singleton + * FINALITY_BLOCKS (optional, default 10) subtract from latest; checkpoint = last scanned block + * LOOKBACK_BLOCKS (optional, default 200000) used when checkpoint missing and no --start-block + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { ethers } from 'ethers'; +import { + parseArgs, + getEnvForChain, + toInt, + resolveOutputPath, + resolveCheckpointPath, +} from '@src/cli'; +import { safeReadJson, atomicWriteFile, ensureDirForFile } from '@src/utils'; + +const OUTPUT_FILE = 'fluiddexlite-pools.json'; +const CHECKPOINT_FILE = 'dexlite_checkpoint.json'; + +/** Fluid native token; map to address(0) for Uniswap v4 pool init */ +const FLUID_NATIVE = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +function toUniswapV4Currency(addr: string): string { + return addr.toLowerCase() === FLUID_NATIVE.toLowerCase() + ? ZERO_ADDRESS + : ethers.getAddress(addr); +} + +type Checkpoint = { + chainId: number; + dexLite: string; + lastProcessedBlock: number; + updatedAt: string; +}; + +/** Same shape as createPools.ts FluidDexLitePoolConfig */ +type CreatePoolsFluidLiteConfig = { + poolType: 'fluiddexlite'; + dexSalt: string; + currency0: string; + currency1: string; + fee: number | null; + tickSpacing: number | null; + sqrtPriceX96: string | null; +}; + +const LOG_INITIALIZE_ABI = [ + 'event LogInitialize(uint256 dexId, tuple(address token0,address token1,bytes32 salt) dexKey, tuple(tuple(address token0,address token1,bytes32 salt) dexKey,uint256 fee,uint256 revenueCut,bool rebalancingStatus,uint256 centerPrice,uint256 centerPriceContract,uint256 upperPercent,uint256 lowerPercent,uint256 upperShiftThreshold,uint256 lowerShiftThreshold,uint256 shiftTime,uint256 minCenterPrice,uint256 maxCenterPrice,uint256 token0Amount,uint256 token1Amount) params, uint256 time)', +] as const; + +function loadExistingKeys(outFile: string): Set { + const keys = new Set(); + if (!fs.existsSync(outFile)) return keys; + const raw = fs.readFileSync(outFile, 'utf8').trim(); + if (!raw) return keys; + try { + const arr = JSON.parse(raw) as CreatePoolsFluidLiteConfig[]; + if (!Array.isArray(arr)) return keys; + for (const x of arr) { + keys.add(`${x.dexSalt}:${x.currency0}:${x.currency1}`); + } + } catch { + // ignore + } + return keys; +} + +/** Return [currency0, currency1] with currency0 < currency1 (lexicographic on mapped addrs) */ +function orderCurrencies(a: string, b: string): [string, string] { + const mapped = [toUniswapV4Currency(a), toUniswapV4Currency(b)].sort((x, y) => + x.toLowerCase().localeCompare(y.toLowerCase()), + ); + return [mapped[0], mapped[1]]; +} + +function getEnvInt(name: string, chainId: number, def: number): number { + const v = getEnvForChain(name, chainId); + if (!v) return def; + const n = Number(v); + return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : def; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + const dexLiteRaw = getEnvForChain('FLUID_DEX_LITE', chainId); + if (!rpcUrl || !dexLiteRaw) { + throw new Error( + 'Missing required env: RPC_URL and FLUID_DEX_LITE (or RPC_URL_, FLUID_DEX_LITE_)', + ); + } + const dexLite = ethers.getAddress(dexLiteRaw); + + const outputDir = (args['output-dir'] as string) ?? 'detected'; + const checkpointDir = (args['checkpoint-dir'] as string) ?? 'checkpoints'; + const chunkBlocks = Math.max(1, toInt(args['chunk-blocks'], 10000)); + + const finality = getEnvInt('FINALITY_BLOCKS', chainId, 10); + const lookbackBlocks = getEnvInt('LOOKBACK_BLOCKS', chainId, 200000); + const startBlockArg = args['start-block']; + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const iface = new ethers.Interface(LOG_INITIALIZE_ABI as unknown as string[]); + const topic0 = iface.getEvent('LogInitialize')!.topicHash; + + const latestBlock = await provider.getBlockNumber(); + const toBlock = Math.max(0, latestBlock - finality); + + const cpPath = resolveCheckpointPath(checkpointDir, chainId, CHECKPOINT_FILE); + const cp = safeReadJson(cpPath); + + let fromBlock: number; + if (startBlockArg != null) { + fromBlock = Math.max(0, toInt(startBlockArg, 0)); + } else if ( + cp && + cp.chainId === chainId && + cp.dexLite.toLowerCase() === dexLite.toLowerCase() + ) { + fromBlock = cp.lastProcessedBlock + 1; + } else { + fromBlock = Math.max(0, toBlock - lookbackBlocks); + } + + if (fromBlock > toBlock) { + const newCp: Checkpoint = { + chainId, + dexLite, + lastProcessedBlock: toBlock, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(newCp, null, 2) + '\n'); + console.log( + JSON.stringify( + { + ok: true, + note: 'No new blocks to scan', + chainId, + dexLite, + fromBlock, + toBlock, + latestBlock, + finality, + checkpointFile: cpPath, + }, + null, + 2, + ), + ); + return; + } + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + const seenKeys = loadExistingKeys(outPath); + ensureDirForFile(outPath); + + let totalLogs = 0; + let newPools = 0; + + for (let start = fromBlock; start <= toBlock; start += chunkBlocks) { + const end = Math.min(toBlock, start + chunkBlocks - 1); + + const logs = await provider.getLogs({ + address: dexLite, + topics: [topic0], + fromBlock: start, + toBlock: end, + }); + + totalLogs += logs.length; + const newRecords: CreatePoolsFluidLiteConfig[] = []; + + for (const log of logs) { + let parsed: ethers.LogDescription | null; + try { + parsed = iface.parseLog(log); + } catch { + continue; + } + + const dexKey = parsed?.args[1] as { + token0: string; + token1: string; + salt: string; + }; + + const token0 = ethers.getAddress(dexKey.token0); + const token1 = ethers.getAddress(dexKey.token1); + const salt = dexKey.salt as string; + + const [currency0, currency1] = orderCurrencies(token0, token1); + const dedupeKey = `${salt}:${currency0}:${currency1}`; + if (seenKeys.has(dedupeKey)) continue; + seenKeys.add(dedupeKey); + + newPools++; + newRecords.push({ + poolType: 'fluiddexlite', + dexSalt: salt, + currency0, + currency1, + fee: null, + tickSpacing: null, + sqrtPriceX96: null, + }); + } + + if (newRecords.length > 0) { + const existing = + safeReadJson(outPath) ?? []; + const merged = existing.concat(newRecords); + atomicWriteFile(outPath, JSON.stringify(merged, null, 2) + '\n'); + } + + const interimCp: Checkpoint = { + chainId, + dexLite, + lastProcessedBlock: end, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(interimCp, null, 2) + '\n'); + + console.error( + `[scan] ${start}..${end} logs=${logs.length} new=${newRecords.length}`, + ); + } + + console.log( + JSON.stringify( + { + ok: true, + chainId, + dexLite, + scanned: { + fromBlock, + toBlock, + latestBlock, + finality, + chunkBlocks, + }, + logsFound: totalLogs, + newPools, + outFile: outPath, + checkpointFile: cpPath, + }, + null, + 2, + ), + ); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/aggregator-hooks/polling/FluidDexT1.ts b/aggregator-hooks/polling/FluidDexT1.ts new file mode 100644 index 00000000..954a1f43 --- /dev/null +++ b/aggregator-hooks/polling/FluidDexT1.ts @@ -0,0 +1,305 @@ +/** + * Fluid Dex T1 pool discovery (checkpoint-based polling): + * - loads checkpoint which stores lastProcessedBlock + * - scans blocks since checkpoint for LogDexDeployed events from FluidDexFactory + * - for each new dex, fetches currency0/currency1 via resolver.getDexTokens + * - appends to output in createPools format + * + * Usage: + * npx tsx polling/fluiddext1.ts --chain-id 1 + * + * Options: + * --chain-id (required) Chain ID; loads RPC_URL_, FLUID_DEX_T1_RESOLVER_, etc. + * --output-dir output directory (default: detected); writes to output-dir/chain-id/fluiddext1-pools.json + * --checkpoint-dir checkpoint directory (default: checkpoints) + * --chunk-blocks block chunk size for getLogs (default: 10000) + * --start-block (optional) override checkpoint; start scan from this block + * + * Env vars (use VAR_ or VAR for single chain): + * RPC_URL (required) + * FLUID_DEX_T1_RESOLVER (required) IFluidDexResolver for getDexTokens + * FLUID_DEX_T1_FACTORY (optional, default mainnet) + * FINALITY_BLOCKS (optional, default 10) subtract from latest; checkpoint = last scanned block + * LOOKBACK_BLOCKS (optional, default 200000) used when checkpoint missing and no --start-block + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { ethers } from 'ethers'; +import { + parseArgs, + getEnvForChain, + toInt, + resolveOutputPath, + resolveCheckpointPath, + withRetry, +} from '@src/cli'; +import { pRateLimit, pLimit, safeReadJson, atomicWriteFile } from '@src/utils'; + +const OUTPUT_FILE = 'fluiddext1-pools.json'; +const CHECKPOINT_FILE = 'fluiddext1_checkpoint.json'; +const DEFAULT_FACTORY = '0x91716c4eDA1fB55e84Bf8b4c7085f84285c19085'; + +/** Fluid native token; map to address(0) for Uniswap v4 pool init */ +const FLUID_NATIVE = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +function toUniswapV4Currency(addr: string): string { + return addr.toLowerCase() === FLUID_NATIVE.toLowerCase() + ? ZERO_ADDRESS + : ethers.getAddress(addr); +} + +type Checkpoint = { + chainId: number; + factory: string; + lastProcessedBlock: number; + updatedAt: string; +}; + +/** Same shape as createPools.ts FluidDexT1PoolConfig */ +type CreatePoolsFluidDexT1Config = { + poolType: 'fluiddext1'; + fluidPool: string; + currency0: string; + currency1: string; + fee: number | null; + tickSpacing: number | null; + sqrtPriceX96: string | null; +}; + +const FACTORY_ABI = [ + 'event LogDexDeployed(address indexed dex, uint256 indexed dexId)', +]; + +const RESOLVER_ABI = [ + 'function getDexTokens(address dex_) external view returns (address token0_, address token1_)', +]; + +function loadExistingKeys(outFile: string): Set { + const keys = new Set(); + if (!fs.existsSync(outFile)) return keys; + try { + const arr = safeReadJson(outFile); + if (!Array.isArray(arr)) return keys; + for (const x of arr) + keys.add(`${x.fluidPool}:${x.currency0}:${x.currency1}`); + } catch { + // ignore + } + return keys; +} + +/** Return [currency0, currency1] with currency0 < currency1 (lexicographic on mapped addrs) */ +function orderCurrencies(a: string, b: string): [string, string] { + const mapped = [toUniswapV4Currency(a), toUniswapV4Currency(b)].sort((x, y) => + x.toLowerCase().localeCompare(y.toLowerCase()), + ); + return [mapped[0], mapped[1]]; +} + +function getEnvInt(name: string, chainId: number, def: number): number { + const v = getEnvForChain(name, chainId); + if (!v) return def; + const n = Number(v); + return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : def; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + const resolverAddr = getEnvForChain('FLUID_DEX_T1_RESOLVER', chainId); + const factoryRaw = + getEnvForChain('FLUID_DEX_T1_FACTORY', chainId) ?? DEFAULT_FACTORY; + + if (!rpcUrl || !resolverAddr) { + throw new Error('Missing required env: RPC_URL and FLUID_DEX_T1_RESOLVER'); + } + + const factory = ethers.getAddress(factoryRaw); + + const outputDir = (args['output-dir'] as string) ?? 'detected'; + const checkpointDir = (args['checkpoint-dir'] as string) ?? 'checkpoints'; + const chunkBlocks = Math.max(1, toInt(args['chunk-blocks'], 10000)); + + const finality = getEnvInt('FINALITY_BLOCKS', chainId, 10); + const lookbackBlocks = getEnvInt('LOOKBACK_BLOCKS', chainId, 200000); + const startBlockArg = args['start-block']; + + const concurrency = Math.max( + 1, + toInt(getEnvForChain('CONCURRENCY', chainId), 8), + ); + const rps = toInt(getEnvForChain('RPS', chainId), 80); + const rateLimit = pRateLimit(rps); + const limit = pLimit(concurrency); + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const resolver = new ethers.Contract( + ethers.getAddress(resolverAddr), + RESOLVER_ABI, + provider, + ); + + const iface = new ethers.Interface(FACTORY_ABI as unknown as string[]); + const topic0 = iface.getEvent('LogDexDeployed')!.topicHash; + + const latestBlock = await withRetry(() => provider.getBlockNumber()); + const toBlock = Math.max(0, latestBlock - finality); + + const cpPath = resolveCheckpointPath(checkpointDir, chainId, CHECKPOINT_FILE); + const cp = safeReadJson(cpPath); + + let fromBlock: number; + if (startBlockArg != null) { + fromBlock = Math.max(0, toInt(startBlockArg, 0)); + } else if ( + cp?.chainId === chainId && + cp?.factory?.toLowerCase() === factory.toLowerCase() + ) { + fromBlock = cp.lastProcessedBlock + 1; + } else { + fromBlock = Math.max(0, toBlock - lookbackBlocks); + } + + if (fromBlock > toBlock) { + const newCp: Checkpoint = { + chainId, + factory, + lastProcessedBlock: toBlock, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(newCp, null, 2) + '\n'); + console.log( + JSON.stringify( + { ok: true, note: 'No new blocks to scan', fromBlock, toBlock }, + null, + 2, + ), + ); + return; + } + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + const seenKeys = loadExistingKeys(outPath); + let allRecords = safeReadJson(outPath) ?? []; + let totalLogs = 0; + let newPools = 0; + + for (let start = fromBlock; start <= toBlock; start += chunkBlocks) { + const end = Math.min(toBlock, start + chunkBlocks - 1); + + const logs = await withRetry(() => + provider.getLogs({ + address: factory, + topics: [topic0], + fromBlock: start, + toBlock: end, + }), + ); + + totalLogs += logs.length; + + const newRecords = ( + await Promise.all( + logs.map((log) => + limit(async () => { + let parsed: ethers.LogDescription | null; + try { + parsed = iface.parseLog(log); + } catch { + return null; + } + if (!parsed) return null; + + const dex = ethers.getAddress(parsed.args.dex as string); + let token0: string; + let token1: string; + try { + await rateLimit(); + [token0, token1] = await withRetry(() => + resolver.getDexTokens(dex), + ); + } catch { + console.error(`Failed getDexTokens for ${dex}`); + return null; + } + + const [currency0, currency1] = orderCurrencies(token0, token1); + const key = `${dex}:${currency0}:${currency1}`; + if (seenKeys.has(key)) return null; + seenKeys.add(key); + + return { + poolType: 'fluiddext1' as const, + fluidPool: dex, + currency0, + currency1, + fee: null, + tickSpacing: null, + sqrtPriceX96: null, + }; + }), + ), + ) + ).filter(Boolean) as CreatePoolsFluidDexT1Config[]; + + newPools += newRecords.length; + + if (newRecords.length > 0) { + allRecords = allRecords.concat(newRecords); + atomicWriteFile(outPath, JSON.stringify(allRecords, null, 2) + '\n'); + } + + const interimCp: Checkpoint = { + chainId, + factory, + lastProcessedBlock: end, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(interimCp, null, 2) + '\n'); + console.error( + `[scan] ${start}..${end} logs=${logs.length} new=${newRecords.length}`, + ); + } + + console.log( + JSON.stringify( + { + ok: true, + chainId, + factory, + scanned: { + fromBlock, + toBlock, + latestBlock, + finality, + chunkBlocks, + }, + logsFound: totalLogs, + newPools, + outFile: outPath, + checkpointFile: cpPath, + }, + null, + 2, + ), + ); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/aggregator-hooks/polling/PancakeSwapV3.ts b/aggregator-hooks/polling/PancakeSwapV3.ts new file mode 100644 index 00000000..c9fdf140 --- /dev/null +++ b/aggregator-hooks/polling/PancakeSwapV3.ts @@ -0,0 +1,240 @@ +/** + * PancakeSwap V3 pool discovery (checkpoint-based polling). + * Scans new PoolCreated events from the PancakeSwap V3 factory since the last + * checkpoint and appends new pools to the output file. + * + * Usage: + * npx tsx polling/PancakeSwapV3.ts --chain-id 1 + * + * Options: + * --chain-id (required) Chain ID + * --output-dir output directory (default: detected) + * --checkpoint-dir checkpoint directory (default: checkpoints) + * --chunk-blocks block chunk size (default: 10000) + * --start-block (optional) override checkpoint start block + * + * Env vars (VAR_ or VAR): + * RPC_URL (required) + * PANCAKESWAP_V3_FACTORY (optional, default mainnet factory) + * FINALITY_BLOCKS (optional, default 10) + * LOOKBACK_BLOCKS (optional, default 200000) + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { ethers } from 'ethers'; +import { + parseArgs, + getEnvForChain, + toInt, + resolveOutputPath, + resolveCheckpointPath, +} from '@src/cli'; +import { safeReadJson, atomicWriteFile } from '@src/utils'; + +const OUTPUT_FILE = 'pancakeswapv3-pools.json'; +const CHECKPOINT_FILE = 'pancakeswapv3_checkpoint.json'; +const DEFAULT_FACTORY = '0x0BFbCF9fa4f9C56B0F40a671Ad40E0805A091865'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +const FACTORY_ABI = [ + 'event PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)', +]; + +type Checkpoint = { + chainId: number; + factory: string; + lastProcessedBlock: number; + updatedAt: string; +}; + +type PancakeSwapV3PoolConfig = { + poolType: 'pancakeswapv3'; + v3Pool: string; + currency0: string; + currency1: string; + fee: number; + tickSpacing: number; + sqrtPriceX96: null; +}; + +function isNative(addr: string): boolean { + return addr.toLowerCase() === ZERO_ADDRESS; +} + +function loadExistingKeys(outFile: string): Set { + const keys = new Set(); + if (!fs.existsSync(outFile)) return keys; + try { + const arr = safeReadJson(outFile); + if (!Array.isArray(arr)) return keys; + for (const x of arr) keys.add(x.v3Pool.toLowerCase()); + } catch { + /* ignore */ + } + return keys; +} + +function getEnvInt(name: string, chainId: number, def: number): number { + const v = getEnvForChain(name, chainId); + if (!v) return def; + const n = Number(v); + return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : def; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + if (!rpcUrl) throw new Error('Missing required env: RPC_URL'); + + const factoryRaw = + getEnvForChain('PANCAKESWAP_V3_FACTORY', chainId) ?? DEFAULT_FACTORY; + const factory = ethers.getAddress(factoryRaw); + + const outputDir = (args['output-dir'] as string) ?? 'detected'; + const checkpointDir = (args['checkpoint-dir'] as string) ?? 'checkpoints'; + const chunkBlocks = Math.max(1, toInt(args['chunk-blocks'], 10_000)); + const finality = getEnvInt('FINALITY_BLOCKS', chainId, 10); + const lookbackBlocks = getEnvInt('LOOKBACK_BLOCKS', chainId, 200_000); + const startBlockArg = args['start-block']; + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const iface = new ethers.Interface(FACTORY_ABI); + const topic0 = iface.getEvent('PoolCreated')!.topicHash; + + const latestBlock = await provider.getBlockNumber(); + const toBlock = Math.max(0, latestBlock - finality); + + const cpPath = resolveCheckpointPath(checkpointDir, chainId, CHECKPOINT_FILE); + const cp = safeReadJson(cpPath); + + let fromBlock: number; + if (startBlockArg != null) { + fromBlock = Math.max(0, toInt(startBlockArg, 0)); + } else if ( + cp?.chainId === chainId && + cp?.factory?.toLowerCase() === factory.toLowerCase() + ) { + fromBlock = cp.lastProcessedBlock + 1; + } else { + fromBlock = Math.max(0, toBlock - lookbackBlocks); + } + + if (fromBlock > toBlock) { + const newCp: Checkpoint = { + chainId, + factory, + lastProcessedBlock: toBlock, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(newCp, null, 2) + '\n'); + console.log( + JSON.stringify( + { ok: true, note: 'No new blocks to scan', fromBlock, toBlock }, + null, + 2, + ), + ); + return; + } + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + const seenKeys = loadExistingKeys(outPath); + let allRecords = safeReadJson(outPath) ?? []; + let totalLogs = 0; + let newPools = 0; + + for (let start = fromBlock; start <= toBlock; start += chunkBlocks) { + const end = Math.min(toBlock, start + chunkBlocks - 1); + + const logs = await provider.getLogs({ + address: factory, + topics: [topic0], + fromBlock: start, + toBlock: end, + }); + totalLogs += logs.length; + const newRecords: PancakeSwapV3PoolConfig[] = []; + + for (const log of logs) { + let parsed: ethers.LogDescription | null; + try { + parsed = iface.parseLog(log); + } catch { + continue; + } + if (!parsed) continue; + + const token0 = ethers.getAddress(parsed.args.token0 as string); + const token1 = ethers.getAddress(parsed.args.token1 as string); + const pool = ethers.getAddress(parsed.args.pool as string); + const fee = Number(parsed.args.fee); + const tickSpacing = Number(parsed.args.tickSpacing); + + if (isNative(token0) || isNative(token1)) continue; + if (seenKeys.has(pool.toLowerCase())) continue; + seenKeys.add(pool.toLowerCase()); + + newPools++; + newRecords.push({ + poolType: 'pancakeswapv3', + v3Pool: pool, + currency0: token0, + currency1: token1, + fee, + tickSpacing, + sqrtPriceX96: null, + }); + } + + if (newRecords.length > 0) { + allRecords = allRecords.concat(newRecords); + atomicWriteFile(outPath, JSON.stringify(allRecords, null, 2) + '\n'); + } + + const interimCp: Checkpoint = { + chainId, + factory, + lastProcessedBlock: end, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(interimCp, null, 2) + '\n'); + console.error( + `[scan] ${start}..${end} logs=${logs.length} new=${newRecords.length}`, + ); + } + + console.log( + JSON.stringify( + { + ok: true, + chainId, + factory, + scanned: { fromBlock, toBlock, latestBlock, finality, chunkBlocks }, + logsFound: totalLogs, + newPools, + outFile: outPath, + checkpointFile: cpPath, + }, + null, + 2, + ), + ); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/aggregator-hooks/polling/Slipstream.ts b/aggregator-hooks/polling/Slipstream.ts new file mode 100644 index 00000000..c2300751 --- /dev/null +++ b/aggregator-hooks/polling/Slipstream.ts @@ -0,0 +1,240 @@ +/** + * Slipstream pool discovery (checkpoint-based polling). + * Scans new PoolCreated events from the Slipstream (Velodrome/Aerodrome CL) + * factory since the last checkpoint and appends new pools to the output file. + * + * Usage: + * npx tsx polling/Slipstream.ts --chain-id 10 + * + * Options: + * --chain-id (required) Chain ID + * --output-dir output directory (default: detected) + * --checkpoint-dir checkpoint directory (default: checkpoints) + * --chunk-blocks block chunk size (default: 10000) + * --start-block (optional) override checkpoint start block + * + * Env vars (VAR_ or VAR): + * RPC_URL (required) + * SLIPSTREAM_FACTORY (required — no chain-agnostic default) + * FINALITY_BLOCKS (optional, default 10) + * LOOKBACK_BLOCKS (optional, default 200000) + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { ethers } from 'ethers'; +import { + parseArgs, + getEnvForChain, + toInt, + resolveOutputPath, + resolveCheckpointPath, +} from '@src/cli'; +import { safeReadJson, atomicWriteFile } from '@src/utils'; + +const OUTPUT_FILE = 'slipstream-pools.json'; +const CHECKPOINT_FILE = 'slipstream_checkpoint.json'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +// Slipstream CLFactory: tickSpacing is the third indexed param (not fee) +const FACTORY_ABI = [ + 'event PoolCreated(address indexed token0, address indexed token1, int24 indexed tickSpacing, address pool)', +]; + +type Checkpoint = { + chainId: number; + factory: string; + lastProcessedBlock: number; + updatedAt: string; +}; + +type SlipstreamPoolConfig = { + poolType: 'slipstream'; + slipstreamPool: string; + currency0: string; + currency1: string; + tickSpacing: number; + sqrtPriceX96: null; +}; + +function isNative(addr: string): boolean { + return addr.toLowerCase() === ZERO_ADDRESS; +} + +function loadExistingKeys(outFile: string): Set { + const keys = new Set(); + if (!fs.existsSync(outFile)) return keys; + try { + const arr = safeReadJson(outFile); + if (!Array.isArray(arr)) return keys; + for (const x of arr) keys.add(x.slipstreamPool.toLowerCase()); + } catch { + /* ignore */ + } + return keys; +} + +function getEnvInt(name: string, chainId: number, def: number): number { + const v = getEnvForChain(name, chainId); + if (!v) return def; + const n = Number(v); + return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : def; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + if (!rpcUrl) throw new Error('Missing required env: RPC_URL'); + + const factoryRaw = getEnvForChain('SLIPSTREAM_FACTORY', chainId); + if (!factoryRaw) + throw new Error( + 'Missing required env: SLIPSTREAM_FACTORY (or SLIPSTREAM_FACTORY_)', + ); + const factory = ethers.getAddress(factoryRaw); + + const outputDir = (args['output-dir'] as string) ?? 'detected'; + const checkpointDir = (args['checkpoint-dir'] as string) ?? 'checkpoints'; + const chunkBlocks = Math.max(1, toInt(args['chunk-blocks'], 10_000)); + const finality = getEnvInt('FINALITY_BLOCKS', chainId, 10); + const lookbackBlocks = getEnvInt('LOOKBACK_BLOCKS', chainId, 200_000); + const startBlockArg = args['start-block']; + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const iface = new ethers.Interface(FACTORY_ABI); + const topic0 = iface.getEvent('PoolCreated')!.topicHash; + + const latestBlock = await provider.getBlockNumber(); + const toBlock = Math.max(0, latestBlock - finality); + + const cpPath = resolveCheckpointPath(checkpointDir, chainId, CHECKPOINT_FILE); + const cp = safeReadJson(cpPath); + + let fromBlock: number; + if (startBlockArg != null) { + fromBlock = Math.max(0, toInt(startBlockArg, 0)); + } else if ( + cp?.chainId === chainId && + cp?.factory?.toLowerCase() === factory.toLowerCase() + ) { + fromBlock = cp.lastProcessedBlock + 1; + } else { + fromBlock = Math.max(0, toBlock - lookbackBlocks); + } + + if (fromBlock > toBlock) { + const newCp: Checkpoint = { + chainId, + factory, + lastProcessedBlock: toBlock, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(newCp, null, 2) + '\n'); + console.log( + JSON.stringify( + { ok: true, note: 'No new blocks to scan', fromBlock, toBlock }, + null, + 2, + ), + ); + return; + } + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + const seenKeys = loadExistingKeys(outPath); + let allRecords = safeReadJson(outPath) ?? []; + let totalLogs = 0; + let newPools = 0; + + for (let start = fromBlock; start <= toBlock; start += chunkBlocks) { + const end = Math.min(toBlock, start + chunkBlocks - 1); + + const logs = await provider.getLogs({ + address: factory, + topics: [topic0], + fromBlock: start, + toBlock: end, + }); + totalLogs += logs.length; + const newRecords: SlipstreamPoolConfig[] = []; + + for (const log of logs) { + let parsed: ethers.LogDescription | null; + try { + parsed = iface.parseLog(log); + } catch { + continue; + } + if (!parsed) continue; + + const token0 = ethers.getAddress(parsed.args.token0 as string); + const token1 = ethers.getAddress(parsed.args.token1 as string); + const pool = ethers.getAddress(parsed.args.pool as string); + const tickSpacing = Number(parsed.args.tickSpacing); + + if (isNative(token0) || isNative(token1)) continue; + if (seenKeys.has(pool.toLowerCase())) continue; + seenKeys.add(pool.toLowerCase()); + + newPools++; + newRecords.push({ + poolType: 'slipstream', + slipstreamPool: pool, + currency0: token0, + currency1: token1, + tickSpacing, + sqrtPriceX96: null, + }); + } + + if (newRecords.length > 0) { + allRecords = allRecords.concat(newRecords); + atomicWriteFile(outPath, JSON.stringify(allRecords, null, 2) + '\n'); + } + + const interimCp: Checkpoint = { + chainId, + factory, + lastProcessedBlock: end, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(interimCp, null, 2) + '\n'); + console.error( + `[scan] ${start}..${end} logs=${logs.length} new=${newRecords.length}`, + ); + } + + console.log( + JSON.stringify( + { + ok: true, + chainId, + factory, + scanned: { fromBlock, toBlock, latestBlock, finality, chunkBlocks }, + logsFound: totalLogs, + newPools, + outFile: outPath, + checkpointFile: cpPath, + }, + null, + 2, + ), + ); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/aggregator-hooks/polling/StableSwapNG.ts b/aggregator-hooks/polling/StableSwapNG.ts new file mode 100644 index 00000000..c2fe23d8 --- /dev/null +++ b/aggregator-hooks/polling/StableSwapNG.ts @@ -0,0 +1,337 @@ +/** + * StableSwap-NG pool discovery (block-based polling): + * - loads checkpoint which stores lastProcessedBlock + * - scans blocks since checkpoint for PlainPoolDeployed/MetaPoolDeployed events + * - event count = number of new pools; fetches pool_list(n-i+1)..pool_list(n) where n=poolCount-1, i=eventCount + * - appends new pool records to output in createPools format + * - updates checkpoint to last scanned block + * + * Usage: + * npx tsx polling/stableswapng.ts --chain-id 1 + * + * Options: + * --chain-id (required) Chain ID; loads RPC_URL_, STABLESWAPNG_FACTORY_ + * --output-dir output directory (default: detected) + * --checkpoint-dir checkpoint directory (default: checkpoints) + * --chunk-blocks block chunk size for getLogs (default: 10000) + * --start-block (optional) override checkpoint; start scan from this block + * + * Env vars (use VAR_ or VAR for single chain): + * RPC_URL (required) + * STABLESWAPNG_FACTORY (optional, default mainnet Curve StableSwap-NG factory) + * FINALITY_BLOCKS (optional, default 10) subtract from latest; checkpoint = last scanned block + * LOOKBACK_BLOCKS (optional, default 200000) used when checkpoint missing and no --start-block + * RPS (optional, default 80) max RPC requests per second + * CONCURRENCY (optional, default 8) max concurrent RPC calls + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { ethers } from 'ethers'; +import { + parseArgs, + getEnvForChain, + toInt, + resolveOutputPath, + resolveCheckpointPath, + withRetry, +} from '@src/cli'; +import { pRateLimit, pLimit, safeReadJson, atomicWriteFile } from '@src/utils'; + +const OUTPUT_FILE = 'stableswapng-pools.json'; +const CHECKPOINT_FILE = 'stableswapng_checkpoint.json'; +const DEFAULT_FACTORY = '0x6A8cbed756804B16E05E741eDaBd5cB544AE21bf'; + +type Checkpoint = { + chainId: number; + factory: string; + lastProcessedBlock: number; + lastKnownPoolCount: number; + updatedAt: string; +}; + +/** Same shape as createPools.ts StableSwapPoolConfig for stableswapng */ +type CreatePoolsStableSwapConfig = { + poolType: 'stableswapng'; + curvePool: string; + tokens: string[]; + fee: number | null; + tickSpacing: number | null; + sqrtPriceX96: string | null; +}; + +const FACTORY_ABI = [ + 'function pool_count() view returns (uint256)', + 'function pool_list(uint256) view returns (address)', + 'function get_n_coins(address) view returns (uint256)', + 'function get_coins(address) view returns (address[])', + 'function get_base_pool(address) view returns (address)', + 'event PlainPoolDeployed(address[] coins, uint256 A, uint256 fee, address deployer)', + 'event MetaPoolDeployed(address coin, address base_pool, uint256 A, uint256 fee, address deployer)', +]; + +function loadExistingPoolAddrs(outFile: string): Set { + const keys = new Set(); + if (!fs.existsSync(outFile)) return keys; + try { + const arr = safeReadJson(outFile); + if (!Array.isArray(arr)) return keys; + for (const x of arr) keys.add(x.curvePool.toLowerCase()); + } catch { + // ignore + } + return keys; +} + +function uniqAddresses(addrs: string[]): string[] { + const s = new Set(); + for (const a of addrs) { + if (!a) continue; + const norm = a.toLowerCase(); + if (norm === ethers.ZeroAddress.toLowerCase()) continue; + s.add(ethers.getAddress(a)); + } + return [...s]; +} + +function getEnvInt(name: string, chainId: number, def: number): number { + const v = getEnvForChain(name, chainId); + if (!v) return def; + const n = Number(v); + return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : def; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + const factoryRaw = + getEnvForChain('STABLESWAPNG_FACTORY', chainId) ?? DEFAULT_FACTORY; + + if (!rpcUrl) { + throw new Error('Missing required env: RPC_URL (or RPC_URL_)'); + } + + const factory = ethers.getAddress(factoryRaw); + + const outputDir = (args['output-dir'] as string) ?? 'detected'; + const checkpointDir = (args['checkpoint-dir'] as string) ?? 'checkpoints'; + const chunkBlocks = Math.max(1, toInt(args['chunk-blocks'], 10000)); + + const finality = getEnvInt('FINALITY_BLOCKS', chainId, 10); + const lookbackBlocks = getEnvInt('LOOKBACK_BLOCKS', chainId, 200000); + const startBlockArg = args['start-block']; + + const concurrency = Math.max( + 1, + toInt(getEnvForChain('CONCURRENCY', chainId), 8), + ); + const rps = toInt(getEnvForChain('RPS', chainId), 80); + const rateLimit = pRateLimit(rps); + const limit = pLimit(concurrency); + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const contract = new ethers.Contract(factory, FACTORY_ABI, provider); + + const iface = new ethers.Interface(FACTORY_ABI as unknown as string[]); + const plainTopic = iface.getEvent('PlainPoolDeployed')!.topicHash; + const metaTopic = iface.getEvent('MetaPoolDeployed')!.topicHash; + + const latestBlock = await withRetry(() => provider.getBlockNumber()); + const toBlock = Math.max(0, latestBlock - finality); + + const cpPath = resolveCheckpointPath(checkpointDir, chainId, CHECKPOINT_FILE); + const cp = safeReadJson(cpPath); + + let fromBlock: number; + if (startBlockArg != null) { + fromBlock = Math.max(0, toInt(startBlockArg, 0)); + } else if ( + cp?.chainId === chainId && + cp?.factory?.toLowerCase() === factory.toLowerCase() + ) { + fromBlock = cp.lastProcessedBlock + 1; + } else { + fromBlock = Math.max(0, toBlock - lookbackBlocks); + } + + const poolCountBn = await contract.pool_count(); + const poolCount = Number(poolCountBn); + if (!Number.isFinite(poolCount) || poolCount < 0) { + throw new Error(`Unexpected pool_count: ${poolCountBn}`); + } + + const lastKnownPoolCount = + cp?.chainId === chainId && + cp?.factory?.toLowerCase() === factory.toLowerCase() + ? (cp.lastKnownPoolCount ?? 0) + : 0; + + if (fromBlock > toBlock && poolCount <= lastKnownPoolCount) { + const newCp: Checkpoint = { + chainId, + factory, + lastProcessedBlock: toBlock, + lastKnownPoolCount: poolCount, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(newCp, null, 2) + '\n'); + console.log( + JSON.stringify( + { + ok: true, + note: 'No new blocks or pools to process', + fromBlock, + toBlock, + poolCount, + }, + null, + 2, + ), + ); + return; + } + + let eventCount = 0; + if (fromBlock <= toBlock) { + for (let start = fromBlock; start <= toBlock; start += chunkBlocks) { + const end = Math.min(toBlock, start + chunkBlocks - 1); + + const logs = await withRetry(() => + provider.getLogs({ + address: factory, + fromBlock: start, + toBlock: end, + topics: [[plainTopic, metaTopic]], + }), + ); + + eventCount += logs.length; + console.error( + `[scan] ${start}..${end} events=${logs.length} total=${eventCount}`, + ); + } + } + + const startIndex = Math.max(0, lastKnownPoolCount); + const fetchCount = poolCount - startIndex; + + if (fetchCount <= 0) { + const newCp: Checkpoint = { + chainId, + factory, + lastProcessedBlock: Math.max(toBlock, cp?.lastProcessedBlock ?? 0), + lastKnownPoolCount: poolCount, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(newCp, null, 2) + '\n'); + console.log( + JSON.stringify( + { + ok: true, + chainId, + factory, + eventCount, + newPools: 0, + outFile: resolveOutputPath(outputDir, chainId, OUTPUT_FILE), + checkpointFile: cpPath, + }, + null, + 2, + ), + ); + return; + } + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + const seenAddrs = loadExistingPoolAddrs(outPath); + let allRecords = safeReadJson(outPath) ?? []; + + const newIndices = Array.from( + { length: fetchCount }, + (_, k) => startIndex + k, + ); + const fetched = await Promise.all( + newIndices.map((i) => + limit(async () => { + await rateLimit(); + const addr = await withRetry( + () => contract.pool_list(i) as Promise, + ); + const curvePool = ethers.getAddress(addr); + if (seenAddrs.has(curvePool.toLowerCase())) return null; + + await rateLimit(); + const [nCoinsBn, coinsRaw, basePoolRaw] = await Promise.all([ + withRetry(() => contract.get_n_coins(curvePool) as Promise), + withRetry(() => contract.get_coins(curvePool) as Promise), + withRetry(() => contract.get_base_pool(curvePool) as Promise), + ]); + const basePool = ethers.getAddress(basePoolRaw); + const isPlain = + basePool.toLowerCase() === ethers.ZeroAddress.toLowerCase(); + if (!isPlain) return null; + const coins = uniqAddresses(coinsRaw as string[]); + return { + poolType: 'stableswapng' as const, + curvePool, + tokens: coins, + fee: null, + tickSpacing: null, + sqrtPriceX96: null, + }; + }), + ), + ); + + const newRecords = fetched.filter(Boolean) as CreatePoolsStableSwapConfig[]; + allRecords = allRecords.concat(newRecords); + const newCount = newRecords.length; + + if (newCount > 0) { + atomicWriteFile(outPath, JSON.stringify(allRecords, null, 2) + '\n'); + } + + const newCp: Checkpoint = { + chainId, + factory, + lastProcessedBlock: Math.max(toBlock, cp?.lastProcessedBlock ?? 0), + lastKnownPoolCount: poolCount, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(newCp, null, 2) + '\n'); + + console.log( + JSON.stringify( + { + ok: true, + chainId, + factory, + scanned: { fromBlock, toBlock, latestBlock, finality, chunkBlocks }, + eventCount, + poolsFetched: fetchCount, + newPools: newCount, + outFile: outPath, + checkpointFile: cpPath, + }, + null, + 2, + ), + ); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/aggregator-hooks/polling/UniswapV2.ts b/aggregator-hooks/polling/UniswapV2.ts new file mode 100644 index 00000000..d9267bd4 --- /dev/null +++ b/aggregator-hooks/polling/UniswapV2.ts @@ -0,0 +1,236 @@ +/** + * Uniswap V2 pool discovery (checkpoint-based polling). + * Scans new PairCreated events from the V2 factory since the last checkpoint + * and appends new pools to the output file. + * + * Usage: + * npx tsx polling/UniswapV2.ts --chain-id 1 + * + * Options: + * --chain-id (required) Chain ID + * --output-dir output directory (default: detected) + * --checkpoint-dir checkpoint directory (default: checkpoints) + * --chunk-blocks block chunk size (default: 10000) + * --start-block (optional) override checkpoint start block + * + * Env vars (VAR_ or VAR): + * RPC_URL (required) + * UNISWAP_V2_FACTORY (optional, default mainnet factory) + * FINALITY_BLOCKS (optional, default 10) + * LOOKBACK_BLOCKS (optional, default 200000) + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { ethers } from 'ethers'; +import { + parseArgs, + getEnvForChain, + toInt, + resolveOutputPath, + resolveCheckpointPath, +} from '@src/cli'; +import { safeReadJson, atomicWriteFile } from '@src/utils'; + +const OUTPUT_FILE = 'uniswapv2-pools.json'; +const CHECKPOINT_FILE = 'uniswapv2_checkpoint.json'; +const DEFAULT_FACTORY = '0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +const FACTORY_ABI = [ + 'event PairCreated(address indexed token0, address indexed token1, address pair, uint)', +]; + +type Checkpoint = { + chainId: number; + factory: string; + lastProcessedBlock: number; + updatedAt: string; +}; + +type UniswapV2PoolConfig = { + poolType: 'uniswapv2'; + v2Pair: string; + currency0: string; + currency1: string; + tickSpacing: number; + sqrtPriceX96: null; +}; + +function isNative(addr: string): boolean { + return addr.toLowerCase() === ZERO_ADDRESS; +} + +function loadExistingKeys(outFile: string): Set { + const keys = new Set(); + if (!fs.existsSync(outFile)) return keys; + try { + const arr = safeReadJson(outFile); + if (!Array.isArray(arr)) return keys; + for (const x of arr) keys.add(x.v2Pair.toLowerCase()); + } catch { + /* ignore */ + } + return keys; +} + +function getEnvInt(name: string, chainId: number, def: number): number { + const v = getEnvForChain(name, chainId); + if (!v) return def; + const n = Number(v); + return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : def; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + if (!rpcUrl) throw new Error('Missing required env: RPC_URL'); + + const factoryRaw = + getEnvForChain('UNISWAP_V2_FACTORY', chainId) ?? DEFAULT_FACTORY; + const factory = ethers.getAddress(factoryRaw); + + const outputDir = (args['output-dir'] as string) ?? 'detected'; + const checkpointDir = (args['checkpoint-dir'] as string) ?? 'checkpoints'; + const chunkBlocks = Math.max(1, toInt(args['chunk-blocks'], 10_000)); + const finality = getEnvInt('FINALITY_BLOCKS', chainId, 10); + const lookbackBlocks = getEnvInt('LOOKBACK_BLOCKS', chainId, 200_000); + const startBlockArg = args['start-block']; + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const iface = new ethers.Interface(FACTORY_ABI); + const topic0 = iface.getEvent('PairCreated')!.topicHash; + + const latestBlock = await provider.getBlockNumber(); + const toBlock = Math.max(0, latestBlock - finality); + + const cpPath = resolveCheckpointPath(checkpointDir, chainId, CHECKPOINT_FILE); + const cp = safeReadJson(cpPath); + + let fromBlock: number; + if (startBlockArg != null) { + fromBlock = Math.max(0, toInt(startBlockArg, 0)); + } else if ( + cp?.chainId === chainId && + cp?.factory?.toLowerCase() === factory.toLowerCase() + ) { + fromBlock = cp.lastProcessedBlock + 1; + } else { + fromBlock = Math.max(0, toBlock - lookbackBlocks); + } + + if (fromBlock > toBlock) { + const newCp: Checkpoint = { + chainId, + factory, + lastProcessedBlock: toBlock, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(newCp, null, 2) + '\n'); + console.log( + JSON.stringify( + { ok: true, note: 'No new blocks to scan', fromBlock, toBlock }, + null, + 2, + ), + ); + return; + } + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + const seenKeys = loadExistingKeys(outPath); + let allRecords = safeReadJson(outPath) ?? []; + let totalLogs = 0; + let newPools = 0; + + for (let start = fromBlock; start <= toBlock; start += chunkBlocks) { + const end = Math.min(toBlock, start + chunkBlocks - 1); + + const logs = await provider.getLogs({ + address: factory, + topics: [topic0], + fromBlock: start, + toBlock: end, + }); + totalLogs += logs.length; + const newRecords: UniswapV2PoolConfig[] = []; + + for (const log of logs) { + let parsed: ethers.LogDescription | null; + try { + parsed = iface.parseLog(log); + } catch { + continue; + } + if (!parsed) continue; + + const token0 = ethers.getAddress(parsed.args.token0 as string); + const token1 = ethers.getAddress(parsed.args.token1 as string); + const pair = ethers.getAddress(parsed.args.pair as string); + + if (isNative(token0) || isNative(token1)) continue; + if (seenKeys.has(pair.toLowerCase())) continue; + seenKeys.add(pair.toLowerCase()); + + newPools++; + newRecords.push({ + poolType: 'uniswapv2', + v2Pair: pair, + currency0: token0, + currency1: token1, + tickSpacing: 1, + sqrtPriceX96: null, + }); + } + + if (newRecords.length > 0) { + allRecords = allRecords.concat(newRecords); + atomicWriteFile(outPath, JSON.stringify(allRecords, null, 2) + '\n'); + } + + const interimCp: Checkpoint = { + chainId, + factory, + lastProcessedBlock: end, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(interimCp, null, 2) + '\n'); + console.error( + `[scan] ${start}..${end} logs=${logs.length} new=${newRecords.length}`, + ); + } + + console.log( + JSON.stringify( + { + ok: true, + chainId, + factory, + scanned: { fromBlock, toBlock, latestBlock, finality, chunkBlocks }, + logsFound: totalLogs, + newPools, + outFile: outPath, + checkpointFile: cpPath, + }, + null, + 2, + ), + ); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/aggregator-hooks/polling/UniswapV3.ts b/aggregator-hooks/polling/UniswapV3.ts new file mode 100644 index 00000000..d0385d35 --- /dev/null +++ b/aggregator-hooks/polling/UniswapV3.ts @@ -0,0 +1,240 @@ +/** + * Uniswap V3 pool discovery (checkpoint-based polling). + * Scans new PoolCreated events from the V3 factory since the last checkpoint + * and appends new pools to the output file. + * + * Usage: + * npx tsx polling/UniswapV3.ts --chain-id 1 + * + * Options: + * --chain-id (required) Chain ID + * --output-dir output directory (default: detected) + * --checkpoint-dir checkpoint directory (default: checkpoints) + * --chunk-blocks block chunk size (default: 10000) + * --start-block (optional) override checkpoint start block + * + * Env vars (VAR_ or VAR): + * RPC_URL (required) + * UNISWAP_V3_FACTORY (optional, default mainnet factory) + * FINALITY_BLOCKS (optional, default 10) + * LOOKBACK_BLOCKS (optional, default 200000) + */ + +import 'dotenv/config'; +import fs from 'node:fs'; +import { ethers } from 'ethers'; +import { + parseArgs, + getEnvForChain, + toInt, + resolveOutputPath, + resolveCheckpointPath, +} from '@src/cli'; +import { safeReadJson, atomicWriteFile } from '@src/utils'; + +const OUTPUT_FILE = 'uniswapv3-pools.json'; +const CHECKPOINT_FILE = 'uniswapv3_checkpoint.json'; +const DEFAULT_FACTORY = '0x1F98431c8aD98523631AE4a59f267346ea31F984'; +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +const FACTORY_ABI = [ + 'event PoolCreated(address indexed token0, address indexed token1, uint24 indexed fee, int24 tickSpacing, address pool)', +]; + +type Checkpoint = { + chainId: number; + factory: string; + lastProcessedBlock: number; + updatedAt: string; +}; + +type UniswapV3PoolConfig = { + poolType: 'uniswapv3'; + v3Pool: string; + currency0: string; + currency1: string; + fee: number; + tickSpacing: number; + sqrtPriceX96: null; +}; + +function isNative(addr: string): boolean { + return addr.toLowerCase() === ZERO_ADDRESS; +} + +function loadExistingKeys(outFile: string): Set { + const keys = new Set(); + if (!fs.existsSync(outFile)) return keys; + try { + const arr = safeReadJson(outFile); + if (!Array.isArray(arr)) return keys; + for (const x of arr) keys.add(x.v3Pool.toLowerCase()); + } catch { + /* ignore */ + } + return keys; +} + +function getEnvInt(name: string, chainId: number, def: number): number { + const v = getEnvForChain(name, chainId); + if (!v) return def; + const n = Number(v); + return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : def; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const chainIdRaw = args['chain-id']; + if (chainIdRaw == null) { + console.error('Missing required --chain-id '); + process.exit(1); + } + const chainId = toInt(chainIdRaw, 0); + if (chainId <= 0) { + console.error('--chain-id must be a positive integer'); + process.exit(1); + } + + const rpcUrl = getEnvForChain('RPC_URL', chainId); + if (!rpcUrl) throw new Error('Missing required env: RPC_URL'); + + const factoryRaw = + getEnvForChain('UNISWAP_V3_FACTORY', chainId) ?? DEFAULT_FACTORY; + const factory = ethers.getAddress(factoryRaw); + + const outputDir = (args['output-dir'] as string) ?? 'detected'; + const checkpointDir = (args['checkpoint-dir'] as string) ?? 'checkpoints'; + const chunkBlocks = Math.max(1, toInt(args['chunk-blocks'], 10_000)); + const finality = getEnvInt('FINALITY_BLOCKS', chainId, 10); + const lookbackBlocks = getEnvInt('LOOKBACK_BLOCKS', chainId, 200_000); + const startBlockArg = args['start-block']; + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const iface = new ethers.Interface(FACTORY_ABI); + const topic0 = iface.getEvent('PoolCreated')!.topicHash; + + const latestBlock = await provider.getBlockNumber(); + const toBlock = Math.max(0, latestBlock - finality); + + const cpPath = resolveCheckpointPath(checkpointDir, chainId, CHECKPOINT_FILE); + const cp = safeReadJson(cpPath); + + let fromBlock: number; + if (startBlockArg != null) { + fromBlock = Math.max(0, toInt(startBlockArg, 0)); + } else if ( + cp?.chainId === chainId && + cp?.factory?.toLowerCase() === factory.toLowerCase() + ) { + fromBlock = cp.lastProcessedBlock + 1; + } else { + fromBlock = Math.max(0, toBlock - lookbackBlocks); + } + + if (fromBlock > toBlock) { + const newCp: Checkpoint = { + chainId, + factory, + lastProcessedBlock: toBlock, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(newCp, null, 2) + '\n'); + console.log( + JSON.stringify( + { ok: true, note: 'No new blocks to scan', fromBlock, toBlock }, + null, + 2, + ), + ); + return; + } + + const outPath = resolveOutputPath(outputDir, chainId, OUTPUT_FILE); + const seenKeys = loadExistingKeys(outPath); + let allRecords = safeReadJson(outPath) ?? []; + let totalLogs = 0; + let newPools = 0; + + for (let start = fromBlock; start <= toBlock; start += chunkBlocks) { + const end = Math.min(toBlock, start + chunkBlocks - 1); + + const logs = await provider.getLogs({ + address: factory, + topics: [topic0], + fromBlock: start, + toBlock: end, + }); + totalLogs += logs.length; + const newRecords: UniswapV3PoolConfig[] = []; + + for (const log of logs) { + let parsed: ethers.LogDescription | null; + try { + parsed = iface.parseLog(log); + } catch { + continue; + } + if (!parsed) continue; + + const token0 = ethers.getAddress(parsed.args.token0 as string); + const token1 = ethers.getAddress(parsed.args.token1 as string); + const pool = ethers.getAddress(parsed.args.pool as string); + const fee = Number(parsed.args.fee); + const tickSpacing = Number(parsed.args.tickSpacing); + + if (isNative(token0) || isNative(token1)) continue; + if (seenKeys.has(pool.toLowerCase())) continue; + seenKeys.add(pool.toLowerCase()); + + newPools++; + newRecords.push({ + poolType: 'uniswapv3', + v3Pool: pool, + currency0: token0, + currency1: token1, + fee, + tickSpacing, + sqrtPriceX96: null, + }); + } + + if (newRecords.length > 0) { + allRecords = allRecords.concat(newRecords); + atomicWriteFile(outPath, JSON.stringify(allRecords, null, 2) + '\n'); + } + + const interimCp: Checkpoint = { + chainId, + factory, + lastProcessedBlock: end, + updatedAt: new Date().toISOString(), + }; + atomicWriteFile(cpPath, JSON.stringify(interimCp, null, 2) + '\n'); + console.error( + `[scan] ${start}..${end} logs=${logs.length} new=${newRecords.length}`, + ); + } + + console.log( + JSON.stringify( + { + ok: true, + chainId, + factory, + scanned: { fromBlock, toBlock, latestBlock, finality, chunkBlocks }, + logsFound: totalLogs, + newPools, + outFile: outPath, + checkpointFile: cpPath, + }, + null, + 2, + ), + ); +} + +main().catch((err) => { + console.error(err); + process.exitCode = 1; +}); diff --git a/aggregator-hooks/src/cli.ts b/aggregator-hooks/src/cli.ts new file mode 100644 index 00000000..beb04fa5 --- /dev/null +++ b/aggregator-hooks/src/cli.ts @@ -0,0 +1,110 @@ +/** + * Shared CLI and env utilities for discovery scripts. + * All scripts use chain-ID-suffixed env vars and consistent CLI args. + */ +import path from 'node:path'; + +/** Parse generic --key value args. Keys with no value get true. */ +export function parseArgs(argv: string[]): Record { + const out: Record = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (!a.startsWith('--')) continue; + const key = a.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith('--')) { + out[key] = true; + } else { + out[key] = next; + i++; + } + } + return out; +} + +/** Get env var: VAR_${chainId} first, fallback to VAR for single-chain usage. */ +export function getEnvForChain( + name: string, + chainId: number, +): string | undefined { + const v = process.env[`${name}_${chainId}`]; + if (v != null && String(v).trim()) return String(v).trim(); + const fallback = process.env[name]; + if (fallback != null && String(fallback).trim()) + return String(fallback).trim(); + return undefined; +} + +/** Require env var; throws if missing. */ +export function mustEnvForChain(name: string, chainId: number): string { + const v = getEnvForChain(name, chainId); + if (!v) + throw new Error(`Missing required env var: ${name}_${chainId} or ${name}`); + return v; +} + +/** Parse int from string or number; return default if invalid. */ +export function toInt(v: unknown, def: number): number { + if (typeof v === 'number') return Number.isFinite(v) ? Math.floor(v) : def; + if (typeof v === 'string') { + const n = Number(v); + return Number.isFinite(n) ? Math.floor(n) : def; + } + return def; +} + +/** Resolve output path: outputDir/chainId/filename */ +export function resolveOutputPath( + outputDir: string, + chainId: number, + filename: string, +): string { + return path.resolve(outputDir, String(chainId), filename); +} + +/** Resolve checkpoint path: checkpointDir/chainId/filename */ +export function resolveCheckpointPath( + checkpointDir: string, + chainId: number, + filename: string, +): string { + return path.resolve(checkpointDir, String(chainId), filename); +} + +/** Common args for all scripts */ +export interface CommonArgs { + chainId: number; + outputDir: string; + chunkBlocks: number; +} + +/** Common args for polling scripts (add checkpoint dir) */ +export interface PollingArgs extends CommonArgs { + checkpointDir: string; +} + +/** Common args for historical scripts */ +export interface HistoricalArgs extends CommonArgs { + startBlock: number; + endBlock: number | null; +} + +/** + * Retry wrapper with exponential backoff for transient RPC errors. + * Retries on any thrown error; callers should not use this for on-chain reverts. + */ +export async function withRetry( + fn: () => Promise, + retries = 5, + baseMs = 500, +): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await fn(); + } catch (e) { + if (attempt >= retries) throw e; + const delay = baseMs * 2 ** attempt + Math.random() * 100; + await new Promise((r) => setTimeout(r, delay)); + } + } +} diff --git a/aggregator-hooks/src/createPools.ts b/aggregator-hooks/src/createPools.ts new file mode 100644 index 00000000..0eedd88f --- /dev/null +++ b/aggregator-hooks/src/createPools.ts @@ -0,0 +1,1475 @@ +#!/usr/bin/env node + +import 'dotenv/config'; +import { ethers } from 'ethers'; +import { + readFileSync, + writeFileSync, + renameSync, + existsSync, + mkdirSync, +} from 'fs'; +import { execFileSync, spawn } from 'child_process'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +import { getEnvForChain, toInt } from './cli.js'; +import { createLogger, type Logger } from './logger.js'; +import { + CREATION_MODULES, + POOL_TYPES, + type Address, + type PoolConfig, + type PoolDeployedEntry, + type PoolEntry, + type PoolKeyRecord, + type FactoryImmutables, +} from '../creation-modules/index.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const projectRoot = join(__dirname, '..', '..'); + +/** Compute PoolId = keccak256(abi.encode(poolKey)) matching Uniswap v4 PoolIdLibrary.toId() */ +function computePoolId(poolKey: PoolKeyRecord): string { + const encoded = ethers.AbiCoder.defaultAbiCoder().encode( + ['address', 'address', 'uint24', 'int24', 'address'], + [ + poolKey.currency0, + poolKey.currency1, + poolKey.fee, + poolKey.tickSpacing, + poolKey.hooks, + ], + ); + return ethers.keccak256(encoded); +} + +// Foundry's default CREATE2 deployer +const CREATE2_DEPLOYER = '0x4e59b44847b379578588920cA78FbF26c0B4956C'; + +interface VerifyOptions { + enabled: boolean; + etherscanApiKey: string | null; + verifierUrl: string | null; + verifier: string | null; + compilerVersion: string | null; +} + +interface ParsedArgs { + jsonFile: string; + factoryAddress: Address | null; + selfDeploy: boolean; + rpcUrl: string; + chainId: number | null; + registryDir: string; + dryRun: boolean; + verbose: boolean; + startAt: number; + jobs: number; + priorityGasPrice: string | null; + verify: VerifyOptions; +} + +function isPoolType(s: unknown): s is string { + return typeof s === 'string' && POOL_TYPES.includes(s); +} + +function parseArgs(): ParsedArgs { + const args = process.argv.slice(2); + const selfDeployIndex = args.indexOf('--self-deploy'); + const selfDeploy = selfDeployIndex !== -1; + + const flagNames = [ + '--self-deploy', + '--chain-id', + '--registry-dir', + '--dry-run', + '--verbose', + '-v', + '--start-at', + '--jobs', + '-j', + '--priority-gas-price', + '--verify', + '--verifier-url', + '--verifier', + '--compiler-version', + ]; + const positionalArgs: string[] = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (flagNames.includes(a)) { + if ( + a === '--chain-id' || + a === '--registry-dir' || + a === '--start-at' || + a === '--jobs' || + a === '-j' || + a === '--priority-gas-price' || + a === '--verifier-url' || + a === '--verifier' || + a === '--compiler-version' + ) + i++; + continue; + } + positionalArgs.push(a); + } + + const minArgs = 1; + if (positionalArgs.length < minArgs) { + console.error( + 'Usage: ts-node createPools.ts [factoryAddress] [--self-deploy] [--chain-id ]', + ); + console.error( + " jsonFile: Path to JSON file containing pool configurations (each config must have a 'poolType' field)", + ); + console.error( + ' factoryAddress: Factory contract address (required unless --self-deploy)', + ); + console.error( + ' --self-deploy: Deploy hooks directly from wallet instead of via factory', + ); + console.error( + ' --chain-id : Chain ID; selects RPC_URL_ from env (e.g. RPC_URL_1 for mainnet)', + ); + console.error(''); + console.error('Modes:'); + console.error( + ' Factory mode: ts-node createPools.ts pools.json 0xFactoryAddr [--chain-id 1] [--registry-dir ./deployed-pools]', + ); + console.error( + ' Self-deploy: ts-node createPools.ts pools.json --self-deploy [--chain-id 1] [--registry-dir ./deployed-pools]', + ); + console.error( + ' --dry-run: Simulate without broadcasting (self-deploy: forge script without --broadcast; factory: staticCall only)', + ); + console.error( + ' --verbose, -v: Run forge scripts verbosely and log full output on errors', + ); + console.error( + ' --start-at : Start at 1-based pool index (skip earlier pools). e.g. --start-at 3 to resume from pool 3.', + ); + console.error( + ' --jobs , -j : Run N parallel salt mining workers (default 1). Speeds up mining.', + ); + console.error( + ' --priority-gas-price : Max priority fee per gas for EIP1559 (e.g. 3gwei). Speeds up tx inclusion.', + ); + console.error( + ' --verify: Submit hook contract for block explorer verification after deployment', + ); + console.error( + ' --verifier : Verifier backend (etherscan|blockscout|sourcify). Default: etherscan', + ); + console.error( + ' --verifier-url : Custom verifier API URL (e.g. for blockscout)', + ); + console.error( + ' --compiler-version : Solc version used to compile the hook (e.g. 0.8.24). Required when the factory', + ); + console.error( + ' was deployed with a different solc than the current local environment.', + ); + console.error(''); + console.error('Environment variables:'); + console.error( + ' RPC_URL_: RPC endpoint (required when --chain-id set)', + ); + console.error( + ' PRIVATE_KEY: Private key for signing transactions (required)', + ); + console.error( + ' ETHERSCAN_API_KEY or ETHERSCAN_API_KEY_: API key for block explorer verification', + ); + process.exit(1); + } + + const jsonFile = positionalArgs[0]; + const factoryAddress: Address | null = selfDeploy + ? null + : positionalArgs[1] + ? (ethers.getAddress(positionalArgs[1]) as Address) + : null; + + if ( + selfDeploy && + positionalArgs.length >= 2 && + positionalArgs[1].startsWith('0x') + ) { + console.error( + 'Error: --self-deploy and factoryAddress are mutually exclusive', + ); + process.exit(1); + } + + if (!selfDeploy && (!factoryAddress || positionalArgs.length < 2)) { + console.error( + 'Error: In factory mode, factoryAddress is required (e.g. createPools.ts pools.json 0xFactoryAddr)', + ); + process.exit(1); + } + + const chainIdIndex = args.indexOf('--chain-id'); + const chainIdRaw = + chainIdIndex !== -1 && args[chainIdIndex + 1] + ? args[chainIdIndex + 1] + : null; + const chainId = chainIdRaw != null ? toInt(String(chainIdRaw), 0) : null; + + const rpcUrl = + chainId != null && chainId > 0 + ? getEnvForChain('RPC_URL', chainId) + : (process.env.RPC_URL ?? '').trim() || undefined; + if (!rpcUrl) { + console.error( + chainId != null && chainId > 0 + ? `Error: RPC_URL_${chainId} environment variable is required when using --chain-id ${chainId}` + : 'Error: RPC_URL environment variable is required', + ); + process.exit(1); + } + + if (!process.env.PRIVATE_KEY) { + console.error('Error: PRIVATE_KEY environment variable is required'); + process.exit(1); + } + + const registryDirIndex = args.indexOf('--registry-dir'); + const registryDir = + registryDirIndex !== -1 && args[registryDirIndex + 1] + ? args[registryDirIndex + 1] + : 'created-pools'; + + const dryRun = args.includes('--dry-run'); + const verbose = args.includes('--verbose') || args.includes('-v'); + + const startAtIndex = args.indexOf('--start-at'); + const startAtRaw = + startAtIndex !== -1 && args[startAtIndex + 1] + ? args[startAtIndex + 1] + : null; + const startAt = startAtRaw != null ? toInt(String(startAtRaw), 1) : 1; + if (startAt < 1) { + console.error('Error: --start-at must be >= 1'); + process.exit(1); + } + + const jobsIndex = args.indexOf('--jobs'); + const jobsIndexShort = args.indexOf('-j'); + const jobsIdx = jobsIndex !== -1 ? jobsIndex : jobsIndexShort; + const jobsRaw = + jobsIdx !== -1 && args[jobsIdx + 1] ? args[jobsIdx + 1] : null; + const jobs = jobsRaw != null ? toInt(String(jobsRaw), 1) : 1; + if (jobs < 1 || jobs > 16) { + console.error('Error: --jobs must be between 1 and 16'); + process.exit(1); + } + + const priorityGasPriceIndex = args.indexOf('--priority-gas-price'); + const priorityGasPriceRaw = + priorityGasPriceIndex !== -1 && args[priorityGasPriceIndex + 1] + ? args[priorityGasPriceIndex + 1] + : null; + const priorityGasPrice = priorityGasPriceRaw?.trim() || null; + + const verifyEnabled = args.includes('--verify'); + const verifierIndex = args.indexOf('--verifier'); + const verifier = + verifierIndex !== -1 && args[verifierIndex + 1] + ? args[verifierIndex + 1] + : null; + const verifierUrlIndex = args.indexOf('--verifier-url'); + const verifierUrl = + verifierUrlIndex !== -1 && args[verifierUrlIndex + 1] + ? args[verifierUrlIndex + 1] + : null; + const compilerVersionIndex = args.indexOf('--compiler-version'); + const compilerVersion = + compilerVersionIndex !== -1 && args[compilerVersionIndex + 1] + ? args[compilerVersionIndex + 1] + : null; + + return { + jsonFile, + factoryAddress, + selfDeploy, + rpcUrl, + chainId, + registryDir, + dryRun, + verbose, + startAt, + jobs, + priorityGasPrice, + verify: { + enabled: verifyEnabled, + etherscanApiKey: null, // resolved later from env once chainId is known + verifierUrl, + verifier, + compilerVersion, + }, + }; +} + +function appendToRegistryFile( + registryDir: string, + poolType: string, + entry: PoolDeployedEntry, + log: Logger, +): void { + const fileName = `deployed-${poolType}.json`; + const filePath = join(registryDir, fileName); + + let poolsDeployed: PoolDeployedEntry[]; + if (existsSync(filePath)) { + const content = readFileSync(filePath, 'utf-8'); + poolsDeployed = JSON.parse(content) as PoolDeployedEntry[]; + } else { + poolsDeployed = []; + } + + poolsDeployed.push(entry); + + if (!existsSync(registryDir)) { + mkdirSync(registryDir, { recursive: true }); + } + const tmp = filePath + '.tmp'; + writeFileSync(tmp, JSON.stringify(poolsDeployed, null, 2)); + renameSync(tmp, filePath); + log.info(`Appended to registry: ${filePath}`); +} + +/** + * Upsert a KEY=VALUE line in a .env file. Creates the file if it does not + * exist. Replaces the line if KEY is already present. + */ +function appendToEnvFile(filePath: string, key: string, value: string): void { + let lines: string[] = []; + if (existsSync(filePath)) { + lines = readFileSync(filePath, 'utf-8').split('\n'); + } + const prefix = `${key}=`; + const idx = lines.findIndex((l) => l.startsWith(prefix)); + const newLine = `${key}=${value}`; + if (idx !== -1) { + lines[idx] = newLine; + } else { + lines.push(newLine); + } + // Ensure file ends with a newline + if (lines[lines.length - 1] !== '') lines.push(''); + writeFileSync(filePath, lines.join('\n')); +} + +const POOL_MANAGER_INIT_ABI = [ + 'function initialize((address currency0, address currency1, uint24 fee, int24 tickSpacing, address hooks) key, uint160 sqrtPriceX96) returns (int24 tick)', +]; + +async function initializeSingletonPool( + signer: ethers.Signer, + poolManagerAddress: Address, + poolKey: PoolKeyRecord, + sqrtPriceX96: bigint, + log: Logger, + dryRun: boolean, +): Promise<{ blockNumber: number; txHash: string } | null> { + const poolManager = new ethers.Contract( + poolManagerAddress, + POOL_MANAGER_INIT_ABI, + signer, + ); + + log.info( + `Calling poolManager.initialize for pool: ${poolKey.currency0} / ${poolKey.currency1}`, + ); + if (dryRun) log.info('(dry run - no broadcast)'); + + try { + if (dryRun) { + await poolManager.initialize.staticCall(poolKey, sqrtPriceX96); + log.success(`Dry run: initialize would succeed`); + return null; + } + + const tx = await poolManager.initialize(poolKey, sqrtPriceX96); + log.success(`Transaction sent: ${tx.hash}`); + const receipt = await tx.wait(); + log.success(`Pool initialized in block ${receipt!.blockNumber}`); + return { blockNumber: Number(receipt!.blockNumber), txHash: tx.hash }; + } catch (error) { + log.error('Error initializing pool:', error); + throw error; + } +} + +/** + * Read the solc version from a forge build artifact. + * The artifact lives at out/.sol/.json and + * contains a "metadata" object with compiler.version (e.g. "0.8.24+commit.e11b9ed9"). + * Returns null if the artifact is missing or unparseable. + */ +function readCompilerVersionFromArtifact( + contractIdentifier: string, +): string | null { + try { + // contractIdentifier: "path/to/Foo.sol:Foo" + const contractName = contractIdentifier.split(':')[1]; + const solFile = contractIdentifier.split(':')[0].split('/').pop()!; // "Foo.sol" + const artifactPath = join( + projectRoot, + 'out', + solFile, + `${contractName}.json`, + ); + if (!existsSync(artifactPath)) return null; + const artifact = JSON.parse(readFileSync(artifactPath, 'utf-8')) as { + metadata?: { compiler?: { version?: string } }; + }; + return artifact?.metadata?.compiler?.version ?? null; + } catch { + return null; + } +} + +function verifyContract( + hookAddress: Address, + contractIdentifier: string, + constructorArgs: string, + chainId: number, + verifyOptions: VerifyOptions, + log: Logger, +): void { + // Resolve verifier URL: explicit flag > BLOCKSCOUT_API_URL env > none + const resolvedVerifierUrl = + verifyOptions.verifierUrl ?? + getEnvForChain('BLOCKSCOUT_API_URL', chainId) ?? + null; + + // If a Blockscout URL is in play but no verifier was explicitly named, use blockscout + const isBlockscout = + verifyOptions.verifier === 'blockscout' || + (!verifyOptions.verifier && resolvedVerifierUrl != null); + const verifier = + verifyOptions.verifier ?? (isBlockscout ? 'blockscout' : 'etherscan'); + + // API key: explicit flag > ETHERSCAN_API_KEY env (blockscout public instances don't require one) + const apiKey = + verifyOptions.etherscanApiKey ?? + getEnvForChain('ETHERSCAN_API_KEY', chainId) ?? + null; + + // Compiler version: explicit flag > build artifact > let forge auto-detect + // In factory mode this matters: the factory embeds the hook bytecode at its own compile time, + // so the solc version used then must match what forge verify-contract uses now. + const compilerVersion = + verifyOptions.compilerVersion ?? + readCompilerVersionFromArtifact(contractIdentifier); + if (!compilerVersion) { + log.info( + ` Warning: compiler version not found in build artifacts. If the factory was compiled with a different` + + ` solc than is installed locally, verification may fail. Pass --compiler-version to fix this.`, + ); + } else { + log.info(` Compiler version: ${compilerVersion}`); + } + + log.info( + `Submitting ${contractIdentifier} at ${hookAddress} for verification (verifier: ${verifier})...`, + ); + + const forgeArgs = [ + 'verify-contract', + hookAddress, + contractIdentifier, + '--constructor-args', + constructorArgs, + '--chain-id', + chainId.toString(), + '--verifier', + verifier, + '--watch', + ]; + + if (apiKey) forgeArgs.push('--etherscan-api-key', apiKey); + if (resolvedVerifierUrl) + forgeArgs.push('--verifier-url', resolvedVerifierUrl); + if (compilerVersion) forgeArgs.push('--compiler-version', compilerVersion); + + try { + const output = execFileSync('forge', forgeArgs, { + encoding: 'utf-8', + cwd: projectRoot, + }); + log.success(`Verification submitted successfully for ${hookAddress}`); + if (output.trim()) + log.verbose(`\n--- forge verify-contract output ---\n${output}`); + } catch (error) { + const execErr = error as { + stdout?: string; + stderr?: string; + message?: string; + }; + log.error( + `Verification failed for ${hookAddress} (non-fatal): ${execErr.message ?? 'unknown error'}`, + error instanceof Error ? error : new Error(String(error)), + ); + if (execErr.stdout || execErr.stderr) { + log.dumpForgeOutput({ + stdout: execErr.stdout, + stderr: execErr.stderr, + label: 'forge verify-contract', + }); + } + } +} + +function parseSqrtPriceX96(v: unknown): bigint | null { + if (v == null) return null; + if (typeof v === 'bigint') return v; + if (typeof v === 'string') return BigInt(v); + if (typeof v === 'number') return BigInt(Math.floor(v)); + return null; +} + +function loadJsonFile(filePath: string, log: Logger): PoolConfig[] { + try { + const content = readFileSync(filePath, 'utf-8'); + const pools = JSON.parse(content); + + if (!Array.isArray(pools)) { + throw new Error('JSON file must contain an array of pool configurations'); + } + + if (pools.length === 0) { + throw new Error('JSON file must contain at least one pool configuration'); + } + + for (let i = 0; i < pools.length; i++) { + const p = pools[i]; + if (!isPoolType(p?.poolType)) { + throw new Error( + `Pool ${i + 1} missing or invalid 'poolType'. Must be one of: ${POOL_TYPES.join(', ')}`, + ); + } + } + + const firstType = pools[0].poolType as string; + for (let i = 1; i < pools.length; i++) { + if (pools[i].poolType !== firstType) { + throw new Error( + `Pool ${i + 1} has poolType "${ + pools[i].poolType + }" but all configs must have the same poolType (first is "${firstType}")`, + ); + } + } + + return pools.map((p: Record) => ({ + ...p, + sqrtPriceX96: parseSqrtPriceX96(p.sqrtPriceX96), + })) as PoolConfig[]; + } catch (error) { + log.error( + 'Error loading JSON file:', + error instanceof Error + ? error + : new Error('Unknown error loading JSON file'), + ); + process.exit(1); + } +} + +/** PoolManager Initialize event for verification */ +const INITIALIZE_TOPIC = + '0xdd466e674ea557f56295e2d0218a125ea4b4f0f6f3307b95f85e6110838d6438'; + +async function verifyDeploymentOnForgeFailure( + provider: ethers.Provider, + poolManagerAddress: Address, + poolConfig: PoolConfig, + poolType: string, + forgeOutput: string, +): Promise<{ + hookAddress: Address; + hookDeployed: boolean; + poolsInitialized: number; + blockNumber?: number; + poolEntriesFromEvent?: PoolEntry[]; +} | null> { + const module = CREATION_MODULES[poolType]; + if (!module) return null; + + const hookMatch = forgeOutput.match(/Hook Address:\s*(0x[a-fA-F0-9]{40})/); + if (!hookMatch) return null; + + const hookAddress = ethers.getAddress(hookMatch[1]) as Address; + const code = await provider.getCode(hookAddress); + const hookDeployed = !!code && code !== '0x' && code.length > 2; + + if (!hookDeployed) + return { hookAddress, hookDeployed: false, poolsInitialized: 0 }; + + const poolKeys = module.buildPoolKeys(poolConfig, hookAddress); + const poolEntriesFromEvent: PoolEntry[] = []; + let initializeBlockNumber: number | undefined; + + try { + const blockNumber = await provider.getBlockNumber(); + const fromBlock = Math.max(0, blockNumber - 500); + const logs = await provider.getLogs({ + address: poolManagerAddress, + topics: [INITIALIZE_TOPIC], + fromBlock, + toBlock: blockNumber, + }); + + for (const log of logs) { + if (log.topics.length < 4) continue; + const currency0 = ethers.getAddress( + '0x' + log.topics[2].slice(26), + ) as Address; + const currency1 = ethers.getAddress( + '0x' + log.topics[3].slice(26), + ) as Address; + const data = ethers.AbiCoder.defaultAbiCoder().decode( + ['uint24', 'int24', 'address', 'uint160', 'int24'], + log.data, + ); + const fee = Number(data[0]); + const tickSpacing = Number(data[1]); + const hooks = data[2]; + if (hooks?.toLowerCase() !== hookAddress.toLowerCase()) continue; + if ( + poolKeys.some( + (k) => + k.currency0.toLowerCase() === currency0.toLowerCase() && + k.currency1.toLowerCase() === currency1.toLowerCase(), + ) + ) { + if (initializeBlockNumber === undefined) + initializeBlockNumber = Number(log.blockNumber); + const poolKey: PoolKeyRecord = { + currency0, + currency1, + fee, + tickSpacing, + hooks: hookAddress, + }; + poolEntriesFromEvent.push({ poolKey, poolId: log.topics[1] }); + } + } + } catch { + /* ignore */ + } + + return { + hookAddress, + hookDeployed, + poolsInitialized: poolEntriesFromEvent.length, + blockNumber: initializeBlockNumber, + poolEntriesFromEvent: + poolEntriesFromEvent.length > 0 ? poolEntriesFromEvent : undefined, + }; +} + +function runMineHookWorker( + scriptPath: string, + args: string[], + execEnv: NodeJS.ProcessEnv, + projectRoot: string, + streamOutput: boolean, +): Promise<{ salt: string; output: string }> { + return new Promise((resolve, reject) => { + const child = spawn('bash', args, { + cwd: projectRoot, + env: execEnv, + stdio: ['inherit', 'pipe', streamOutput ? 'inherit' : 'pipe'], + }); + + let output = ''; + child.stdout!.on('data', (chunk) => { + const str = chunk.toString(); + output += str; + if (streamOutput) process.stdout.write(chunk); + }); + + child.on('close', (code) => { + if (code === 0) { + const saltMatch = output.match( + /Salt \(bytes32\):\s*(0x[a-fA-F0-9]{64})/, + ); + if (saltMatch) resolve({ salt: saltMatch[1], output }); + else reject(new Error('Could not parse salt from mine_hook.sh output')); + } else { + const err = new Error( + `mine_hook.sh exited with code ${code}`, + ) as Error & { + stdout?: string; + }; + err.stdout = output; + reject(err); + } + }); + child.on('error', reject); + }); +} + +async function mineSalt( + constructorArgs: string, + protocolId: number, + log: Logger, + deployerAddress?: Address, + jobs = 1, +): Promise { + const scriptPath = join(projectRoot, 'mine_hook.sh'); + const protocolIdHex = `0x${protocolId.toString(16).toUpperCase()}`; + + log.info(`Mining salt for protocol ${protocolIdHex}...`); + log.info(`Constructor args: ${constructorArgs.substring(0, 66)}...`); + if (deployerAddress) log.info(`Deployer address: ${deployerAddress}`); + if (jobs > 1) log.info(`Running ${jobs} parallel mining workers...`); + + const baseArgs = [scriptPath, constructorArgs, protocolIdHex]; + if (deployerAddress) baseArgs.push('500', deployerAddress); + + // Strip sensitive credentials — salt mining workers only need PATH and build tooling + const { PRIVATE_KEY: _pk, ...safeEnv } = process.env; + const execEnv = { + ...safeEnv, + ...(log.verboseEnabled && { FORGE_VERBOSE: '1' }), + }; + + try { + if (jobs === 1) { + const result = await runMineHookWorker( + scriptPath, + baseArgs, + execEnv, + projectRoot, + true, + ); + log.success(`Found salt: ${result.salt}`); + return result.salt; + } + + const children: ReturnType[] = []; + const workerPromises: Promise< + { salt: string } | { failed: true; output: string } + >[] = []; + + for (let i = 0; i < jobs; i++) { + const child = spawn('bash', baseArgs, { + cwd: projectRoot, + env: execEnv, + stdio: ['inherit', 'pipe', 'pipe'], + }); + children.push(child); + + let output = ''; + child.stdout!.on('data', (chunk) => { + output += chunk.toString(); + }); + child.stderr!.on('data', (chunk) => { + output += chunk.toString(); + }); + + const promise = new Promise< + { salt: string } | { failed: true; output: string } + >((resolve) => { + child.on('close', (code) => { + if (code === 0) { + const saltMatch = output.match( + /Salt \(bytes32\):\s*(0x[a-fA-F0-9]{64})/, + ); + if (saltMatch) resolve({ salt: saltMatch[1] }); + else resolve({ failed: true, output }); + } else { + resolve({ failed: true, output }); + } + }); + child.on('error', (err) => + resolve({ failed: true, output: err.message }), + ); + }); + workerPromises.push(promise); + } + + const killAll = () => { + children.forEach((c) => { + try { + c.kill('SIGTERM'); + } catch { + /* ignore */ + } + }); + }; + + const salt = await new Promise((resolve, reject) => { + let failedCount = 0; + let lastFailedOutput = ''; + workerPromises.forEach((p) => { + p.then((result) => { + if ('salt' in result) { + killAll(); + resolve(result.salt); + } else { + failedCount++; + lastFailedOutput = result.output; + if (failedCount === jobs) { + const err = new Error('All mining workers failed') as Error & { + stdout?: string; + }; + err.stdout = lastFailedOutput; + reject(err); + } + } + }); + }); + }); + + log.success(`Found salt: ${salt}`); + return salt; + } catch (error) { + log.error('Error mining salt:', error); + const execErr = error as { stdout?: string; stderr?: string }; + log.dumpForgeOutput({ + stdout: execErr.stdout, + stderr: execErr.stderr, + label: 'Forge/mine_hook (from failed worker)', + }); + throw error; + } +} + +function selfDeployPool( + poolConfig: PoolConfig, + poolType: string, + immutables: FactoryImmutables, + salt: string, + rpcUrl: string, + dryRun: boolean, + log: Logger, + priorityGasPrice: string | null = null, +): Address | 'deployed' { + const module = CREATION_MODULES[poolType]; + if (!module) throw new Error(`Unknown pool type: ${poolType}`); + + const rawKey = (process.env.PRIVATE_KEY ?? '').trim(); + const privateKey = rawKey.startsWith('0x') ? rawKey : `0x${rawKey}`; + + const envVars: Record = { + PROTOCOL_ID: module.protocolId.toString(), + SALT: salt, + POOL_MANAGER: immutables.poolManager, + PRIVATE_KEY: privateKey, + ...module.buildSelfDeployEnvVars(poolConfig, immutables), + }; + + log.info(`Self-deploying hook via SelfCreateHook.s.sol...`); + if (dryRun) log.info('(dry run - no broadcast)'); + log.info(`Protocol: ${poolType}, Salt: ${salt.substring(0, 18)}...`); + + try { + const output = execFileSync( + 'forge', + [ + 'script', + 'lib/v4-hooks-public/script/SelfCreateHook.s.sol:SelfCreateHookScript', + '--rpc-url', + rpcUrl, + ...(dryRun ? [] : ['--broadcast']), + ...(priorityGasPrice ? ['--priority-gas-price', priorityGasPrice] : []), + ...(log.verboseEnabled ? ['-vvvv'] : []), + ], + { + encoding: 'utf-8', + cwd: projectRoot, + // envVars already contains PRIVATE_KEY; strip it from process.env to avoid duplication + env: (() => { + const { PRIVATE_KEY: _pk, ...rest } = process.env; + return { ...rest, ...envVars }; + })(), + }, + ); + + if (log.verboseEnabled) + log.verbose(`\n--- Forge script output ---\n${output}`); + + const hookMatch = output.match(/Hook Address:\s*(0x[a-fA-F0-9]{40})/); + if (hookMatch) { + const addr = ethers.getAddress(hookMatch[1]) as Address; + log.success(`Hook deployed at: ${addr}`); + return addr; + } + + log.success(`Self-deploy completed`); + return 'deployed'; + } catch (error) { + log.error('Error in self-deploy:', error); + const execErr = error as { stdout?: string; stderr?: string }; + log.dumpForgeOutput({ + stdout: execErr.stdout, + stderr: execErr.stderr, + label: 'Forge', + }); + throw error; + } +} + +async function createPool( + signer: ethers.Signer, + factoryAddress: Address, + poolConfig: PoolConfig, + poolType: string, + salt: string, + log: Logger, + dryRun: boolean, +): Promise<{ hookAddress: Address | ''; blockNumber: number; txHash: string }> { + const module = CREATION_MODULES[poolType]; + if (!module) throw new Error(`Unknown pool type: ${poolType}`); + + const args = module.buildCreatePoolArgs(poolConfig, salt); + const factory = new ethers.Contract( + factoryAddress, + module.factoryAbi, + signer, + ); + + log.info(`Calling createPool on factory ${factoryAddress}...`); + if (dryRun) log.info('(dry run - no broadcast)'); + log.info( + `Args: ${args.map((a, i) => `${i}: ${String(a).substring(0, 66)}...`).join(', ')}`, + ); + + try { + if (dryRun) { + await factory.createPool.staticCall(...args); + log.success(`Dry run: createPool would succeed (simulation passed)`); + return { hookAddress: '', blockNumber: 0, txHash: '' }; + } + + const tx = await factory.createPool(...args); + log.success(`Transaction sent: ${tx.hash}`); + + const receipt = await tx.wait(); + log.success(`Transaction confirmed in block ${receipt!.blockNumber}`); + + const hookDeployedEvent = receipt!.logs.find((l: ethers.Log) => { + try { + const parsed = factory.interface.parseLog({ + topics: l.topics as string[], + data: l.data, + }); + return parsed?.name === 'HookDeployed'; + } catch { + return false; + } + }); + + if (hookDeployedEvent) { + const parsed = factory.interface.parseLog({ + topics: hookDeployedEvent.topics as string[], + data: hookDeployedEvent.data, + }); + const hookAddress = ethers.getAddress( + (parsed?.args.hook || parsed?.args[0]) as string, + ) as Address; + log.success(`Hook deployed at: ${hookAddress}`); + return { + hookAddress, + blockNumber: Number(receipt!.blockNumber), + txHash: tx.hash, + }; + } + + return { + hookAddress: '', + blockNumber: Number(receipt!.blockNumber), + txHash: tx.hash, + }; + } catch (error) { + log.error('Error creating pool:', error); + throw error; + } +} + +async function main() { + const { + jsonFile, + factoryAddress, + selfDeploy, + rpcUrl, + chainId: parsedChainId, + registryDir, + dryRun, + verbose, + startAt, + jobs, + priorityGasPrice, + verify, + } = parseArgs(); + + const log = createLogger({ verbose }); + + const provider = new ethers.JsonRpcProvider(rpcUrl); + const privateKey = process.env.PRIVATE_KEY!; + const signer = new ethers.Wallet(privateKey, provider); + + log.banner({ + title: 'Pool Creation Script', + jsonFile, + mode: selfDeploy ? 'Self-Deploy' : 'Factory', + factoryAddress: factoryAddress ?? undefined, + rpcUrl, + registryDir, + dryRun, + verbose, + startAt, + jobs, + priorityGasPrice, + verify: verify.enabled, + signerAddress: signer.address, + }); + + if (selfDeploy) { + const allPools = loadJsonFile(jsonFile, log); + const pools = startAt > 1 ? allPools.slice(startAt - 1) : allPools; + if (startAt > 1 && pools.length === 0) { + log.error( + `Error: --start-at ${startAt} exceeds pool count (${allPools.length})`, + ); + process.exit(1); + } + log.info( + `Loaded ${allPools.length} pool configuration(s)${ + startAt > 1 + ? `, processing from index ${startAt} (${pools.length} remaining)` + : '' + }`, + ); + log.info(''); + + const chainId = + parsedChainId ?? Number((await provider.getNetwork()).chainId); + + // Determine module from first pool (loadJsonFile enforces a single poolType) + const firstPoolType = pools[0].poolType; + const firstModule = CREATION_MODULES[firstPoolType]; + + if (firstModule.isSingleton) { + // --- Singleton flow: deploy once, then initialize a V4 pool per config --- + + if (!firstModule.aggregatorEnvKey || !firstModule.buildInitializeArgs) { + log.error( + `Module ${firstPoolType} is marked as singleton but is missing aggregatorEnvKey or buildInitializeArgs`, + ); + process.exit(1); + } + + const envKey = `${firstModule.aggregatorEnvKey}_${chainId}`; + let hookAddress = (process.env[envKey] ?? '').trim() || null; + + if (hookAddress) { + log.info( + `Reusing existing ${firstPoolType} singleton at ${hookAddress} (${envKey})`, + ); + } else { + log.info( + `No ${envKey} found — deploying ${firstPoolType} singleton aggregator...`, + ); + const immutables = firstModule.getImmutablesFromEnv(chainId); + const constructorArgs = firstModule.encodeConstructorArgs( + pools[0], + immutables, + ); + const salt = await mineSalt( + constructorArgs, + firstModule.protocolId, + log, + CREATE2_DEPLOYER, + jobs, + ); + const deployed = selfDeployPool( + pools[0], + firstPoolType, + immutables, + salt, + rpcUrl, + dryRun, + log, + priorityGasPrice, + ); + + if (!deployed || deployed === 'deployed') { + if (dryRun) { + log.success( + 'Dry run: singleton self-deploy script completed — skipping pool initialization (no real address without broadcast)', + ); + return; + } + log.error( + 'Could not determine deployed aggregator address — aborting', + ); + process.exit(1); + } + hookAddress = deployed; + + if (!dryRun) { + const envFilePath = join(projectRoot, 'aggregator-hooks', '.env'); + appendToEnvFile(envFilePath, envKey, hookAddress); + log.success( + `Wrote ${envKey}=${hookAddress} to aggregator-hooks/.env`, + ); + + // Wait for the deployment tx to be confirmed before initializing pools. + // Without this, the first pool's beforeInitialize call hits an unconfirmed contract. + log.info( + `Waiting for aggregator contract to be confirmed on-chain...`, + ); + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + const code = await provider.getCode(hookAddress); + if (code && code !== '0x') { + log.success(`Aggregator confirmed at ${hookAddress}`); + break; + } + await new Promise((r) => setTimeout(r, 2_000)); + } + } + + if (verify.enabled && !dryRun) { + verifyContract( + hookAddress as Address, + firstModule.contractIdentifier, + constructorArgs, + chainId, + verify, + log, + ); + } + } + + const immutables = firstModule.getImmutablesFromEnv(chainId); + + for (let j = 0; j < pools.length; j++) { + const i = startAt - 1 + j; + const poolConfig = pools[j]; + + log.section( + `Initializing Pool ${i + 1}/${allPools.length} (${firstPoolType})`, + ); + + try { + const [poolKey, sqrtPriceX96] = firstModule.buildInitializeArgs!( + poolConfig, + hookAddress as Address, + ); + const result = await initializeSingletonPool( + signer, + immutables.poolManager as Address, + poolKey, + sqrtPriceX96, + log, + dryRun, + ); + + if (registryDir && !dryRun) { + const poolKeys = firstModule.buildPoolKeys( + poolConfig, + hookAddress as Address, + ); + if (poolKeys.length > 0) { + const blockNumber = + result?.blockNumber ?? Number(await provider.getBlockNumber()); + appendToRegistryFile( + registryDir, + firstPoolType, + { + pools: poolKeys.map((pk) => ({ + poolKey: pk, + poolId: computePoolId(pk), + })), + metadata: { + externalPool: firstModule.getExternalPool(poolConfig), + hookAddress: hookAddress as Address, + txHash: result?.txHash, + blockNumber, + }, + }, + log, + ); + } + } + log.success(`Successfully initialized pool ${i + 1}`); + } catch (error) { + log.error(`Failed to initialize pool ${i + 1}:`, error); + continue; + } + } + } else { + // --- Factory/self-deploy flow: deploy a new hook per pool config --- + for (let j = 0; j < pools.length; j++) { + const i = startAt - 1 + j; + const poolConfig = pools[j]; + const poolType = poolConfig.poolType; + const module = CREATION_MODULES[poolType]; + const immutables = module.getImmutablesFromEnv(chainId); + + log.section( + `Processing Pool ${i + 1}/${allPools.length} (${poolType})`, + ); + + try { + const constructorArgs = module.encodeConstructorArgs( + poolConfig, + immutables, + ); + const salt = await mineSalt( + constructorArgs, + module.protocolId, + log, + CREATE2_DEPLOYER, + jobs, + ); + const hookAddress = selfDeployPool( + poolConfig, + poolType, + immutables, + salt, + rpcUrl, + dryRun, + log, + priorityGasPrice, + ); + + if (hookAddress && hookAddress !== 'deployed') { + if (registryDir) { + const poolKeys = module.buildPoolKeys(poolConfig, hookAddress); + if (poolKeys.length > 0) { + const blockNumber = Number(await provider.getBlockNumber()); + appendToRegistryFile( + registryDir, + poolType, + { + pools: poolKeys.map((poolKey) => ({ + poolKey, + poolId: computePoolId(poolKey), + })), + metadata: { + externalPool: module.getExternalPool(poolConfig), + hookAddress, + blockNumber, + }, + }, + log, + ); + } + } + if (verify.enabled && !dryRun) { + verifyContract( + hookAddress, + module.contractIdentifier, + constructorArgs, + chainId, + verify, + log, + ); + } + } + log.success(`Successfully created pool ${i + 1}`); + } catch (error) { + log.error(`Failed to create pool ${i + 1}:`, error); + const execErr = error as { stdout?: string; stderr?: string }; + log.dumpForgeOutput({ + stdout: execErr.stdout, + stderr: execErr.stderr, + label: 'Forge', + }); + const forgeOutput = [execErr.stdout, execErr.stderr] + .filter(Boolean) + .join('\n'); + if (forgeOutput) { + const immutables = + CREATION_MODULES[poolConfig.poolType].getImmutablesFromEnv( + chainId, + ); + const verification = await verifyDeploymentOnForgeFailure( + provider, + immutables.poolManager, + poolConfig, + poolConfig.poolType, + forgeOutput, + ); + if (verification?.hookDeployed) { + log.error(''); + log.error('Verification (possible false positive):'); + log.error( + ` Hook has code at ${verification.hookAddress} → deployment likely succeeded`, + ); + if (verification.poolsInitialized > 0) { + log.error( + ` Found ${verification.poolsInitialized} Initialize event(s) for this hook → pool(s) initialized on-chain`, + ); + } + log.error(' Check block explorer to confirm.'); + + if (registryDir && !dryRun) { + const recoverPools: PoolEntry[] = + verification.poolEntriesFromEvent ?? + module + .buildPoolKeys(poolConfig, verification.hookAddress) + .map((poolKey) => ({ + poolKey, + poolId: computePoolId(poolKey), + })); + const blockNumber = + verification.blockNumber ?? + Number(await provider.getBlockNumber()); + if (recoverPools.length > 0) { + appendToRegistryFile( + registryDir, + poolConfig.poolType, + { + pools: recoverPools, + metadata: { + externalPool: module.getExternalPool(poolConfig), + hookAddress: verification.hookAddress, + blockNumber, + }, + }, + log, + ); + log.info( + ' Appended to registry despite forge failure (deployment verified on-chain).', + ); + } + } + } + } + continue; + } + } + } + } else { + const allPools = loadJsonFile(jsonFile, log); + const pools = startAt > 1 ? allPools.slice(startAt - 1) : allPools; + if (startAt > 1 && pools.length === 0) { + log.error( + `Error: --start-at ${startAt} exceeds pool count (${allPools.length})`, + ); + process.exit(1); + } + const poolType = pools[0].poolType as string; + const module = CREATION_MODULES[poolType]; + + log.info( + `Loaded ${allPools.length} pool configuration(s) (poolType: ${poolType})${ + startAt > 1 + ? `, processing from index ${startAt} (${pools.length} remaining)` + : '' + }`, + ); + log.info(''); + + log.info('Reading factory immutables...'); + const factoryImmutables = await module.readFactoryImmutables( + provider, + factoryAddress!, + ); + const chainId = + parsedChainId ?? Number((await provider.getNetwork()).chainId); + log.info(`POOL_MANAGER: ${factoryImmutables.poolManager}`); + for (const [key, val] of Object.entries(factoryImmutables)) { + if (key !== 'poolManager' && val) log.info(`${key}: ${val}`); + } + log.info(''); + + for (let j = 0; j < pools.length; j++) { + const i = startAt - 1 + j; + const poolConfig = pools[j]; + + log.section(`Processing Pool ${i + 1}/${allPools.length}`); + + try { + const constructorArgs = module.encodeConstructorArgs( + poolConfig, + factoryImmutables, + ); + const salt = await mineSalt( + constructorArgs, + module.protocolId, + log, + factoryAddress!, + jobs, + ); + const result = await createPool( + signer, + factoryAddress!, + poolConfig, + poolType, + salt, + log, + dryRun, + ); + + if (result.hookAddress && !dryRun) { + if (registryDir) { + const poolKeys = module.buildPoolKeys( + poolConfig, + result.hookAddress, + ); + if (poolKeys.length > 0) { + appendToRegistryFile( + registryDir, + poolType, + { + pools: poolKeys.map((poolKey) => ({ + poolKey, + poolId: computePoolId(poolKey), + })), + metadata: { + externalPool: module.getExternalPool(poolConfig), + hookAddress: result.hookAddress, + txHash: result.txHash, + blockNumber: result.blockNumber, + }, + }, + log, + ); + } + } + if (verify.enabled) { + verifyContract( + result.hookAddress, + module.contractIdentifier, + constructorArgs, + chainId, + verify, + log, + ); + } + } + log.success( + `Successfully created pool ${i + 1}${dryRun ? ' (dry run)' : ''}`, + ); + } catch (error) { + log.error(`Failed to create pool ${i + 1}:`, error); + const execErr = error as { stdout?: string; stderr?: string }; + log.dumpForgeOutput({ + stdout: execErr.stdout, + stderr: execErr.stderr, + label: 'Forge', + }); + continue; + } + } + } + + log.info('\n=== Done ==='); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/aggregator-hooks/src/logger.ts b/aggregator-hooks/src/logger.ts new file mode 100644 index 00000000..96e9e928 --- /dev/null +++ b/aggregator-hooks/src/logger.ts @@ -0,0 +1,117 @@ +/** Redact the API key segment from RPC URLs (e.g. Infura/Alchemy path keys). */ +function redactRpcUrl(url: string): string { + try { + const u = new URL(url); + const parts = u.pathname.split('/').filter(Boolean); + if (parts.length > 0) { + const last = parts[parts.length - 1]; + if (last.length >= 16) { + parts[parts.length - 1] = last.slice(0, 4) + '***'; + u.pathname = '/' + parts.join('/'); + } + } + return u.toString(); + } catch { + return url; + } +} + +/** + * Config passed to banner() for startup output. + */ +export interface BannerConfig { + title: string; + jsonFile: string; + mode: string; + factoryAddress?: string | null; + rpcUrl: string; + registryDir?: string; + dryRun?: boolean; + verbose?: boolean; + startAt?: number; + jobs?: number; + priorityGasPrice?: string | null; + verify?: boolean; + signerAddress?: string; +} + +/** + * Lightweight logger for createPools CLI. + * Centralizes output formatting and verbose-mode handling. + */ +export interface Logger { + verboseEnabled: boolean; + info: (msg: string) => void; + success: (msg: string) => void; + error: (msg: string, err?: unknown) => void; + verbose: (msg: string) => void; + section: (title: string) => void; + banner: (config: BannerConfig) => void; + dumpForgeOutput: (opts: { + stdout?: string; + stderr?: string; + label?: string; + }) => void; +} + +export function createLogger(opts: { verbose: boolean }): Logger { + const { verbose } = opts; + + return { + verboseEnabled: verbose, + info: (msg: string) => console.log(msg), + success: (msg: string) => console.log(`✓ ${msg}`), + error: (msg: string, err?: unknown) => { + console.error(msg); + if (err instanceof Error) console.error(err.message); + const e = err as { data?: unknown; reason?: string }; + if (e?.data) console.error('Error data:', e.data); + if (e?.reason) console.error('Revert reason:', e.reason); + }, + verbose: (msg: string) => { + if (verbose) console.log(msg); + }, + section: (title: string) => console.log(`\n--- ${title} ---`), + banner: (config: BannerConfig) => { + const lines: string[] = [ + `=== ${config.title} ===`, + `JSON File: ${config.jsonFile}`, + `Mode: ${config.mode}`, + ...(config.factoryAddress + ? [`Factory Address: ${config.factoryAddress}`] + : []), + `RPC URL: ${redactRpcUrl(config.rpcUrl)}`, + ...(config.registryDir ? [`Registry dir: ${config.registryDir}`] : []), + ...(config.dryRun + ? ['DRY RUN: forge scripts will simulate without broadcasting'] + : []), + ...(config.verbose + ? ['VERBOSE: forge scripts will run with -vvvv'] + : []), + ...(config.startAt && config.startAt > 1 + ? [ + `Starting at pool index: ${config.startAt} (skipping first ${config.startAt - 1} pool(s))`, + ] + : []), + ...(config.jobs && config.jobs > 1 + ? [`Salt mining: ${config.jobs} parallel workers`] + : []), + ...(config.priorityGasPrice + ? [`Priority gas price: ${config.priorityGasPrice}`] + : []), + ...(config.verify ? ['Contract verification: enabled (--verify)'] : []), + ...(config.signerAddress + ? [`Using signer: ${config.signerAddress}`] + : []), + ]; + lines.forEach((line) => console.log(line)); + if (lines.length > 0) console.log(''); + }, + dumpForgeOutput: ({ stdout, stderr, label = 'Forge' }) => { + if (stdout != null && stdout !== '') + console.error(`\n--- ${label} stdout ---\n`, stdout); + if (stderr != null && stderr !== '') + console.error(`\n--- ${label} stderr ---\n`, stderr); + }, + }; +} diff --git a/aggregator-hooks/src/utils.ts b/aggregator-hooks/src/utils.ts new file mode 100644 index 00000000..207a6519 --- /dev/null +++ b/aggregator-hooks/src/utils.ts @@ -0,0 +1,57 @@ +/** + * Shared utilities for polling and historical discovery scripts. + * Centralizes rate limiting, concurrency control, and file I/O helpers. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +/** Global RPC rate limiter: token-bucket style, strictly serialized. */ +export function pRateLimit(rps: number): () => Promise { + if (rps <= 0) return async () => {}; + const minGapMs = 1000 / rps; + let nextAllowed = 0; + return async function acquire(): Promise { + const now = Date.now(); + if (now < nextAllowed) + await new Promise((r) => setTimeout(r, nextAllowed - now)); + nextAllowed = Math.max(now, nextAllowed) + minGapMs; + }; +} + +/** Concurrency limiter: at most `concurrency` async tasks in flight at once. */ +export function pLimit(concurrency: number) { + let active = 0; + const queue: (() => void)[] = []; + return async function limit(fn: () => Promise): Promise { + if (active >= concurrency) await new Promise((r) => queue.push(r)); + active++; + try { + return await fn(); + } finally { + active--; + queue.shift()?.(); + } + }; +} + +/** Ensure the parent directory of a file path exists. */ +export function ensureDirForFile(filePath: string): void { + fs.mkdirSync(path.dirname(path.resolve(filePath)), { recursive: true }); +} + +/** Read and parse a JSON file; returns null on any error. */ +export function safeReadJson(filePath: string): T | null { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) as T; + } catch { + return null; + } +} + +/** Write a file atomically via a .tmp rename to prevent partial writes on crash. */ +export function atomicWriteFile(filePath: string, contents: string): void { + ensureDirForFile(filePath); + const abs = path.resolve(filePath); + fs.writeFileSync(abs + '.tmp', contents); + fs.renameSync(abs + '.tmp', abs); +} diff --git a/aggregator-hooks/tsconfig.json b/aggregator-hooks/tsconfig.json new file mode 100644 index 00000000..485caec0 --- /dev/null +++ b/aggregator-hooks/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "baseUrl": ".", + "paths": { + "@src/cli": ["src/cli.ts"], + "@src/utils": ["src/utils.ts"] + }, + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": [ + "src/**/*.ts", + "creation-modules/*.ts", + "historical/**/*.ts", + "polling/**/*.ts", + "abis/**/*.ts" + ], + "exclude": ["node_modules", "dist"] +} diff --git a/foundry.lock b/foundry.lock index a7e21326..0db4b853 100644 --- a/foundry.lock +++ b/foundry.lock @@ -17,6 +17,21 @@ "lib/solidity-lib": { "rev": "3fcc8ee6d5c7dea3283416cbcee601d89504a243" }, + "lib/v4-core": { + "tag": { + "name": "v4.0.0", + "rev": "e50237c43811bd9b526eff40f26772152a42daba" + } + }, + "lib/v4-hooks-public": { + "rev": "0ec65960e213d0bc38be91004c66d550a085c0dc" + }, + "src/pkgs/calibur": { + "rev": "69d5eb61498ffac7740530310b270459f2ae2a20" + }, + "src/pkgs/mixed-quoter": { + "rev": "d576527bff2e7c9db5434bb2b3806fd184610865" + }, "src/pkgs/permit2": { "rev": "cc56ad0f3439c502c246fc5cfcc3db92bb8b7219" }, @@ -29,6 +44,9 @@ "src/pkgs/universal-router": { "rev": "3663f6db6e2fe121753cd2d899699c2dc75dca86" }, + "src/pkgs/universal-router-2_0": { + "rev": "3663f6db6e2fe121753cd2d899699c2dc75dca86" + }, "src/pkgs/util-contracts": { "rev": "0e84aa540ecafacb22f9e61a5e684c5c32a21f56" }, diff --git a/lib/forge-chronicles b/lib/forge-chronicles index ee772636..000cd249 160000 --- a/lib/forge-chronicles +++ b/lib/forge-chronicles @@ -1 +1 @@ -Subproject commit ee77263652129d7492a79a7c4db3fb5d36a3f079 +Subproject commit 000cd249d0bad6caaa907c12f2d4a8a7608ac5f0 diff --git a/lib/v4-core b/lib/v4-core new file mode 160000 index 00000000..d153b048 --- /dev/null +++ b/lib/v4-core @@ -0,0 +1 @@ +Subproject commit d153b048868a60c2403a3ef5b2301bb247884d46 diff --git a/lib/v4-hooks-public b/lib/v4-hooks-public new file mode 160000 index 00000000..0ec65960 --- /dev/null +++ b/lib/v4-hooks-public @@ -0,0 +1 @@ +Subproject commit 0ec65960e213d0bc38be91004c66d550a085c0dc diff --git a/mine_hook.sh b/mine_hook.sh new file mode 100644 index 00000000..d72235ba --- /dev/null +++ b/mine_hook.sh @@ -0,0 +1,143 @@ +#!/bin/bash + +# Mine an aggregator hook address by searching with incrementing salt offsets + +show_help() { + echo "Mine an aggregator hook address by searching with incrementing salt offsets" + echo "" + echo "Usage: $0 [max_attempts] [deployer_address]" + echo "" + echo "Arguments:" + echo " constructor_args Hex-encoded constructor arguments (e.g., 0x000000000000000000000000...)" + echo " protocol_id Protocol identifier for the hook type:" + echo " 0xC1 - StableSwap" + echo " 0xC2 - StableSwap-NG" + echo " 0xF1 - FluidDexT1" + echo " 0xF2 - FluidDexV2 (not yet implemented)" + echo " 0xF3 - FluidDexLite" + echo " max_attempts Optional. Maximum mining attempts (default: 500)" + echo " deployer_address Optional. Address that will deploy the hook." + echo " Use factory address for factory deploys, wallet address for self-deploys." + echo " (default: CREATE2_DEPLOYER 0x4e59b44847b379578588920cA78FbF26c0B4956C)" + echo "" + echo "Examples:" + echo " # Mine with default CREATE2_DEPLOYER" + echo " $0 0x00000000... 0xF3" + echo "" + echo " # Mine with factory as deployer" + echo " $0 0x00000000... 0xF3 500 0xYourFactoryAddress" + echo "" + echo " # Mine with wallet as deployer (for self-deploy)" + echo " $0 0x00000000... 0xF3 500 0xYourWalletAddress" + echo "" + echo "Options:" + echo " -h, --help Show this help message and exit" +} + +# Check for help flag +if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then + show_help + exit 0 +fi + +if [ $# -lt 2 ]; then + echo "Error: Missing required arguments" + echo "" + show_help + exit 1 +fi + +CONSTRUCTOR_ARGS=$1 +PROTOCOL_ID=$2 +MAX_ATTEMPTS=${3:-500} # Default to 500 attempts +DEPLOYER_ADDRESS=${4:-0x4e59b44847b379578588920cA78FbF26c0B4956C} # Default to CREATE2_DEPLOYER +SALT_INCREMENT=160444 # Must match MAX_LOOP in AggregatorHookMiner.sol + +# Validate DEPLOYER_ADDRESS format (0x + 40 hex chars) +if ! [[ "$DEPLOYER_ADDRESS" =~ ^0x[0-9a-fA-F]{40}$ ]]; then + echo "Error: Invalid DEPLOYER_ADDRESS '$DEPLOYER_ADDRESS'" + echo "Expected format: 0x followed by 40 hex characters (e.g., 0x4e59b44847b379578588920cA78FbF26c0B4956C)" + exit 1 +fi + +# Random base offset (0 to 1 quadrillion) so each run searches a different salt region. +# Avoids createCollision when redeploying pools with the same constructor args. +# Use 4 bytes (not 8): bash uses signed 64-bit; od -tu8 can overflow to negative, breaking Solidity's uint256 env parsing and causing the same salts to be searched repeatedly. +RANDOM_BASE=$(($(LC_ALL=C od -An -N4 -tu4 /dev/urandom 2>/dev/null || echo $RANDOM) % 1000000000000000)) +[ -z "$RANDOM_BASE" ] && RANDOM_BASE=0 + +echo "Starting aggregator hook mining..." +echo "Random base offset: $RANDOM_BASE (avoids collisions on redeploy)" +echo "Constructor args: $CONSTRUCTOR_ARGS" +echo "Protocol ID: $PROTOCOL_ID" +echo "Deployer address: $DEPLOYER_ADDRESS" +echo "Max attempts: $MAX_ATTEMPTS" +echo "Salt increment per attempt: $SALT_INCREMENT" +echo "" + +# Run from contracts/ directory (where this script lives) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +if [ ! -f "lib/v4-hooks-public/script/MineAggregatorHook.s.sol" ]; then + echo "Error: MineAggregatorHook.s.sol not found at lib/v4-hooks-public/script/MineAggregatorHook.s.sol" + echo "Add v4-hooks-public and checkout main:" + echo " git submodule add -b main https://github.com/Uniswap/v4-hooks-public lib/v4-hooks-public" + echo " cd lib/v4-hooks-public && git fetch origin main && git checkout main" + echo " git submodule update --init --recursive" + exit 1 +fi + +for ((i=0; i "$FORGE_OUTPUT" 2>&1 & + FORGE_PID=$! + + # Heartbeat: update same line every 15s so user knows it's still running + ELAPSED=0 + while kill -0 $FORGE_PID 2>/dev/null; do + printf "\r ... still searching (Attempt %d/%d, %ds elapsed) " $((i + 1)) $MAX_ATTEMPTS $ELAPSED + sleep 15 + ELAPSED=$((ELAPSED + 15)) + done + printf "\r%-70s\r" "" # clear the heartbeat line + wait $FORGE_PID 2>/dev/null + FORGE_EXIT=$? + DURATION=$(( $(date +%s) - START_SEC )) + OUTPUT=$(cat "$FORGE_OUTPUT") + rm -f "$FORGE_OUTPUT" + + # Check if we found a valid salt (look for "Hook Address" in output) + if echo "$OUTPUT" | grep -q "Hook Address:"; then + echo "" + echo "SUCCESS! Found valid salt." + echo "" + echo "$OUTPUT" | grep -A 10 "=== Aggregator Hook Mining Results ===" + exit 0 + fi + + # Check if it was a "could not find salt" error (expected, continue searching) + if echo "$OUTPUT" | grep -q "could not find salt"; then + echo " No match found in this range (forge exit=$FORGE_EXIT, ${DURATION}s), continuing..." + continue + fi + + # Some other error occurred + echo " Unexpected error (forge exit=$FORGE_EXIT, ${DURATION}s):" + echo "$OUTPUT" + exit 1 +done + +echo "" +echo "FAILED: Could not find valid salt after $MAX_ATTEMPTS attempts" +echo "Total salts searched: $((MAX_ATTEMPTS * SALT_INCREMENT))" +exit 1 diff --git a/remappings.txt b/remappings.txt index cca3ff4d..5d299716 100644 --- a/remappings.txt +++ b/remappings.txt @@ -61,4 +61,11 @@ src/pkgs/mixed-quoter:@uniswap/swap-router-contracts=src/pkgs/swap-router-contra @uniswap/lib=lib/solidity-lib @uniswap/v2-core=src/pkgs/v2-core @uniswap/v3-core=src/pkgs/v3-core -@uniswap/v4-core=src/pkgs/v4-core \ No newline at end of file +@uniswap/v4-core=src/pkgs/v4-core +@aggregator-hooks/=lib/v4-hooks-public/src/aggregator-hooks/ +script/:@uniswap/v4-core/=lib/v4-hooks-public/lib/v4-core/ +lib/v4-hooks-public:@openzeppelin/contracts=lib/v4-hooks-public/lib/openzeppelin-contracts/contracts +lib/v4-hooks-public:@uniswap/v4-periphery/=lib/v4-hooks-public/lib/v4-periphery/ +lib/v4-hooks-public:@uniswap/v4-core/=lib/v4-hooks-public/lib/v4-core/ +lib/v4-hooks-public:@protocol-fees/=lib/v4-hooks-public/lib/protocol-fees/src/ +lib/v4-hooks-public/lib/protocol-fees:v4-core/=lib/v4-hooks-public/lib/v4-core/src/ \ No newline at end of file diff --git a/src/pkgs/v4-core b/src/pkgs/v4-core index 46c68346..d153b048 160000 --- a/src/pkgs/v4-core +++ b/src/pkgs/v4-core @@ -1 +1 @@ -Subproject commit 46c6834698c48bc4a463a86d8420f4eb1d7f3b75 +Subproject commit d153b048868a60c2403a3ef5b2301bb247884d46